hmn/src/website/user.go

510 lines
14 KiB
Go

package website
import (
"errors"
"fmt"
"net/http"
"sort"
"strings"
"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/hmnurl"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/templates"
"github.com/jackc/pgx/v4"
)
type UserProfileTemplateData struct {
templates.BaseData
ProfileUser templates.User
ProfileUserLinks []templates.Link
ProfileUserProjects []templates.Project
TimelineItems []templates.TimelineItem
NumForums int
NumBlogs int
NumSnippets int
}
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 {
c.Perf.StartBlock("SQL", "Fetch user")
userResult, err := db.QueryOne(c.Context(), c.Conn, models.User{},
`
SELECT $columns
FROM
auth_user
WHERE
LOWER(auth_user.username) = $1
`,
username,
)
c.Perf.EndBlock()
if err != nil {
if errors.Is(err, db.ErrNoMatchingRows) {
return FourOhFour(c)
} else {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user: %s", username))
}
}
profileUser = userResult.(*models.User)
}
c.Perf.StartBlock("SQL", "Fetch user links")
type userLinkQuery struct {
UserLink models.Link `db:"link"`
}
userLinkQueryResult, err := db.Query(c.Context(), c.Conn, userLinkQuery{},
`
SELECT $columns
FROM
handmade_links as link
WHERE
link.user_id = $1
ORDER BY link.ordering ASC
`,
profileUser.ID,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch links for user: %s", username))
}
userLinksSlice := userLinkQueryResult.ToSlice()
profileUserLinks := make([]templates.Link, 0, len(userLinksSlice))
for _, l := range userLinksSlice {
profileUserLinks = append(profileUserLinks, templates.LinkToTemplate(&l.(*userLinkQuery).UserLink))
}
c.Perf.EndBlock()
type projectQuery struct {
Project models.Project `db:"project"`
}
c.Perf.StartBlock("SQL", "Fetch projects")
projectQueryResult, err := db.Query(c.Context(), c.Conn, projectQuery{},
`
SELECT $columns
FROM
handmade_project AS project
INNER JOIN handmade_user_projects AS uproj ON uproj.project_id = project.id
WHERE
uproj.user_id = $1
AND ($2 OR (project.flags = 0 AND project.lifecycle = ANY ($3)))
`,
profileUser.ID,
(c.CurrentUser != nil && (profileUser == c.CurrentUser || c.CurrentUser.IsStaff)),
models.VisibleProjectLifecycles,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch projects for user: %s", username))
}
projectQuerySlice := projectQueryResult.ToSlice()
templateProjects := make([]templates.Project, 0, len(projectQuerySlice))
for _, projectRow := range projectQuerySlice {
projectData := projectRow.(*projectQuery)
templateProjects = append(templateProjects, templates.ProjectToTemplate(&projectData.Project, c.Theme))
}
c.Perf.EndBlock()
type postQuery struct {
Post models.Post `db:"post"`
Thread models.Thread `db:"thread"`
Project models.Project `db:"project"`
}
c.Perf.StartBlock("SQL", "Fetch posts")
postQueryResult, err := db.Query(c.Context(), c.Conn, postQuery{},
`
SELECT $columns
FROM
handmade_post AS post
INNER JOIN handmade_thread AS thread ON thread.id = post.thread_id
INNER JOIN handmade_project AS project ON project.id = post.project_id
WHERE
post.author_id = $1
AND project.lifecycle = ANY ($2)
`,
profileUser.ID,
models.VisibleProjectLifecycles,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch posts for user: %s", username))
}
postQuerySlice := postQueryResult.ToSlice()
c.Perf.EndBlock()
type snippetQuery struct {
Snippet models.Snippet `db:"snippet"`
Asset *models.Asset `db:"asset"`
DiscordMessage *models.DiscordMessage `db:"discord_message"`
}
c.Perf.StartBlock("SQL", "Fetch snippets")
snippetQueryResult, err := db.Query(c.Context(), c.Conn, snippetQuery{},
`
SELECT $columns
FROM
handmade_snippet AS snippet
LEFT JOIN handmade_asset AS asset ON asset.id = snippet.asset_id
LEFT JOIN handmade_discordmessage AS discord_message ON discord_message.id = snippet.discord_message_id
WHERE
snippet.owner_id = $1
`,
profileUser.ID,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets for user: %s", username))
}
snippetQuerySlice := snippetQueryResult.ToSlice()
c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Fetch subforum tree")
subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
c.Perf.EndBlock()
c.Perf.StartBlock("PROFILE", "Construct timeline items")
timelineItems := make([]templates.TimelineItem, 0, len(postQuerySlice)+len(snippetQuerySlice))
numForums := 0
numBlogs := 0
numSnippets := len(snippetQuerySlice)
for _, postRow := range postQuerySlice {
postData := postRow.(*postQuery)
timelineItem := PostToTimelineItem(
lineageBuilder,
&postData.Post,
&postData.Thread,
&postData.Project,
profileUser,
c.Theme,
)
switch timelineItem.Type {
case templates.TimelineTypeForumThread, templates.TimelineTypeForumReply:
numForums += 1
case templates.TimelineTypeBlogPost, templates.TimelineTypeBlogComment:
numBlogs += 1
}
if timelineItem.Type != templates.TimelineTypeUnknown {
timelineItems = append(timelineItems, timelineItem)
} else {
c.Logger.Warn().Int("post ID", postData.Post.ID).Msg("Unknown timeline item type for post")
}
}
for _, snippetRow := range snippetQuerySlice {
snippetData := snippetRow.(*snippetQuery)
timelineItem := SnippetToTimelineItem(
&snippetData.Snippet,
snippetData.Asset,
snippetData.DiscordMessage,
profileUser,
c.Theme,
)
timelineItems = append(timelineItems, timelineItem)
}
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 := getBaseData(c)
baseData.Title = templateUser.Name
var res ResponseData
res.MustWriteTemplate("user_profile.html", UserProfileTemplateData{
BaseData: baseData,
ProfileUser: templateUser,
ProfileUserLinks: profileUserLinks,
ProfileUserProjects: templateProjects,
TimelineItems: timelineItems,
NumForums: numForums,
NumBlogs: numBlogs,
NumSnippets: numSnippets,
}, c.Perf)
return res
}
func UserSettings(c *RequestContext) ResponseData {
var res ResponseData
type UserSettingsTemplateData struct {
templates.BaseData
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
}
ilinks, err := db.Query(c.Context(), c.Conn, models.Link{},
`
SELECT $columns
FROM handmade_links
WHERE user_id = $1
ORDER BY ordering
`,
c.CurrentUser.ID,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user links"))
}
links := ilinks.ToSlice()
linksText := ""
for _, ilink := range links {
link := ilink.(*models.Link)
linksText += fmt.Sprintf("%s %s\n", link.URL, link.Name)
}
var tduser *templates.DiscordUser
var numUnsavedMessages int
iduser, err := db.QueryOne(c.Context(), c.Conn, models.DiscordUser{},
`
SELECT $columns
FROM handmade_discorduser
WHERE hmn_user_id = $1
`,
c.CurrentUser.ID,
)
if errors.Is(err, db.ErrNoMatchingRows) {
// this is fine, but don't fetch any more messages
} else if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user's Discord account"))
} else {
duser := iduser.(*models.DiscordUser)
tmp := templates.DiscordUserToTemplate(duser)
tduser = &tmp
numUnsavedMessages, err = db.QueryInt(c.Context(), c.Conn,
`
SELECT COUNT(*)
FROM
handmade_discordmessage AS msg
LEFT JOIN handmade_discordmessagecontent 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 ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to check for unsaved user messages"))
}
}
templateUser := templates.UserToTemplate(c.CurrentUser, c.Theme)
baseData := getBaseData(c)
baseData.Title = templateUser.Name
res.MustWriteTemplate("user_settings.html", UserSettingsTemplateData{
BaseData: baseData,
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 {
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(),
`
UPDATE auth_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 ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update user"))
}
// Process links
linksText := form.Get("links")
links := strings.Split(linksText, "\n")
_, err = tx.Exec(c.Context(), `DELETE FROM handmade_links 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 {
link = strings.TrimSpace(link)
linkParts := strings.SplitN(link, " ", 2)
url := strings.TrimSpace(linkParts[0])
name := ""
if len(linkParts) > 1 {
name = strings.TrimSpace(linkParts[1])
}
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
continue
}
_, err := tx.Exec(c.Context(),
`
INSERT INTO handmade_links (name, url, ordering, user_id)
VALUES ($1, $2, $3, $4)
`,
name,
url,
i,
c.CurrentUser.ID,
)
if err != nil {
c.Logger.Warn().Err(err).Msg("failed to insert new link")
continue
}
}
}
// 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
_, err = SaveImageFile(c, tx, "avatar", 1*1024*1024, fmt.Sprintf("members/avatars/%s", c.CurrentUser.Username))
if err != nil {
var rejectErr RejectRequestError
if errors.As(err, &rejectErr) {
return RejectRequest(c, rejectErr.Error())
} else {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to save new avatar"))
}
}
// TODO: Success message
err = tx.Commit(c.Context())
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to save user settings"))
}
return c.Redirect(hmnurl.BuildUserSettings(""), http.StatusSeeOther)
}
// TODO: Rework this to use that RejectRequestError thing
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 {
res := 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 {
res := ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update password"))
return &res
}
return nil
}