hmn/src/website/user.go

547 lines
15 KiB
Go
Raw Normal View History

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"
"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"
"git.handmade.network/hmn/hmn/src/discord"
hmnemail "git.handmade.network/hmn/hmn/src/email"
"git.handmade.network/hmn/hmn/src/hmndata"
"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"
2021-12-29 14:38:23 +00:00
"github.com/google/uuid"
"github.com/jackc/pgx/v4"
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
CanAddProject bool
NewProjectUrl string
2021-12-15 01:17:42 +00:00
AdminSetStatusUrl string
AdminNukeUrl string
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 {
user, err := hmndata.FetchUserByUsername(c.Context(), c.Conn, c.CurrentUser, username, hmndata.UsersQuery{})
2021-06-22 09:50:40 +00:00
if err != nil {
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
}
}
profileUser = user
2021-06-22 09:50:40 +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")
userLinks, err := db.Query[models.Link](c.Context(), 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
}
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()
2021-12-11 19:08:10 +00:00
projectsAndStuff, err := hmndata.FetchProjects(c.Context(), c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
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))
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)
templateProjects = append(templateProjects, templateProject)
if p.Project.Personal {
numPersonalProjects++
}
2021-06-22 09:50:40 +00:00
}
c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Fetch posts")
posts, err := hmndata.FetchPosts(c.Context(), c.Conn, c.CurrentUser, hmndata.PostsQuery{
UserIDs: []int{profileUser.ID},
SortDescending: true,
})
2021-06-22 09:50:40 +00:00
c.Perf.EndBlock()
snippets, err := hmndata.FetchSnippets(c.Context(), 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
}
c.Perf.StartBlock("SQL", "Fetch subforum tree")
subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
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
for _, post := range posts {
timelineItems = append(timelineItems, PostToTimelineItem(
hmndata.UrlContextForProject(&post.Project),
2021-06-22 09:50:40 +00:00
lineageBuilder,
&post.Post,
&post.Thread,
2021-06-22 09:50:40 +00:00
profileUser,
c.Theme,
))
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,
s.Tags,
2021-06-22 09:50:40 +00:00
profileUser,
c.Theme,
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
2021-06-22 09:50:40 +00:00
var res ResponseData
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(),
CanAddProject: numPersonalProjects < maxPersonalProjects,
NewProjectUrl: hmnurl.BuildProjectNew(),
2021-12-15 01:17:42 +00:00
AdminSetStatusUrl: hmnurl.BuildAdminSetUserStatus(),
AdminNukeUrl: hmnurl.BuildAdminNukeUser(),
2021-06-22 09:50:40 +00:00
}, c.Perf)
return res
}
2021-12-29 14:38:23 +00:00
var UserAvatarMaxFileSize = 1 * 1024 * 1024
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
User templates.User
Email string // these fields are handled specially on templates.User
ShowEmail bool
LinksText string
SubmitUrl string
ContactUrl string
DiscordUser *templates.DiscordUser
DiscordNumUnsavedMessages int
DiscordAuthorizeUrl string
DiscordUnlinkUrl string
DiscordShowcaseBacklogUrl string
}
links, err := db.Query[models.Link](c.Context(), c.Conn,
`
SELECT $columns
2022-05-07 13:11:05 +00:00
FROM link
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"))
}
linksText := LinksToText(links)
var tduser *templates.DiscordUser
var numUnsavedMessages int
duser, err := db.QueryOne[models.DiscordUser](c.Context(), c.Conn,
`
SELECT $columns
2022-05-07 13:11:05 +00:00
FROM discord_user
WHERE hmn_user_id = $1
`,
c.CurrentUser.ID,
)
if errors.Is(err, db.NotFound) {
// 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"))
} else {
tmp := templates.DiscordUserToTemplate(duser)
tduser = &tmp
numUnsavedMessages, err = db.QueryOneScalar[int](c.Context(), c.Conn,
`
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
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"))
}
}
templateUser := templates.UserToTemplate(c.CurrentUser, c.Theme)
2021-09-01 18:25:09 +00:00
baseData := getBaseDataAutocrumb(c, templateUser.Name)
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,
SubmitUrl: hmnurl.BuildUserSettings(""),
ContactUrl: hmnurl.BuildContactPage(),
DiscordUser: tduser,
DiscordNumUnsavedMessages: numUnsavedMessages,
DiscordAuthorizeUrl: discord.GetAuthorizeUrl(c.CurrentSession.CSRFToken),
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"))
}
tx, err := c.Conn.Begin(c.Context())
if err != nil {
panic(err)
}
defer tx.Rollback(c.Context())
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) {
return RejectRequest(c, "Your email was not valid.")
}
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") == ""
_, err = tx.Exec(c.Context(),
`
2022-05-07 13:11:05 +00:00
UPDATE hmn_user
SET
name = $2,
email = $3,
showemail = $4,
darktheme = $5,
blurb = $6,
signature = $7,
bio = $8,
discord_save_showcase = $9,
discord_delete_snippet_on_message_delete = $10
WHERE
id = $1
`,
c.CurrentUser.ID,
name,
email,
showEmail,
darkTheme,
blurb,
signature,
bio,
discordShowcaseAuto,
discordDeleteSnippetOnMessageDelete,
)
if err != nil {
2021-08-28 12:21:03 +00:00
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update user"))
}
// Process links
2022-03-22 18:07:43 +00:00
twitchLoginsPreChange, preErr := hmndata.FetchTwitchLoginsForUserOrProject(c.Context(), tx, &c.CurrentUser.ID, nil)
linksText := form.Get("links")
links := ParseLinks(linksText)
2022-05-07 13:11:05 +00:00
_, err = tx.Exec(c.Context(), `DELETE FROM link WHERE user_id = $1`, c.CurrentUser.ID)
if err != nil {
c.Logger.Warn().Err(err).Msg("failed to delete old links")
} else {
for i, link := range links {
_, err := tx.Exec(c.Context(),
`
2022-05-07 13:11:05 +00:00
INSERT INTO link (name, url, ordering, user_id)
VALUES ($1, $2, $3, $4)
`,
link.Name,
link.Url,
i,
c.CurrentUser.ID,
)
if err != nil {
c.Logger.Warn().Err(err).Msg("failed to insert new link")
continue
}
}
}
2022-03-22 18:07:43 +00:00
twitchLoginsPostChange, postErr := hmndata.FetchTwitchLoginsForUserOrProject(c.Context(), tx, &c.CurrentUser.ID, nil)
if preErr == nil && postErr == nil {
twitch.UserOrProjectLinksUpdated(twitchLoginsPreChange, twitchLoginsPostChange)
}
// Update password
oldPassword := form.Get("old_password")
newPassword := form.Get("new_password1")
newPasswordConfirmation := form.Get("new_password2")
if oldPassword != "" && newPassword != "" {
errorRes := updatePassword(c, tx, oldPassword, newPassword, newPasswordConfirmation)
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 {
avatarAsset, err := assets.Create(c.Context(), tx, assets.CreateInput{
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 {
_, err := tx.Exec(c.Context(),
`
2022-05-07 13:11:05 +00:00
UPDATE hmn_user
SET
2021-12-29 14:38:23 +00:00
avatar_asset_id = $2
WHERE
id = $1
`,
c.CurrentUser.ID,
2021-12-29 14:38:23 +00:00
avatarUUID,
)
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"))
}
}
err = tx.Commit(c.Context())
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-28 17:07:45 +00:00
res := c.Redirect(hmnurl.BuildUserSettings(""), http.StatusSeeOther)
res.AddFutureNotice("success", "User profile updated.")
return res
}
2021-12-15 01:17:42 +00:00
func UserProfileAdminSetStatus(c *RequestContext) ResponseData {
c.Req.ParseForm()
userIdStr := c.Req.Form.Get("user_id")
userId, err := strconv.Atoi(userIdStr)
if err != nil {
return RejectRequest(c, "No user id provided")
}
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:
return RejectRequest(c, "No legal user status provided")
}
_, err = c.Conn.Exec(c.Context(),
`
2022-05-07 13:11:05 +00:00
UPDATE hmn_user
2021-12-15 01:17:42 +00:00
SET status = $1
WHERE id = $2
`,
desiredStatus,
userId,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update user status"))
}
if desiredStatus == models.UserStatusBanned {
err = auth.DeleteSessionForUser(c.Context(), c.Conn, c.Req.Form.Get("username"))
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)
res.AddFutureNotice("success", "Successfully set status")
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 {
return RejectRequest(c, "No user id provided")
}
err = deleteAllPostsForUser(c.Context(), c.Conn, userId)
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
}
func updatePassword(c *RequestContext, tx pgx.Tx, old, new, confirm string) *ResponseData {
if new != confirm {
res := RejectRequest(c, "Your password and password confirmation did not match.")
return &res
}
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
}
ok, err := auth.CheckPassword(old, oldHashedPassword)
if err != nil {
2021-08-28 12:21:03 +00:00
res := c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to check user's password"))
return &res
}
if !ok {
res := RejectRequest(c, "The old password you provided was not correct.")
return &res
}
newHashedPassword := auth.HashPassword(new)
err = auth.UpdatePassword(c.Context(), tx, c.CurrentUser.Username, newHashedPassword)
if err != nil {
2021-08-28 12:21:03 +00:00
res := c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update password"))
return &res
}
return nil
}