2021-06-22 09:50:40 +00:00
|
|
|
package website
|
|
|
|
|
|
|
|
import (
|
|
|
|
"errors"
|
|
|
|
"net/http"
|
|
|
|
"sort"
|
2021-12-15 01:17:42 +00:00
|
|
|
"strconv"
|
2021-06-22 09:50:40 +00:00
|
|
|
"strings"
|
|
|
|
|
2021-12-29 14:38:23 +00:00
|
|
|
"git.handmade.network/hmn/hmn/src/assets"
|
2021-08-27 17:58:52 +00:00
|
|
|
"git.handmade.network/hmn/hmn/src/auth"
|
|
|
|
"git.handmade.network/hmn/hmn/src/config"
|
2021-06-22 09:50:40 +00:00
|
|
|
"git.handmade.network/hmn/hmn/src/db"
|
2021-08-27 17:58:52 +00:00
|
|
|
"git.handmade.network/hmn/hmn/src/discord"
|
|
|
|
hmnemail "git.handmade.network/hmn/hmn/src/email"
|
2021-12-09 02:04:15 +00:00
|
|
|
"git.handmade.network/hmn/hmn/src/hmndata"
|
2021-08-27 17:58:52 +00:00
|
|
|
"git.handmade.network/hmn/hmn/src/hmnurl"
|
2021-06-22 09:50:40 +00:00
|
|
|
"git.handmade.network/hmn/hmn/src/models"
|
|
|
|
"git.handmade.network/hmn/hmn/src/oops"
|
|
|
|
"git.handmade.network/hmn/hmn/src/templates"
|
2022-03-22 18:07:43 +00:00
|
|
|
"git.handmade.network/hmn/hmn/src/twitch"
|
2022-08-13 19:29:40 +00:00
|
|
|
"git.handmade.network/hmn/hmn/src/utils"
|
2021-12-29 14:38:23 +00:00
|
|
|
"github.com/google/uuid"
|
2023-01-02 21:52:41 +00:00
|
|
|
"github.com/jackc/pgx/v5"
|
2021-06-22 09:50:40 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
type UserProfileTemplateData struct {
|
|
|
|
templates.BaseData
|
|
|
|
ProfileUser templates.User
|
|
|
|
ProfileUserLinks []templates.Link
|
|
|
|
ProfileUserProjects []templates.Project
|
|
|
|
TimelineItems []templates.TimelineItem
|
2021-11-25 03:59:51 +00:00
|
|
|
OwnProfile bool
|
|
|
|
ShowcaseUrl string
|
2021-12-09 04:02:11 +00:00
|
|
|
|
|
|
|
CanAddProject bool
|
|
|
|
NewProjectUrl string
|
2021-12-15 01:17:42 +00:00
|
|
|
|
2022-09-10 21:52:02 +00:00
|
|
|
AdminSetOptionsUrl string
|
|
|
|
AdminNukeUrl string
|
2022-08-05 04:03:45 +00:00
|
|
|
|
|
|
|
SnippetEdit templates.SnippetEdit
|
2021-06-22 09:50:40 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func UserProfile(c *RequestContext) ResponseData {
|
|
|
|
username, hasUsername := c.PathParams["username"]
|
|
|
|
|
|
|
|
if !hasUsername || len(strings.TrimSpace(username)) == 0 {
|
|
|
|
return FourOhFour(c)
|
|
|
|
}
|
|
|
|
|
|
|
|
username = strings.ToLower(username)
|
|
|
|
|
|
|
|
var profileUser *models.User
|
|
|
|
if c.CurrentUser != nil && strings.ToLower(c.CurrentUser.Username) == username {
|
|
|
|
profileUser = c.CurrentUser
|
|
|
|
} else {
|
2022-06-24 21:38:11 +00:00
|
|
|
user, err := hmndata.FetchUserByUsername(c, c.Conn, c.CurrentUser, username, hmndata.UsersQuery{})
|
2021-06-22 09:50:40 +00:00
|
|
|
if err != nil {
|
2021-09-14 04:13:58 +00:00
|
|
|
if errors.Is(err, db.NotFound) {
|
2021-06-22 09:50:40 +00:00
|
|
|
return FourOhFour(c)
|
|
|
|
} else {
|
2021-08-28 12:21:03 +00:00
|
|
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user: %s", username))
|
2021-06-22 09:50:40 +00:00
|
|
|
}
|
|
|
|
}
|
2022-04-16 17:49:29 +00:00
|
|
|
profileUser = user
|
2021-06-22 09:50:40 +00:00
|
|
|
}
|
2021-09-14 04:13:58 +00:00
|
|
|
|
|
|
|
{
|
|
|
|
userIsUnapproved := profileUser.Status != models.UserStatusApproved
|
|
|
|
canViewUnapprovedUser := c.CurrentUser != nil && (c.CurrentUser.ID == profileUser.ID || c.CurrentUser.IsStaff)
|
|
|
|
if userIsUnapproved && !canViewUnapprovedUser {
|
|
|
|
return FourOhFour(c)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-06-22 09:50:40 +00:00
|
|
|
c.Perf.StartBlock("SQL", "Fetch user links")
|
2022-06-24 21:38:11 +00:00
|
|
|
userLinks, err := db.Query[models.Link](c, c.Conn,
|
2021-06-22 09:50:40 +00:00
|
|
|
`
|
|
|
|
SELECT $columns
|
|
|
|
FROM
|
2022-05-07 13:11:05 +00:00
|
|
|
link as link
|
2021-06-22 09:50:40 +00:00
|
|
|
WHERE
|
|
|
|
link.user_id = $1
|
|
|
|
ORDER BY link.ordering ASC
|
|
|
|
`,
|
|
|
|
profileUser.ID,
|
|
|
|
)
|
|
|
|
if err != nil {
|
2021-08-28 12:21:03 +00:00
|
|
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch links for user: %s", username))
|
2021-06-22 09:50:40 +00:00
|
|
|
}
|
2022-04-16 17:49:29 +00:00
|
|
|
profileUserLinks := make([]templates.Link, 0, len(userLinks))
|
|
|
|
for _, l := range userLinks {
|
|
|
|
profileUserLinks = append(profileUserLinks, templates.LinkToTemplate(l))
|
2021-06-22 09:50:40 +00:00
|
|
|
}
|
|
|
|
c.Perf.EndBlock()
|
|
|
|
|
2022-06-24 21:38:11 +00:00
|
|
|
projectsAndStuff, err := hmndata.FetchProjects(c, c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
|
2021-12-11 19:08:10 +00:00
|
|
|
OwnerIDs: []int{profileUser.ID},
|
|
|
|
Lifecycles: models.AllProjectLifecycles,
|
|
|
|
IncludeHidden: true,
|
|
|
|
OrderBy: "all_last_updated DESC",
|
|
|
|
})
|
2021-12-02 10:53:36 +00:00
|
|
|
templateProjects := make([]templates.Project, 0, len(projectsAndStuff))
|
2021-12-09 04:02:11 +00:00
|
|
|
numPersonalProjects := 0
|
2021-12-02 10:53:36 +00:00
|
|
|
for _, p := range projectsAndStuff {
|
2021-12-29 14:38:23 +00:00
|
|
|
templateProject := templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage(), c.Theme)
|
2021-12-08 03:37:52 +00:00
|
|
|
templateProjects = append(templateProjects, templateProject)
|
2021-12-09 04:02:11 +00:00
|
|
|
|
|
|
|
if p.Project.Personal {
|
|
|
|
numPersonalProjects++
|
|
|
|
}
|
2021-06-22 09:50:40 +00:00
|
|
|
}
|
|
|
|
c.Perf.EndBlock()
|
|
|
|
|
|
|
|
c.Perf.StartBlock("SQL", "Fetch posts")
|
2022-06-24 21:38:11 +00:00
|
|
|
posts, err := hmndata.FetchPosts(c, c.Conn, c.CurrentUser, hmndata.PostsQuery{
|
2021-09-14 04:13:58 +00:00
|
|
|
UserIDs: []int{profileUser.ID},
|
|
|
|
SortDescending: true,
|
|
|
|
})
|
2021-06-22 09:50:40 +00:00
|
|
|
c.Perf.EndBlock()
|
|
|
|
|
2022-06-24 21:38:11 +00:00
|
|
|
snippets, err := hmndata.FetchSnippets(c, c.Conn, c.CurrentUser, hmndata.SnippetQuery{
|
2021-11-11 19:00:46 +00:00
|
|
|
OwnerIDs: []int{profileUser.ID},
|
|
|
|
})
|
2021-06-22 09:50:40 +00:00
|
|
|
if err != nil {
|
2021-08-28 12:21:03 +00:00
|
|
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets for user: %s", username))
|
2021-06-22 09:50:40 +00:00
|
|
|
}
|
|
|
|
|
2021-07-30 03:40:47 +00:00
|
|
|
c.Perf.StartBlock("SQL", "Fetch subforum tree")
|
2022-06-24 21:38:11 +00:00
|
|
|
subforumTree := models.GetFullSubforumTree(c, c.Conn)
|
2021-07-30 03:40:47 +00:00
|
|
|
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
|
2021-06-22 09:50:40 +00:00
|
|
|
c.Perf.EndBlock()
|
|
|
|
|
|
|
|
c.Perf.StartBlock("PROFILE", "Construct timeline items")
|
2021-11-11 19:00:46 +00:00
|
|
|
timelineItems := make([]templates.TimelineItem, 0, len(posts)+len(snippets))
|
2021-06-22 09:50:40 +00:00
|
|
|
|
2021-09-14 04:13:58 +00:00
|
|
|
for _, post := range posts {
|
2021-10-23 22:28:06 +00:00
|
|
|
timelineItems = append(timelineItems, PostToTimelineItem(
|
2021-12-09 02:04:15 +00:00
|
|
|
hmndata.UrlContextForProject(&post.Project),
|
2021-06-22 09:50:40 +00:00
|
|
|
lineageBuilder,
|
2021-09-14 04:13:58 +00:00
|
|
|
&post.Post,
|
|
|
|
&post.Thread,
|
2021-06-22 09:50:40 +00:00
|
|
|
profileUser,
|
|
|
|
c.Theme,
|
2021-10-23 22:28:06 +00:00
|
|
|
))
|
2021-06-22 09:50:40 +00:00
|
|
|
}
|
|
|
|
|
2021-11-11 19:00:46 +00:00
|
|
|
for _, s := range snippets {
|
2021-10-24 20:48:28 +00:00
|
|
|
item := SnippetToTimelineItem(
|
2021-11-11 19:00:46 +00:00
|
|
|
&s.Snippet,
|
|
|
|
s.Asset,
|
|
|
|
s.DiscordMessage,
|
2022-08-05 04:03:45 +00:00
|
|
|
s.Projects,
|
2021-06-22 09:50:40 +00:00
|
|
|
profileUser,
|
|
|
|
c.Theme,
|
2022-08-05 04:03:45 +00:00
|
|
|
(c.CurrentUser != nil && (profileUser.ID == c.CurrentUser.ID || c.CurrentUser.IsStaff)),
|
2021-10-24 20:48:28 +00:00
|
|
|
)
|
|
|
|
item.SmallInfo = true
|
|
|
|
timelineItems = append(timelineItems, item)
|
2021-06-22 09:50:40 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
c.Perf.StartBlock("PROFILE", "Sort timeline")
|
|
|
|
sort.Slice(timelineItems, func(i, j int) bool {
|
|
|
|
return timelineItems[j].Date.Before(timelineItems[i].Date)
|
|
|
|
})
|
|
|
|
c.Perf.EndBlock()
|
|
|
|
|
|
|
|
c.Perf.EndBlock()
|
|
|
|
|
2021-08-18 02:09:42 +00:00
|
|
|
templateUser := templates.UserToTemplate(profileUser, c.Theme)
|
|
|
|
|
2021-09-01 18:25:09 +00:00
|
|
|
baseData := getBaseDataAutocrumb(c, templateUser.Name)
|
2021-08-18 02:09:42 +00:00
|
|
|
|
2022-08-05 04:03:45 +00:00
|
|
|
snippetEdit := templates.SnippetEdit{}
|
|
|
|
if c.CurrentUser != nil {
|
|
|
|
snippetEdit = templates.SnippetEdit{
|
|
|
|
AvailableProjectsJSON: templates.SnippetEditProjectsToJSON(templateProjects),
|
|
|
|
SubmitUrl: hmnurl.BuildSnippetSubmit(),
|
|
|
|
AssetMaxSize: AssetMaxSize(c.CurrentUser),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-06-22 09:50:40 +00:00
|
|
|
var res ResponseData
|
2021-07-17 15:19:17 +00:00
|
|
|
res.MustWriteTemplate("user_profile.html", UserProfileTemplateData{
|
2021-06-22 09:50:40 +00:00
|
|
|
BaseData: baseData,
|
2021-08-18 02:09:42 +00:00
|
|
|
ProfileUser: templateUser,
|
2021-06-22 09:50:40 +00:00
|
|
|
ProfileUserLinks: profileUserLinks,
|
|
|
|
ProfileUserProjects: templateProjects,
|
|
|
|
TimelineItems: timelineItems,
|
2021-12-02 10:53:36 +00:00
|
|
|
OwnProfile: (c.CurrentUser != nil && c.CurrentUser.ID == profileUser.ID),
|
2021-11-25 03:59:51 +00:00
|
|
|
ShowcaseUrl: hmnurl.BuildShowcase(),
|
2021-12-09 04:02:11 +00:00
|
|
|
|
|
|
|
CanAddProject: numPersonalProjects < maxPersonalProjects,
|
|
|
|
NewProjectUrl: hmnurl.BuildProjectNew(),
|
2021-12-15 01:17:42 +00:00
|
|
|
|
2022-09-10 21:52:02 +00:00
|
|
|
AdminSetOptionsUrl: hmnurl.BuildAdminSetUserOptions(),
|
|
|
|
AdminNukeUrl: hmnurl.BuildAdminNukeUser(),
|
2022-08-05 04:03:45 +00:00
|
|
|
|
|
|
|
SnippetEdit: snippetEdit,
|
2021-06-22 09:50:40 +00:00
|
|
|
}, c.Perf)
|
|
|
|
return res
|
|
|
|
}
|
2021-08-27 17:58:52 +00:00
|
|
|
|
2021-12-29 14:38:23 +00:00
|
|
|
var UserAvatarMaxFileSize = 1 * 1024 * 1024
|
|
|
|
|
2021-08-27 17:58:52 +00:00
|
|
|
func UserSettings(c *RequestContext) ResponseData {
|
|
|
|
var res ResponseData
|
|
|
|
|
|
|
|
type UserSettingsTemplateData struct {
|
|
|
|
templates.BaseData
|
|
|
|
|
2021-12-29 14:38:23 +00:00
|
|
|
AvatarMaxFileSize int
|
|
|
|
DefaultAvatarUrl string
|
|
|
|
|
Add Discord login (#106)
This leverages our existing Discord OAuth implementation. Any users with a linked Discord account will be able to log in immediately. When logging in, we request the `email` scope in addition to `identity`, so existing users will be prompted one time to accept the new permissions. On subsequent logins, Discord will skip the prompt.
When linking your Discord account to an existing HMN account, we continue to only request the `identity` scope, so we do not receive the user's Discord email.
Both login and linking go through the same Discord OAuth callback. All flows through the callback try to achieve the same end goal: a logged-in HMN user with a linked Discord account.
Linking works the same as it ever has. Login, however, is different because we do not have a session ID to use as the OAuth state. To account for this, I have added a `pending_login` table that stores a secure unique ID and the eventual destination URL. These pending logins expire after 10 minutes. When we receive the OAuth callback, we look up the pending login by the OAuth `state` and immediately delete it. The destination URL will be used to redirect the user to the right place.
If we have a `discord_user` entry for the OAuth'd Discord user, we immediately log the user into the associated HMN account. This is the typical login case. If we do not have a `discord_user`, but there is exactly one HMN user with the same email address as the Discord user, we will link the two accounts and log into the HMN account.
(It is possible for multiple HMN accounts to have the same email, because we don't have a uniqueness constraint there. We fail the login in this case rather than link to the wrong account.)
Finally, if no associated HMN user exists, a new one will be created. It will use the Discord user's username, email, and avatar. This user will have no password, but they can set or reset a password through the usual flows.
Co-authored-by: Ben Visness <bvisness@gmail.com>
Reviewed-on: https://git.handmade.network/hmn/hmn/pulls/106
2023-05-06 19:38:50 +00:00
|
|
|
User templates.User
|
|
|
|
Email string // these fields are handled specially on templates.User
|
|
|
|
ShowEmail bool
|
|
|
|
LinksText string
|
|
|
|
HasPassword bool
|
2021-08-27 17:58:52 +00:00
|
|
|
|
|
|
|
SubmitUrl string
|
|
|
|
ContactUrl string
|
|
|
|
|
|
|
|
DiscordUser *templates.DiscordUser
|
|
|
|
DiscordNumUnsavedMessages int
|
|
|
|
DiscordAuthorizeUrl string
|
|
|
|
DiscordUnlinkUrl string
|
|
|
|
DiscordShowcaseBacklogUrl string
|
|
|
|
}
|
|
|
|
|
2022-06-24 21:38:11 +00:00
|
|
|
links, err := db.Query[models.Link](c, c.Conn,
|
2021-08-27 17:58:52 +00:00
|
|
|
`
|
|
|
|
SELECT $columns
|
2022-05-07 13:11:05 +00:00
|
|
|
FROM link
|
2021-08-27 17:58:52 +00:00
|
|
|
WHERE user_id = $1
|
|
|
|
ORDER BY ordering
|
|
|
|
`,
|
|
|
|
c.CurrentUser.ID,
|
|
|
|
)
|
|
|
|
if err != nil {
|
2021-08-28 12:21:03 +00:00
|
|
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user links"))
|
2021-08-27 17:58:52 +00:00
|
|
|
}
|
|
|
|
|
2021-12-26 10:03:25 +00:00
|
|
|
linksText := LinksToText(links)
|
2021-08-27 17:58:52 +00:00
|
|
|
|
|
|
|
var tduser *templates.DiscordUser
|
|
|
|
var numUnsavedMessages int
|
2022-06-24 21:38:11 +00:00
|
|
|
duser, err := db.QueryOne[models.DiscordUser](c, c.Conn,
|
2021-08-27 17:58:52 +00:00
|
|
|
`
|
|
|
|
SELECT $columns
|
2022-05-07 13:11:05 +00:00
|
|
|
FROM discord_user
|
2021-08-27 17:58:52 +00:00
|
|
|
WHERE hmn_user_id = $1
|
|
|
|
`,
|
|
|
|
c.CurrentUser.ID,
|
|
|
|
)
|
2021-09-14 04:13:58 +00:00
|
|
|
if errors.Is(err, db.NotFound) {
|
2021-08-27 17:58:52 +00:00
|
|
|
// this is fine, but don't fetch any more messages
|
|
|
|
} else if err != nil {
|
2021-08-28 12:21:03 +00:00
|
|
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user's Discord account"))
|
2021-08-27 17:58:52 +00:00
|
|
|
} else {
|
|
|
|
tmp := templates.DiscordUserToTemplate(duser)
|
|
|
|
tduser = &tmp
|
|
|
|
|
2022-06-24 21:38:11 +00:00
|
|
|
numUnsavedMessages, err = db.QueryOneScalar[int](c, c.Conn,
|
2021-08-27 17:58:52 +00:00
|
|
|
`
|
|
|
|
SELECT COUNT(*)
|
|
|
|
FROM
|
2022-05-07 13:11:05 +00:00
|
|
|
discord_message AS msg
|
|
|
|
LEFT JOIN discord_message_content AS c ON c.message_id = msg.id
|
2021-08-27 17:58:52 +00:00
|
|
|
WHERE
|
|
|
|
msg.user_id = $1
|
|
|
|
AND msg.channel_id = $2
|
|
|
|
AND c.last_content IS NULL
|
|
|
|
`,
|
|
|
|
duser.UserID,
|
|
|
|
config.Config.Discord.ShowcaseChannelID,
|
|
|
|
)
|
|
|
|
if err != nil {
|
2021-08-28 12:21:03 +00:00
|
|
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to check for unsaved user messages"))
|
2021-08-27 17:58:52 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
templateUser := templates.UserToTemplate(c.CurrentUser, c.Theme)
|
|
|
|
|
2021-09-01 18:25:09 +00:00
|
|
|
baseData := getBaseDataAutocrumb(c, templateUser.Name)
|
2021-08-27 17:58:52 +00:00
|
|
|
|
|
|
|
res.MustWriteTemplate("user_settings.html", UserSettingsTemplateData{
|
2021-12-29 14:38:23 +00:00
|
|
|
BaseData: baseData,
|
|
|
|
AvatarMaxFileSize: UserAvatarMaxFileSize,
|
|
|
|
DefaultAvatarUrl: templates.UserAvatarDefaultUrl(c.Theme),
|
|
|
|
User: templateUser,
|
|
|
|
Email: c.CurrentUser.Email,
|
|
|
|
ShowEmail: c.CurrentUser.ShowEmail,
|
|
|
|
LinksText: linksText,
|
Add Discord login (#106)
This leverages our existing Discord OAuth implementation. Any users with a linked Discord account will be able to log in immediately. When logging in, we request the `email` scope in addition to `identity`, so existing users will be prompted one time to accept the new permissions. On subsequent logins, Discord will skip the prompt.
When linking your Discord account to an existing HMN account, we continue to only request the `identity` scope, so we do not receive the user's Discord email.
Both login and linking go through the same Discord OAuth callback. All flows through the callback try to achieve the same end goal: a logged-in HMN user with a linked Discord account.
Linking works the same as it ever has. Login, however, is different because we do not have a session ID to use as the OAuth state. To account for this, I have added a `pending_login` table that stores a secure unique ID and the eventual destination URL. These pending logins expire after 10 minutes. When we receive the OAuth callback, we look up the pending login by the OAuth `state` and immediately delete it. The destination URL will be used to redirect the user to the right place.
If we have a `discord_user` entry for the OAuth'd Discord user, we immediately log the user into the associated HMN account. This is the typical login case. If we do not have a `discord_user`, but there is exactly one HMN user with the same email address as the Discord user, we will link the two accounts and log into the HMN account.
(It is possible for multiple HMN accounts to have the same email, because we don't have a uniqueness constraint there. We fail the login in this case rather than link to the wrong account.)
Finally, if no associated HMN user exists, a new one will be created. It will use the Discord user's username, email, and avatar. This user will have no password, but they can set or reset a password through the usual flows.
Co-authored-by: Ben Visness <bvisness@gmail.com>
Reviewed-on: https://git.handmade.network/hmn/hmn/pulls/106
2023-05-06 19:38:50 +00:00
|
|
|
HasPassword: c.CurrentUser.Password != "",
|
2021-08-27 17:58:52 +00:00
|
|
|
|
|
|
|
SubmitUrl: hmnurl.BuildUserSettings(""),
|
|
|
|
ContactUrl: hmnurl.BuildContactPage(),
|
|
|
|
|
|
|
|
DiscordUser: tduser,
|
|
|
|
DiscordNumUnsavedMessages: numUnsavedMessages,
|
Add Discord login (#106)
This leverages our existing Discord OAuth implementation. Any users with a linked Discord account will be able to log in immediately. When logging in, we request the `email` scope in addition to `identity`, so existing users will be prompted one time to accept the new permissions. On subsequent logins, Discord will skip the prompt.
When linking your Discord account to an existing HMN account, we continue to only request the `identity` scope, so we do not receive the user's Discord email.
Both login and linking go through the same Discord OAuth callback. All flows through the callback try to achieve the same end goal: a logged-in HMN user with a linked Discord account.
Linking works the same as it ever has. Login, however, is different because we do not have a session ID to use as the OAuth state. To account for this, I have added a `pending_login` table that stores a secure unique ID and the eventual destination URL. These pending logins expire after 10 minutes. When we receive the OAuth callback, we look up the pending login by the OAuth `state` and immediately delete it. The destination URL will be used to redirect the user to the right place.
If we have a `discord_user` entry for the OAuth'd Discord user, we immediately log the user into the associated HMN account. This is the typical login case. If we do not have a `discord_user`, but there is exactly one HMN user with the same email address as the Discord user, we will link the two accounts and log into the HMN account.
(It is possible for multiple HMN accounts to have the same email, because we don't have a uniqueness constraint there. We fail the login in this case rather than link to the wrong account.)
Finally, if no associated HMN user exists, a new one will be created. It will use the Discord user's username, email, and avatar. This user will have no password, but they can set or reset a password through the usual flows.
Co-authored-by: Ben Visness <bvisness@gmail.com>
Reviewed-on: https://git.handmade.network/hmn/hmn/pulls/106
2023-05-06 19:38:50 +00:00
|
|
|
DiscordAuthorizeUrl: discord.GetAuthorizeUrl(c.CurrentSession.CSRFToken, false),
|
2021-08-27 17:58:52 +00:00
|
|
|
DiscordUnlinkUrl: hmnurl.BuildDiscordUnlink(),
|
|
|
|
DiscordShowcaseBacklogUrl: hmnurl.BuildDiscordShowcaseBacklog(),
|
|
|
|
}, c.Perf)
|
|
|
|
return res
|
|
|
|
}
|
|
|
|
|
|
|
|
func UserSettingsSave(c *RequestContext) ResponseData {
|
2021-12-29 14:38:23 +00:00
|
|
|
maxBodySize := int64(UserAvatarMaxFileSize + 2*1024*1024)
|
|
|
|
c.Req.Body = http.MaxBytesReader(c.Res, c.Req.Body, maxBodySize)
|
|
|
|
err := c.Req.ParseMultipartForm(maxBodySize)
|
|
|
|
if err != nil {
|
|
|
|
// NOTE(asaf): The error for exceeding the max filesize doesn't have a special type, so we can't easily detect it here.
|
|
|
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to parse form"))
|
|
|
|
}
|
|
|
|
|
2022-06-24 21:38:11 +00:00
|
|
|
tx, err := c.Conn.Begin(c)
|
2021-08-27 17:58:52 +00:00
|
|
|
if err != nil {
|
|
|
|
panic(err)
|
|
|
|
}
|
2022-06-24 21:38:11 +00:00
|
|
|
defer tx.Rollback(c)
|
2021-08-27 17:58:52 +00:00
|
|
|
|
2022-08-13 19:29:40 +00:00
|
|
|
hasDiscordUser := utils.Must1(db.QueryOneScalar[bool](c, tx,
|
|
|
|
`
|
|
|
|
SELECT COUNT(*) <> 0
|
|
|
|
FROM discord_user
|
|
|
|
WHERE hmn_user_id = $1
|
|
|
|
`,
|
|
|
|
c.CurrentUser.ID,
|
|
|
|
))
|
|
|
|
|
2021-08-27 17:58:52 +00:00
|
|
|
form, err := c.GetFormValues()
|
|
|
|
if err != nil {
|
|
|
|
c.Logger.Warn().Err(err).Msg("failed to parse form on user update")
|
|
|
|
return c.Redirect(hmnurl.BuildUserSettings(""), http.StatusSeeOther)
|
|
|
|
}
|
|
|
|
|
|
|
|
name := form.Get("realname")
|
|
|
|
|
|
|
|
email := form.Get("email")
|
|
|
|
if !hmnemail.IsEmail(email) {
|
2022-06-24 21:38:11 +00:00
|
|
|
return c.RejectRequest("Your email was not valid.")
|
2021-08-27 17:58:52 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
showEmail := form.Get("showemail") != ""
|
|
|
|
darkTheme := form.Get("darktheme") != ""
|
|
|
|
|
|
|
|
blurb := form.Get("shortbio")
|
|
|
|
signature := form.Get("signature")
|
|
|
|
bio := form.Get("longbio")
|
|
|
|
|
|
|
|
discordShowcaseAuto := form.Get("discord-showcase-auto") != ""
|
|
|
|
discordDeleteSnippetOnMessageDelete := form.Get("discord-snippet-keep") == ""
|
|
|
|
|
2022-08-13 19:29:40 +00:00
|
|
|
var qb db.QueryBuilder
|
|
|
|
qb.Add(
|
2021-08-27 17:58:52 +00:00
|
|
|
`
|
2022-05-07 13:11:05 +00:00
|
|
|
UPDATE hmn_user
|
2021-08-27 17:58:52 +00:00
|
|
|
SET
|
2022-08-13 19:29:40 +00:00
|
|
|
name = $?,
|
|
|
|
email = $?,
|
|
|
|
showemail = $?,
|
|
|
|
darktheme = $?,
|
|
|
|
blurb = $?,
|
|
|
|
signature = $?,
|
|
|
|
bio = $?
|
2021-08-27 17:58:52 +00:00
|
|
|
`,
|
|
|
|
name,
|
|
|
|
email,
|
|
|
|
showEmail,
|
|
|
|
darkTheme,
|
|
|
|
blurb,
|
|
|
|
signature,
|
|
|
|
bio,
|
|
|
|
)
|
2022-08-13 19:29:40 +00:00
|
|
|
if hasDiscordUser {
|
|
|
|
qb.Add(
|
|
|
|
`
|
|
|
|
,
|
|
|
|
discord_save_showcase = $?,
|
|
|
|
discord_delete_snippet_on_message_delete = $?
|
|
|
|
`,
|
|
|
|
discordShowcaseAuto,
|
|
|
|
discordDeleteSnippetOnMessageDelete,
|
|
|
|
)
|
|
|
|
}
|
|
|
|
qb.Add(`WHERE id = $?`, c.CurrentUser.ID)
|
|
|
|
|
|
|
|
_, err = tx.Exec(c, qb.String(), qb.Args()...)
|
2021-08-27 17:58:52 +00:00
|
|
|
if err != nil {
|
2021-08-28 12:21:03 +00:00
|
|
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update user"))
|
2021-08-27 17:58:52 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Process links
|
2022-06-24 21:38:11 +00:00
|
|
|
twitchLoginsPreChange, preErr := hmndata.FetchTwitchLoginsForUserOrProject(c, tx, &c.CurrentUser.ID, nil)
|
2021-08-27 17:58:52 +00:00
|
|
|
linksText := form.Get("links")
|
2021-12-26 10:03:25 +00:00
|
|
|
links := ParseLinks(linksText)
|
2022-06-24 21:38:11 +00:00
|
|
|
_, err = tx.Exec(c, `DELETE FROM link WHERE user_id = $1`, c.CurrentUser.ID)
|
2021-08-27 17:58:52 +00:00
|
|
|
if err != nil {
|
|
|
|
c.Logger.Warn().Err(err).Msg("failed to delete old links")
|
|
|
|
} else {
|
|
|
|
for i, link := range links {
|
2022-06-24 21:38:11 +00:00
|
|
|
_, err := tx.Exec(c,
|
2021-08-27 17:58:52 +00:00
|
|
|
`
|
2022-05-07 13:11:05 +00:00
|
|
|
INSERT INTO link (name, url, ordering, user_id)
|
2021-08-27 17:58:52 +00:00
|
|
|
VALUES ($1, $2, $3, $4)
|
|
|
|
`,
|
2021-12-26 10:03:25 +00:00
|
|
|
link.Name,
|
|
|
|
link.Url,
|
2021-08-27 17:58:52 +00:00
|
|
|
i,
|
|
|
|
c.CurrentUser.ID,
|
|
|
|
)
|
|
|
|
if err != nil {
|
|
|
|
c.Logger.Warn().Err(err).Msg("failed to insert new link")
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2022-06-24 21:38:11 +00:00
|
|
|
twitchLoginsPostChange, postErr := hmndata.FetchTwitchLoginsForUserOrProject(c, tx, &c.CurrentUser.ID, nil)
|
2022-03-22 18:07:43 +00:00
|
|
|
if preErr == nil && postErr == nil {
|
|
|
|
twitch.UserOrProjectLinksUpdated(twitchLoginsPreChange, twitchLoginsPostChange)
|
|
|
|
}
|
2021-08-27 17:58:52 +00:00
|
|
|
|
|
|
|
// Update password
|
|
|
|
oldPassword := form.Get("old_password")
|
2022-08-13 19:15:00 +00:00
|
|
|
newPassword := form.Get("new_password")
|
Add Discord login (#106)
This leverages our existing Discord OAuth implementation. Any users with a linked Discord account will be able to log in immediately. When logging in, we request the `email` scope in addition to `identity`, so existing users will be prompted one time to accept the new permissions. On subsequent logins, Discord will skip the prompt.
When linking your Discord account to an existing HMN account, we continue to only request the `identity` scope, so we do not receive the user's Discord email.
Both login and linking go through the same Discord OAuth callback. All flows through the callback try to achieve the same end goal: a logged-in HMN user with a linked Discord account.
Linking works the same as it ever has. Login, however, is different because we do not have a session ID to use as the OAuth state. To account for this, I have added a `pending_login` table that stores a secure unique ID and the eventual destination URL. These pending logins expire after 10 minutes. When we receive the OAuth callback, we look up the pending login by the OAuth `state` and immediately delete it. The destination URL will be used to redirect the user to the right place.
If we have a `discord_user` entry for the OAuth'd Discord user, we immediately log the user into the associated HMN account. This is the typical login case. If we do not have a `discord_user`, but there is exactly one HMN user with the same email address as the Discord user, we will link the two accounts and log into the HMN account.
(It is possible for multiple HMN accounts to have the same email, because we don't have a uniqueness constraint there. We fail the login in this case rather than link to the wrong account.)
Finally, if no associated HMN user exists, a new one will be created. It will use the Discord user's username, email, and avatar. This user will have no password, but they can set or reset a password through the usual flows.
Co-authored-by: Ben Visness <bvisness@gmail.com>
Reviewed-on: https://git.handmade.network/hmn/hmn/pulls/106
2023-05-06 19:38:50 +00:00
|
|
|
var doChangePassword bool
|
|
|
|
if c.CurrentUser.Password == "" {
|
|
|
|
doChangePassword = newPassword != ""
|
|
|
|
} else {
|
|
|
|
doChangePassword = oldPassword != "" && newPassword != ""
|
|
|
|
}
|
|
|
|
if doChangePassword {
|
2022-08-13 19:15:00 +00:00
|
|
|
errorRes := updatePassword(c, tx, oldPassword, newPassword)
|
2021-08-27 17:58:52 +00:00
|
|
|
if errorRes != nil {
|
|
|
|
return *errorRes
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Update avatar
|
2021-12-29 14:38:23 +00:00
|
|
|
newAvatar, err := GetFormImage(c, "avatar")
|
|
|
|
if err != nil {
|
|
|
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to read image from form"))
|
|
|
|
}
|
|
|
|
var avatarUUID *uuid.UUID
|
|
|
|
if newAvatar.Exists {
|
2022-06-24 21:38:11 +00:00
|
|
|
avatarAsset, err := assets.Create(c, tx, assets.CreateInput{
|
2021-12-29 14:38:23 +00:00
|
|
|
Content: newAvatar.Content,
|
|
|
|
Filename: newAvatar.Filename,
|
|
|
|
ContentType: newAvatar.Mime,
|
|
|
|
UploaderID: &c.CurrentUser.ID,
|
|
|
|
Width: newAvatar.Width,
|
|
|
|
Height: newAvatar.Height,
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to upload avatar"))
|
|
|
|
}
|
|
|
|
avatarUUID = &avatarAsset.ID
|
|
|
|
}
|
|
|
|
if newAvatar.Exists || newAvatar.Remove {
|
2022-06-24 21:38:11 +00:00
|
|
|
_, err := tx.Exec(c,
|
2021-09-08 00:55:52 +00:00
|
|
|
`
|
2022-05-07 13:11:05 +00:00
|
|
|
UPDATE hmn_user
|
2021-09-08 00:55:52 +00:00
|
|
|
SET
|
2021-12-29 14:38:23 +00:00
|
|
|
avatar_asset_id = $2
|
2021-09-08 00:55:52 +00:00
|
|
|
WHERE
|
|
|
|
id = $1
|
|
|
|
`,
|
|
|
|
c.CurrentUser.ID,
|
2021-12-29 14:38:23 +00:00
|
|
|
avatarUUID,
|
2021-09-08 00:55:52 +00:00
|
|
|
)
|
|
|
|
if err != nil {
|
2021-12-29 14:38:23 +00:00
|
|
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update user's avatar"))
|
2021-09-08 00:55:52 +00:00
|
|
|
}
|
2021-08-27 17:58:52 +00:00
|
|
|
}
|
|
|
|
|
2022-06-24 21:38:11 +00:00
|
|
|
err = tx.Commit(c)
|
2021-08-27 17:58:52 +00:00
|
|
|
if err != nil {
|
2021-08-28 12:21:03 +00:00
|
|
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to save user settings"))
|
2021-08-27 17:58:52 +00:00
|
|
|
}
|
|
|
|
|
2021-08-28 17:07:45 +00:00
|
|
|
res := c.Redirect(hmnurl.BuildUserSettings(""), http.StatusSeeOther)
|
|
|
|
res.AddFutureNotice("success", "User profile updated.")
|
|
|
|
|
|
|
|
return res
|
2021-08-27 17:58:52 +00:00
|
|
|
}
|
|
|
|
|
2022-09-10 21:52:02 +00:00
|
|
|
func UserProfileAdminSetOptions(c *RequestContext) ResponseData {
|
2021-12-15 01:17:42 +00:00
|
|
|
c.Req.ParseForm()
|
|
|
|
|
|
|
|
userIdStr := c.Req.Form.Get("user_id")
|
|
|
|
userId, err := strconv.Atoi(userIdStr)
|
|
|
|
if err != nil {
|
2022-06-24 21:38:11 +00:00
|
|
|
return c.RejectRequest("No user id provided")
|
2021-12-15 01:17:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
status := c.Req.Form.Get("status")
|
|
|
|
var desiredStatus models.UserStatus
|
|
|
|
switch status {
|
|
|
|
case "inactive":
|
|
|
|
desiredStatus = models.UserStatusInactive
|
|
|
|
case "confirmed":
|
|
|
|
desiredStatus = models.UserStatusConfirmed
|
|
|
|
case "approved":
|
|
|
|
desiredStatus = models.UserStatusApproved
|
|
|
|
case "banned":
|
|
|
|
desiredStatus = models.UserStatusBanned
|
|
|
|
default:
|
2022-06-24 21:38:11 +00:00
|
|
|
return c.RejectRequest("No legal user status provided")
|
2021-12-15 01:17:42 +00:00
|
|
|
}
|
|
|
|
|
2022-09-10 21:52:02 +00:00
|
|
|
eduRole := c.Req.Form.Get("edu_role")
|
|
|
|
var desiredEduRole models.EduRole
|
|
|
|
switch eduRole {
|
|
|
|
case "none":
|
|
|
|
desiredEduRole = models.EduRoleNone
|
|
|
|
case "beta":
|
|
|
|
desiredEduRole = models.EduRoleBeta
|
|
|
|
case "author":
|
|
|
|
desiredEduRole = models.EduRoleAuthor
|
|
|
|
default:
|
|
|
|
return c.RejectRequest("the education role is bad and you should feel bad")
|
|
|
|
}
|
|
|
|
|
2022-06-24 21:38:11 +00:00
|
|
|
_, err = c.Conn.Exec(c,
|
2021-12-15 01:17:42 +00:00
|
|
|
`
|
2022-05-07 13:11:05 +00:00
|
|
|
UPDATE hmn_user
|
2022-09-10 21:52:02 +00:00
|
|
|
SET status = $2, education_role = $3
|
|
|
|
WHERE id = $1
|
2021-12-15 01:17:42 +00:00
|
|
|
`,
|
|
|
|
userId,
|
2022-09-10 21:52:02 +00:00
|
|
|
desiredStatus,
|
|
|
|
desiredEduRole,
|
2021-12-15 01:17:42 +00:00
|
|
|
)
|
|
|
|
if err != nil {
|
2022-09-10 21:52:02 +00:00
|
|
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update user admin settings"))
|
2021-12-15 01:17:42 +00:00
|
|
|
}
|
2021-12-21 04:07:55 +00:00
|
|
|
if desiredStatus == models.UserStatusBanned {
|
2022-06-24 21:38:11 +00:00
|
|
|
err = auth.DeleteSessionForUser(c, c.Conn, c.Req.Form.Get("username"))
|
2021-12-21 04:07:55 +00:00
|
|
|
if err != nil {
|
|
|
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to log out user"))
|
|
|
|
}
|
|
|
|
}
|
2021-12-15 01:17:42 +00:00
|
|
|
res := c.Redirect(hmnurl.BuildUserProfile(c.Req.Form.Get("username")), http.StatusSeeOther)
|
2022-09-10 21:52:02 +00:00
|
|
|
res.AddFutureNotice("success", "Successfully set admin options")
|
2021-12-15 01:17:42 +00:00
|
|
|
return res
|
|
|
|
}
|
|
|
|
|
|
|
|
func UserProfileAdminNuke(c *RequestContext) ResponseData {
|
|
|
|
c.Req.ParseForm()
|
|
|
|
userIdStr := c.Req.Form.Get("user_id")
|
|
|
|
userId, err := strconv.Atoi(userIdStr)
|
|
|
|
if err != nil {
|
2022-06-24 21:38:11 +00:00
|
|
|
return c.RejectRequest("No user id provided")
|
2021-12-15 01:17:42 +00:00
|
|
|
}
|
|
|
|
|
2022-06-24 21:38:11 +00:00
|
|
|
err = deleteAllPostsForUser(c, c.Conn, userId)
|
2021-12-15 01:17:42 +00:00
|
|
|
if err != nil {
|
|
|
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete user posts"))
|
|
|
|
}
|
|
|
|
res := c.Redirect(hmnurl.BuildUserProfile(c.Req.Form.Get("username")), http.StatusSeeOther)
|
|
|
|
res.AddFutureNotice("success", "Successfully nuked user")
|
|
|
|
return res
|
|
|
|
}
|
|
|
|
|
2022-08-13 19:15:00 +00:00
|
|
|
func updatePassword(c *RequestContext, tx pgx.Tx, old, new string) *ResponseData {
|
Add Discord login (#106)
This leverages our existing Discord OAuth implementation. Any users with a linked Discord account will be able to log in immediately. When logging in, we request the `email` scope in addition to `identity`, so existing users will be prompted one time to accept the new permissions. On subsequent logins, Discord will skip the prompt.
When linking your Discord account to an existing HMN account, we continue to only request the `identity` scope, so we do not receive the user's Discord email.
Both login and linking go through the same Discord OAuth callback. All flows through the callback try to achieve the same end goal: a logged-in HMN user with a linked Discord account.
Linking works the same as it ever has. Login, however, is different because we do not have a session ID to use as the OAuth state. To account for this, I have added a `pending_login` table that stores a secure unique ID and the eventual destination URL. These pending logins expire after 10 minutes. When we receive the OAuth callback, we look up the pending login by the OAuth `state` and immediately delete it. The destination URL will be used to redirect the user to the right place.
If we have a `discord_user` entry for the OAuth'd Discord user, we immediately log the user into the associated HMN account. This is the typical login case. If we do not have a `discord_user`, but there is exactly one HMN user with the same email address as the Discord user, we will link the two accounts and log into the HMN account.
(It is possible for multiple HMN accounts to have the same email, because we don't have a uniqueness constraint there. We fail the login in this case rather than link to the wrong account.)
Finally, if no associated HMN user exists, a new one will be created. It will use the Discord user's username, email, and avatar. This user will have no password, but they can set or reset a password through the usual flows.
Co-authored-by: Ben Visness <bvisness@gmail.com>
Reviewed-on: https://git.handmade.network/hmn/hmn/pulls/106
2023-05-06 19:38:50 +00:00
|
|
|
if c.CurrentUser.Password != "" {
|
|
|
|
oldHashedPassword, err := auth.ParsePasswordString(c.CurrentUser.Password)
|
|
|
|
if err != nil {
|
|
|
|
c.Logger.Warn().Err(err).Msg("failed to parse user's password string")
|
|
|
|
return nil
|
|
|
|
}
|
2021-08-27 17:58:52 +00:00
|
|
|
|
Add Discord login (#106)
This leverages our existing Discord OAuth implementation. Any users with a linked Discord account will be able to log in immediately. When logging in, we request the `email` scope in addition to `identity`, so existing users will be prompted one time to accept the new permissions. On subsequent logins, Discord will skip the prompt.
When linking your Discord account to an existing HMN account, we continue to only request the `identity` scope, so we do not receive the user's Discord email.
Both login and linking go through the same Discord OAuth callback. All flows through the callback try to achieve the same end goal: a logged-in HMN user with a linked Discord account.
Linking works the same as it ever has. Login, however, is different because we do not have a session ID to use as the OAuth state. To account for this, I have added a `pending_login` table that stores a secure unique ID and the eventual destination URL. These pending logins expire after 10 minutes. When we receive the OAuth callback, we look up the pending login by the OAuth `state` and immediately delete it. The destination URL will be used to redirect the user to the right place.
If we have a `discord_user` entry for the OAuth'd Discord user, we immediately log the user into the associated HMN account. This is the typical login case. If we do not have a `discord_user`, but there is exactly one HMN user with the same email address as the Discord user, we will link the two accounts and log into the HMN account.
(It is possible for multiple HMN accounts to have the same email, because we don't have a uniqueness constraint there. We fail the login in this case rather than link to the wrong account.)
Finally, if no associated HMN user exists, a new one will be created. It will use the Discord user's username, email, and avatar. This user will have no password, but they can set or reset a password through the usual flows.
Co-authored-by: Ben Visness <bvisness@gmail.com>
Reviewed-on: https://git.handmade.network/hmn/hmn/pulls/106
2023-05-06 19:38:50 +00:00
|
|
|
ok, err := auth.CheckPassword(old, oldHashedPassword)
|
|
|
|
if err != nil {
|
|
|
|
res := c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to check user's password"))
|
|
|
|
return &res
|
|
|
|
}
|
2021-08-27 17:58:52 +00:00
|
|
|
|
Add Discord login (#106)
This leverages our existing Discord OAuth implementation. Any users with a linked Discord account will be able to log in immediately. When logging in, we request the `email` scope in addition to `identity`, so existing users will be prompted one time to accept the new permissions. On subsequent logins, Discord will skip the prompt.
When linking your Discord account to an existing HMN account, we continue to only request the `identity` scope, so we do not receive the user's Discord email.
Both login and linking go through the same Discord OAuth callback. All flows through the callback try to achieve the same end goal: a logged-in HMN user with a linked Discord account.
Linking works the same as it ever has. Login, however, is different because we do not have a session ID to use as the OAuth state. To account for this, I have added a `pending_login` table that stores a secure unique ID and the eventual destination URL. These pending logins expire after 10 minutes. When we receive the OAuth callback, we look up the pending login by the OAuth `state` and immediately delete it. The destination URL will be used to redirect the user to the right place.
If we have a `discord_user` entry for the OAuth'd Discord user, we immediately log the user into the associated HMN account. This is the typical login case. If we do not have a `discord_user`, but there is exactly one HMN user with the same email address as the Discord user, we will link the two accounts and log into the HMN account.
(It is possible for multiple HMN accounts to have the same email, because we don't have a uniqueness constraint there. We fail the login in this case rather than link to the wrong account.)
Finally, if no associated HMN user exists, a new one will be created. It will use the Discord user's username, email, and avatar. This user will have no password, but they can set or reset a password through the usual flows.
Co-authored-by: Ben Visness <bvisness@gmail.com>
Reviewed-on: https://git.handmade.network/hmn/hmn/pulls/106
2023-05-06 19:38:50 +00:00
|
|
|
if !ok {
|
|
|
|
res := c.RejectRequest("The old password you provided was not correct.")
|
|
|
|
return &res
|
|
|
|
}
|
2021-08-27 17:58:52 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
newHashedPassword := auth.HashPassword(new)
|
Add Discord login (#106)
This leverages our existing Discord OAuth implementation. Any users with a linked Discord account will be able to log in immediately. When logging in, we request the `email` scope in addition to `identity`, so existing users will be prompted one time to accept the new permissions. On subsequent logins, Discord will skip the prompt.
When linking your Discord account to an existing HMN account, we continue to only request the `identity` scope, so we do not receive the user's Discord email.
Both login and linking go through the same Discord OAuth callback. All flows through the callback try to achieve the same end goal: a logged-in HMN user with a linked Discord account.
Linking works the same as it ever has. Login, however, is different because we do not have a session ID to use as the OAuth state. To account for this, I have added a `pending_login` table that stores a secure unique ID and the eventual destination URL. These pending logins expire after 10 minutes. When we receive the OAuth callback, we look up the pending login by the OAuth `state` and immediately delete it. The destination URL will be used to redirect the user to the right place.
If we have a `discord_user` entry for the OAuth'd Discord user, we immediately log the user into the associated HMN account. This is the typical login case. If we do not have a `discord_user`, but there is exactly one HMN user with the same email address as the Discord user, we will link the two accounts and log into the HMN account.
(It is possible for multiple HMN accounts to have the same email, because we don't have a uniqueness constraint there. We fail the login in this case rather than link to the wrong account.)
Finally, if no associated HMN user exists, a new one will be created. It will use the Discord user's username, email, and avatar. This user will have no password, but they can set or reset a password through the usual flows.
Co-authored-by: Ben Visness <bvisness@gmail.com>
Reviewed-on: https://git.handmade.network/hmn/hmn/pulls/106
2023-05-06 19:38:50 +00:00
|
|
|
err := auth.UpdatePassword(c, tx, c.CurrentUser.Username, newHashedPassword)
|
2021-08-27 17:58:52 +00:00
|
|
|
if err != nil {
|
2021-08-28 12:21:03 +00:00
|
|
|
res := c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update password"))
|
2021-08-27 17:58:52 +00:00
|
|
|
return &res
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|