547 lines
15 KiB
Go
547 lines
15 KiB
Go
package website
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.handmade.network/hmn/hmn/src/assets"
|
|
"git.handmade.network/hmn/hmn/src/auth"
|
|
"git.handmade.network/hmn/hmn/src/config"
|
|
"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"
|
|
"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/twitch"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v4"
|
|
)
|
|
|
|
type UserProfileTemplateData struct {
|
|
templates.BaseData
|
|
ProfileUser templates.User
|
|
ProfileUserLinks []templates.Link
|
|
ProfileUserProjects []templates.Project
|
|
TimelineItems []templates.TimelineItem
|
|
OwnProfile bool
|
|
ShowcaseUrl string
|
|
|
|
CanAddProject bool
|
|
NewProjectUrl string
|
|
|
|
AdminSetStatusUrl string
|
|
AdminNukeUrl string
|
|
}
|
|
|
|
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, c.Conn, c.CurrentUser, username, hmndata.UsersQuery{})
|
|
if err != nil {
|
|
if errors.Is(err, db.NotFound) {
|
|
return FourOhFour(c)
|
|
} else {
|
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user: %s", username))
|
|
}
|
|
}
|
|
profileUser = user
|
|
}
|
|
|
|
{
|
|
userIsUnapproved := profileUser.Status != models.UserStatusApproved
|
|
canViewUnapprovedUser := c.CurrentUser != nil && (c.CurrentUser.ID == profileUser.ID || c.CurrentUser.IsStaff)
|
|
if userIsUnapproved && !canViewUnapprovedUser {
|
|
return FourOhFour(c)
|
|
}
|
|
}
|
|
|
|
c.Perf.StartBlock("SQL", "Fetch user links")
|
|
userLinks, err := db.Query[models.Link](c, c.Conn,
|
|
`
|
|
SELECT $columns
|
|
FROM
|
|
link as link
|
|
WHERE
|
|
link.user_id = $1
|
|
ORDER BY link.ordering ASC
|
|
`,
|
|
profileUser.ID,
|
|
)
|
|
if err != nil {
|
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch links for user: %s", username))
|
|
}
|
|
profileUserLinks := make([]templates.Link, 0, len(userLinks))
|
|
for _, l := range userLinks {
|
|
profileUserLinks = append(profileUserLinks, templates.LinkToTemplate(l))
|
|
}
|
|
c.Perf.EndBlock()
|
|
|
|
projectsAndStuff, err := hmndata.FetchProjects(c, c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
|
|
OwnerIDs: []int{profileUser.ID},
|
|
Lifecycles: models.AllProjectLifecycles,
|
|
IncludeHidden: true,
|
|
OrderBy: "all_last_updated DESC",
|
|
})
|
|
templateProjects := make([]templates.Project, 0, len(projectsAndStuff))
|
|
numPersonalProjects := 0
|
|
for _, p := range projectsAndStuff {
|
|
templateProject := templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage(), c.Theme)
|
|
templateProjects = append(templateProjects, templateProject)
|
|
|
|
if p.Project.Personal {
|
|
numPersonalProjects++
|
|
}
|
|
}
|
|
c.Perf.EndBlock()
|
|
|
|
c.Perf.StartBlock("SQL", "Fetch posts")
|
|
posts, err := hmndata.FetchPosts(c, c.Conn, c.CurrentUser, hmndata.PostsQuery{
|
|
UserIDs: []int{profileUser.ID},
|
|
SortDescending: true,
|
|
})
|
|
c.Perf.EndBlock()
|
|
|
|
snippets, err := hmndata.FetchSnippets(c, c.Conn, c.CurrentUser, hmndata.SnippetQuery{
|
|
OwnerIDs: []int{profileUser.ID},
|
|
})
|
|
if err != nil {
|
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets for user: %s", username))
|
|
}
|
|
|
|
c.Perf.StartBlock("SQL", "Fetch subforum tree")
|
|
subforumTree := models.GetFullSubforumTree(c, c.Conn)
|
|
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
|
|
c.Perf.EndBlock()
|
|
|
|
c.Perf.StartBlock("PROFILE", "Construct timeline items")
|
|
timelineItems := make([]templates.TimelineItem, 0, len(posts)+len(snippets))
|
|
|
|
for _, post := range posts {
|
|
timelineItems = append(timelineItems, PostToTimelineItem(
|
|
hmndata.UrlContextForProject(&post.Project),
|
|
lineageBuilder,
|
|
&post.Post,
|
|
&post.Thread,
|
|
profileUser,
|
|
c.Theme,
|
|
))
|
|
}
|
|
|
|
for _, s := range snippets {
|
|
item := SnippetToTimelineItem(
|
|
&s.Snippet,
|
|
s.Asset,
|
|
s.DiscordMessage,
|
|
s.Tags,
|
|
profileUser,
|
|
c.Theme,
|
|
)
|
|
item.SmallInfo = true
|
|
timelineItems = append(timelineItems, item)
|
|
}
|
|
|
|
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()
|
|
|
|
templateUser := templates.UserToTemplate(profileUser, c.Theme)
|
|
|
|
baseData := getBaseDataAutocrumb(c, templateUser.Name)
|
|
|
|
var res ResponseData
|
|
res.MustWriteTemplate("user_profile.html", UserProfileTemplateData{
|
|
BaseData: baseData,
|
|
ProfileUser: templateUser,
|
|
ProfileUserLinks: profileUserLinks,
|
|
ProfileUserProjects: templateProjects,
|
|
TimelineItems: timelineItems,
|
|
OwnProfile: (c.CurrentUser != nil && c.CurrentUser.ID == profileUser.ID),
|
|
ShowcaseUrl: hmnurl.BuildShowcase(),
|
|
|
|
CanAddProject: numPersonalProjects < maxPersonalProjects,
|
|
NewProjectUrl: hmnurl.BuildProjectNew(),
|
|
|
|
AdminSetStatusUrl: hmnurl.BuildAdminSetUserStatus(),
|
|
AdminNukeUrl: hmnurl.BuildAdminNukeUser(),
|
|
}, c.Perf)
|
|
return res
|
|
}
|
|
|
|
var UserAvatarMaxFileSize = 1 * 1024 * 1024
|
|
|
|
func UserSettings(c *RequestContext) ResponseData {
|
|
var res ResponseData
|
|
|
|
type UserSettingsTemplateData struct {
|
|
templates.BaseData
|
|
|
|
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, c.Conn,
|
|
`
|
|
SELECT $columns
|
|
FROM link
|
|
WHERE user_id = $1
|
|
ORDER BY ordering
|
|
`,
|
|
c.CurrentUser.ID,
|
|
)
|
|
if err != nil {
|
|
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, c.Conn,
|
|
`
|
|
SELECT $columns
|
|
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 {
|
|
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, c.Conn,
|
|
`
|
|
SELECT COUNT(*)
|
|
FROM
|
|
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 {
|
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to check for unsaved user messages"))
|
|
}
|
|
}
|
|
|
|
templateUser := templates.UserToTemplate(c.CurrentUser, c.Theme)
|
|
|
|
baseData := getBaseDataAutocrumb(c, templateUser.Name)
|
|
|
|
res.MustWriteTemplate("user_settings.html", UserSettingsTemplateData{
|
|
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 {
|
|
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)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
defer tx.Rollback(c)
|
|
|
|
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 c.RejectRequest("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,
|
|
`
|
|
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 {
|
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update user"))
|
|
}
|
|
|
|
// Process links
|
|
twitchLoginsPreChange, preErr := hmndata.FetchTwitchLoginsForUserOrProject(c, tx, &c.CurrentUser.ID, nil)
|
|
linksText := form.Get("links")
|
|
links := ParseLinks(linksText)
|
|
_, err = tx.Exec(c, `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,
|
|
`
|
|
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
|
|
}
|
|
}
|
|
}
|
|
twitchLoginsPostChange, postErr := hmndata.FetchTwitchLoginsForUserOrProject(c, 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
|
|
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, 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,
|
|
`
|
|
UPDATE hmn_user
|
|
SET
|
|
avatar_asset_id = $2
|
|
WHERE
|
|
id = $1
|
|
`,
|
|
c.CurrentUser.ID,
|
|
avatarUUID,
|
|
)
|
|
if err != nil {
|
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update user's avatar"))
|
|
}
|
|
}
|
|
|
|
err = tx.Commit(c)
|
|
if err != nil {
|
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to save user settings"))
|
|
}
|
|
|
|
res := c.Redirect(hmnurl.BuildUserSettings(""), http.StatusSeeOther)
|
|
res.AddFutureNotice("success", "User profile updated.")
|
|
|
|
return res
|
|
}
|
|
|
|
func UserProfileAdminSetStatus(c *RequestContext) ResponseData {
|
|
c.Req.ParseForm()
|
|
|
|
userIdStr := c.Req.Form.Get("user_id")
|
|
userId, err := strconv.Atoi(userIdStr)
|
|
if err != nil {
|
|
return c.RejectRequest("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 c.RejectRequest("No legal user status provided")
|
|
}
|
|
|
|
_, err = c.Conn.Exec(c,
|
|
`
|
|
UPDATE hmn_user
|
|
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, c.Conn, c.Req.Form.Get("username"))
|
|
if err != nil {
|
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to log out user"))
|
|
}
|
|
}
|
|
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 c.RejectRequest("No user id provided")
|
|
}
|
|
|
|
err = deleteAllPostsForUser(c, 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 := c.RejectRequest("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 {
|
|
res := c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to check user's password"))
|
|
return &res
|
|
}
|
|
|
|
if !ok {
|
|
res := c.RejectRequest("The old password you provided was not correct.")
|
|
return &res
|
|
}
|
|
|
|
newHashedPassword := auth.HashPassword(new)
|
|
err = auth.UpdatePassword(c, tx, c.CurrentUser.Username, newHashedPassword)
|
|
if err != nil {
|
|
res := c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update password"))
|
|
return &res
|
|
}
|
|
|
|
return nil
|
|
}
|