package website import ( "context" "errors" "fmt" "net/http" "strings" "time" "git.handmade.network/hmn/hmn/src/assets" "git.handmade.network/hmn/hmn/src/config" "git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/discord" "git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/templates" "git.handmade.network/hmn/hmn/src/utils" "github.com/google/uuid" ) // This callback handles Discord account linking whether the user is signed in // or not. In all cases, the end state is that the user is signed into a // Handmade Network account with a linked Discord account. HMN accounts will be // created as necessary. // // If we initiate OAuth while logged in, we will use the current session's CSRF // token as the OAuth state. Otherwise, we will generate a new entry in the // pending_login table with an equivalently random token and use that token for // the state. // // Considerations: // // | | Already signed in | Not signed in | // |-----------------------|----------------------|-------------------------------| // | No matching info | Link Discord account | Create HMN account | // |-----------------------| to current HMN |-------------------------------| // | Matching Discord user | account (stealing is | Log into HMN account and link | // |-----------------------| ok, but make sure | Discord user to it. (Double- | // | One matching email | any other accounts | check Discord link settings.) | // |-----------------------| are unlinked) |-------------------------------| // | More than one | | Fail login | // | matching email | | | // |-----------------------|----------------------|-------------------------------| func DiscordOAuthCallback(c *RequestContext) ResponseData { query := c.Req.URL.Query() var destinationUrl string tx, err := c.Conn.Begin(c) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to start transaction for Discord OAuth")) } defer tx.Rollback(c) // Check the state, figure out where we're going state := query.Get("state") if c.CurrentUser == nil { // Check the state against all our pending signins - if none is found, // then CSRF'd!!!! (or the login just expired) pendingLogin, err := db.QueryOne[models.PendingLogin](c, c.Conn, ` SELECT $columns FROM pending_login WHERE id = $1 AND expires_at > CURRENT_TIMESTAMP `, state, ) if err == db.NotFound { c.Logger.Warn().Str("userId", c.CurrentUser.Username).Msg("user failed Discord OAuth state validation - potential attack?") res := c.Redirect("/", http.StatusSeeOther) logoutUser(c, &res) return res } else if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to look up pending login")) } destinationUrl = pendingLogin.DestinationUrl // Delete the pending login; we're done with it _, err = tx.Exec(c, `DELETE FROM pending_login WHERE id = $1`, pendingLogin.ID) if err != nil { c.Logger.Warn().Str("id", pendingLogin.ID).Err(err).Msg("failed to delete pending login") } } else { // Check the state against the current session - if it does not match, // then CSRF'd!!!! if state != c.CurrentSession.CSRFToken { c.Logger.Warn().Str("userId", c.CurrentUser.Username).Msg("user failed Discord OAuth state validation - potential attack?") res := c.Redirect("/", http.StatusSeeOther) logoutUser(c, &res) return res } // The only way into OAuth when logged in is when linking your Discord // account in settings. destinationUrl = hmnurl.BuildUserSettings("discord") } // Check for error values and redirect back to from whence they came if errCode := query.Get("error"); errCode != "" { if errCode == "access_denied" { // This occurs when the user cancels. Just go back so they can try again. var dest string if c.CurrentUser == nil { // Send 'em back to the login page for another go, with the // same destination dest = hmnurl.BuildLoginPage(destinationUrl) } else { dest = hmnurl.BuildUserSettings("discord") } return c.Redirect(dest, http.StatusSeeOther) } else { return c.RejectRequest("Failed to authenticate with Discord.") } } // Do the actual token exchange code := query.Get("code") authRes, err := discord.ExchangeOAuthCode(c, code, hmnurl.BuildDiscordOAuthCallback()) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to exchange Discord authorization code")) } expiry := time.Now().Add(time.Duration(authRes.ExpiresIn) * time.Second) user, err := discord.GetCurrentUserAsOAuth(c, authRes.AccessToken) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch Discord user info")) } hmnMember, err := discord.GetGuildMember(c, config.Config.Discord.GuildID, user.ID) if err != nil { if err == discord.NotFound { // nothing, this is fine } else { c.Logger.Error().Err(err).Msg("failed to get HMN Discord member for Discord user") } } // Make the necessary updates in our database (see table above) // Determine which HMN user to associate this Discord login with. This // may not turn anything up, in which case we need to make an account. var hmnUser *models.User if c.CurrentUser != nil { hmnUser = c.CurrentUser } else { utils.Assert(user.Email, "didn't get an email from Discord! bad scopes?") userFromDiscordID, err := db.QueryOne[models.User](c, tx, ` SELECT $columns{hmn_user} FROM discord_user JOIN hmn_user ON discord_user.hmn_user_id = hmn_user.id WHERE userid = $1 `, user.ID, ) if err == nil { hmnUser = userFromDiscordID } else if err == db.NotFound { // no problem } else { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to look up existing HMN user from Discord OAuth")) } if hmnUser == nil { usersFromDiscordEmail, err := db.Query[models.User](c, tx, ` SELECT $columns FROM hmn_user WHERE LOWER(email) = LOWER($1) `, user.Email, ) if err == nil { if len(usersFromDiscordEmail) > 1 { // oh no why don't we have a unique constraint on emails return c.RejectRequest("There are multiple Handmade Network accounts with this email address. Please sign into one of them separately.") } else if len(usersFromDiscordEmail) == 1 { hmnUser = usersFromDiscordEmail[0] } } else if err == db.NotFound { // no problem } else { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to look up existing HMN user by email")) } } } // Create a new HMN account if no existing account matches if hmnUser == nil { // Check if an HMN account already has this username. We don't link // in this case because usernames can be changed and we don't want // account takeovers. usernameTaken, err := db.QueryOneScalar[bool](c, tx, `SELECT COUNT(*) > 0 FROM hmn_user WHERE LOWER(username) = LOWER($1)`, user.Username, ) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to check if username was taken when logging in with Discord")) } if usernameTaken { return c.RejectRequest(fmt.Sprintf("There is already a Handmade Network account with the username \"%s\".", user.Username)) } var displayName string if hmnMember != nil && hmnMember.Nick != nil { displayName = *hmnMember.Nick } var avatarHash *string if hmnMember != nil && hmnMember.Avatar != nil { avatarHash = hmnMember.Avatar } else if user.Avatar != nil { avatarHash = user.Avatar } var avatarAssetID *uuid.UUID if avatarHash != nil { // Note! Not using the transaction here. Don't want to fail the login due to avatars. if avatarAsset, err := saveDiscordAvatar(c, c.Conn, user.ID, *user.Avatar); err == nil { avatarAssetID = &avatarAsset.ID } else { c.Logger.Warn().Err(err).Msg("failed to save Discord avatar") } } newHMNUser, err := db.QueryOne[models.User](c, tx, ` INSERT INTO hmn_user ( username, name, email, password, avatar_asset_id, date_joined, registration_ip ) VALUES ( $1, $2, $3, '', $4, $5, $6 ) RETURNING $columns `, user.Username, displayName, strings.ToLower(user.Email), avatarAssetID, time.Now(), c.GetIP(), ) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create new HMN user for Discord login")) } hmnUser = newHMNUser } // Add the Discord user data to our database _, err = tx.Exec(c, ` INSERT INTO discord_user (username, discriminator, access_token, refresh_token, avatar, locale, userid, expiry, hmn_user_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (userid) DO UPDATE SET username = EXCLUDED.username, discriminator = EXCLUDED.discriminator, access_token = EXCLUDED.access_token, refresh_token = EXCLUDED.refresh_token, avatar = EXCLUDED.avatar, locale = EXCLUDED.locale, expiry = EXCLUDED.expiry, hmn_user_id = EXCLUDED.hmn_user_id `, user.Username, user.Discriminator, authRes.AccessToken, authRes.RefreshToken, user.Avatar, user.Locale, user.ID, expiry, hmnUser.ID, ) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to save new Discord user info")) } // Mark the HMN user as confirmed - Discord is good enough auth for us _, err = tx.Exec(c, ` UPDATE hmn_user SET status = $1 WHERE id = $2 `, models.UserStatusApproved, hmnUser.ID, ) if err != nil { c.Logger.Error().Err(err).Msg("failed to set user status to approved after linking discord account") // NOTE(asaf): It's not worth failing the request over this, so we're not returning an error to the user. } err = tx.Commit(c) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to save updates from Discord OAuth")) } // Add the role on Discord if hmnMember != nil { err = discord.AddGuildMemberRole(c, user.ID, config.Config.Discord.MemberRoleID) if err != nil { c.Logger.Error().Err(err).Msg("failed to add member role") } } res := c.Redirect(destinationUrl, http.StatusSeeOther) err = loginUser(c, hmnUser, &res) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, err) } return res } func DiscordUnlink(c *RequestContext) ResponseData { tx, err := c.Conn.Begin(c) if err != nil { panic(err) } defer tx.Rollback(c) discordUser, err := db.QueryOne[models.DiscordUser](c, tx, ` SELECT $columns FROM discord_user WHERE hmn_user_id = $1 `, c.CurrentUser.ID, ) if err != nil { if errors.Is(err, db.NotFound) { return c.Redirect(hmnurl.BuildUserSettings("discord"), http.StatusSeeOther) } else { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get Discord user for unlink")) } } _, err = tx.Exec(c, ` DELETE FROM discord_user WHERE id = $1 `, discordUser.ID, ) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete Discord user")) } err = tx.Commit(c) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to commit Discord user delete")) } err = discord.RemoveGuildMemberRole(c, discordUser.UserID, config.Config.Discord.MemberRoleID) if err != nil { c.Logger.Warn().Err(err).Msg("failed to remove member role on unlink") } return c.Redirect(hmnurl.BuildUserSettings("discord"), http.StatusSeeOther) } func DiscordShowcaseBacklog(c *RequestContext) ResponseData { duser, err := db.QueryOne[models.DiscordUser](c, c.Conn, `SELECT $columns FROM discord_user WHERE hmn_user_id = $1`, c.CurrentUser.ID, ) if errors.Is(err, db.NotFound) { // Nothing to do c.Logger.Warn().Msg("could not do showcase backlog because no discord user exists") return c.Redirect(hmnurl.BuildUserProfile(c.CurrentUser.Username), http.StatusSeeOther) } else if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get discord user")) } msgIDs, err := db.QueryScalar[string](c, c.Conn, ` SELECT msg.id FROM discord_message AS msg WHERE msg.user_id = $1 AND msg.channel_id = $2 `, duser.UserID, config.Config.Discord.ShowcaseChannelID, ) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, err) } for _, msgID := range msgIDs { interned, err := discord.FetchInternedMessage(c, c.Conn, msgID) if err != nil && !errors.Is(err, db.NotFound) { return c.ErrorResponse(http.StatusInternalServerError, err) } else if err == nil { // NOTE(asaf): Creating snippet even if the checkbox is off because the user asked us to. err = discord.HandleSnippetForInternedMessage(c, c.Conn, interned, true) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, err) } } } return c.Redirect(hmnurl.BuildUserProfile(c.CurrentUser.Username), http.StatusSeeOther) } func saveDiscordAvatar(ctx context.Context, conn db.ConnOrTx, userID, avatarHash string) (*models.Asset, error) { const size = 256 filename := fmt.Sprintf("%s.png", avatarHash) url := fmt.Sprintf("https://cdn.discordapp.com/avatars/%s/%s?size=%d", userID, filename, size) content, _, err := discord.DownloadDiscordResource(ctx, url) if err != nil { return nil, oops.New(err, "failed to download Discord avatar") } asset, err := assets.Create(ctx, conn, assets.CreateInput{ Content: content, Filename: filename, ContentType: "image/png", Width: size, Height: size, }) if err != nil { return nil, oops.New(err, "failed to save asset for Discord attachment") } return asset, nil } func DiscordBotDebugPage(c *RequestContext) ResponseData { type DiscordBotDebugData struct { templates.BaseData BotEvents []discord.BotEvent } botEvents := discord.GetBotEvents() var res ResponseData res.MustWriteTemplate("discord_bot_debug.html", DiscordBotDebugData{ BaseData: getBaseData(c, "", nil), BotEvents: botEvents, }, c.Perf) return res }