diff --git a/src/templates/src/forum_post_delete.html b/src/templates/src/forum_post_delete.html index a49463ed..bd650607 100644 --- a/src/templates/src/forum_post_delete.html +++ b/src/templates/src/forum_post_delete.html @@ -5,6 +5,7 @@

Are you sure you want to delete this post?

{{ template "forum_post_standalone.html" .Post }}
+ {{ csrftoken .Session }}
diff --git a/src/website/forums.go b/src/website/forums.go index 1996f0d9..dad4951d 100644 --- a/src/website/forums.go +++ b/src/website/forums.go @@ -19,7 +19,6 @@ import ( "git.handmade.network/hmn/hmn/src/templates" "git.handmade.network/hmn/hmn/src/utils" "github.com/jackc/pgx/v4" - "github.com/jackc/pgx/v4/pgxpool" ) type forumCategoryData struct { @@ -54,17 +53,12 @@ type editorData struct { func ForumCategory(c *RequestContext) ResponseData { const threadsPerPage = 25 - c.Perf.StartBlock("SQL", "Fetch category tree") - categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn) - lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree) - c.Perf.EndBlock() - - currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"]) - if !valid { + cd, ok := getCommonForumData(c) + if !ok { return FourOhFour(c) } - currentSubforumSlugs := lineageBuilder.GetSubforumLineageSlugs(currentCatId) + currentSubforumSlugs := cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID) c.Perf.StartBlock("SQL", "Fetch count of page threads") numThreads, err := db.QueryInt(c.Context(), c.Conn, @@ -75,7 +69,7 @@ func ForumCategory(c *RequestContext) ResponseData { thread.category_id = $1 AND NOT thread.deleted `, - currentCatId, + cd.CatID, ) if err != nil { panic(oops.New(err, "failed to get count of threads")) @@ -137,7 +131,7 @@ func ForumCategory(c *RequestContext) ResponseData { ORDER BY lastpost.postdate DESC LIMIT $3 OFFSET $4 `, - currentCatId, + cd.CatID, currentUserId, threadsPerPage, howManyThreadsToSkip, @@ -158,7 +152,7 @@ func ForumCategory(c *RequestContext) ResponseData { return templates.ThreadListItem{ Title: row.Thread.Title, - Url: hmnurl.BuildForumThread(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(row.Thread.CategoryID), row.Thread.ID, row.Thread.Title, 1), + Url: hmnurl.BuildForumThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(row.Thread.CategoryID), row.Thread.ID, row.Thread.Title, 1), FirstUser: templates.UserToTemplate(row.FirstUser, c.Theme), FirstDate: row.FirstPost.PostDate, LastUser: templates.UserToTemplate(row.LastUser, c.Theme), @@ -180,7 +174,7 @@ func ForumCategory(c *RequestContext) ResponseData { var subcats []forumSubcategoryData if page == 1 { - subcatNodes := categoryTree[currentCatId].Children + subcatNodes := cd.CategoryTree[cd.CatID].Children for _, catNode := range subcatNodes { c.Perf.StartBlock("SQL", "Fetch count of subcategory threads") @@ -242,7 +236,7 @@ func ForumCategory(c *RequestContext) ResponseData { subcats = append(subcats, forumSubcategoryData{ Name: *catNode.Name, - Url: hmnurl.BuildForumCategory(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(catNode.ID), 1), + Url: hmnurl.BuildForumCategory(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(catNode.ID), 1), Threads: threads, TotalThreads: numThreads, }) @@ -267,7 +261,7 @@ func ForumCategory(c *RequestContext) ResponseData { }, } - currentSubforums := lineageBuilder.GetSubforumLineage(currentCatId) + currentSubforums := cd.LineageBuilder.GetSubforumLineage(cd.CatID) for i, subforum := range currentSubforums { baseData.Breadcrumbs = append(baseData.Breadcrumbs, templates.Breadcrumb{ Name: *subforum.Name, // NOTE(asaf): All subforum categories must have names. @@ -279,7 +273,7 @@ func ForumCategory(c *RequestContext) ResponseData { res.MustWriteTemplate("forum_category.html", forumCategoryData{ BaseData: baseData, NewThreadUrl: hmnurl.BuildForumNewThread(c.CurrentProject.Slug, currentSubforumSlugs, false), - MarkReadUrl: hmnurl.BuildForumCategoryMarkRead(currentCatId), + MarkReadUrl: hmnurl.BuildForumCategoryMarkRead(cd.CatID), Threads: threads, Pagination: templates.Pagination{ Current: page, @@ -406,50 +400,14 @@ type forumThreadData struct { var threadViewPostsPerPage = 15 func ForumThread(c *RequestContext) ResponseData { - c.Perf.StartBlock("SQL", "Fetch category tree") - categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn) - lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree) - c.Perf.EndBlock() - - currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"]) - if !valid { + cd, ok := getCommonForumData(c) + if !ok { return FourOhFour(c) } - threadId, err := strconv.Atoi(c.PathParams["threadid"]) - if err != nil { - return FourOhFour(c) - } + currentSubforumSlugs := cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID) - currentSubforumSlugs := lineageBuilder.GetSubforumLineageSlugs(currentCatId) - - c.Perf.StartBlock("SQL", "Fetch current thread") - type threadQueryResult struct { - Thread models.Thread `db:"thread"` - } - irow, err := db.QueryOne(c.Context(), c.Conn, threadQueryResult{}, - ` - SELECT $columns - FROM - handmade_thread AS thread - JOIN handmade_category AS cat ON cat.id = thread.category_id - WHERE - thread.id = $1 - AND NOT thread.deleted - AND cat.id = $2 - `, - threadId, - currentCatId, // NOTE(asaf): This verifies that the requested thread is under the requested subforum. - ) - c.Perf.EndBlock() - if err != nil { - if errors.Is(err, db.ErrNoMatchingRows) { - return FourOhFour(c) - } else { - panic(err) - } - } - thread := irow.(*threadQueryResult).Thread + thread := cd.FetchThread(c.Context(), c.Conn) numPosts, err := db.QueryInt(c.Context(), c.Conn, ` @@ -533,21 +491,23 @@ func ForumThread(c *RequestContext) ResponseData { } // Update thread last read info - c.Perf.StartBlock("SQL", "Update TLRI") - _, err = c.Conn.Exec(c.Context(), - ` + if c.CurrentUser != nil { + c.Perf.StartBlock("SQL", "Update TLRI") + _, err = c.Conn.Exec(c.Context(), + ` INSERT INTO handmade_threadlastreadinfo (thread_id, user_id, lastread) VALUES ($1, $2, $3) ON CONFLICT (thread_id, user_id) DO UPDATE SET lastread = EXCLUDED.lastread `, - threadId, - c.CurrentUser.ID, - time.Now(), - ) - c.Perf.EndBlock() - if err != nil { - return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update forum tlri")) + cd.ThreadID, + c.CurrentUser.ID, + time.Now(), + ) + c.Perf.EndBlock() + if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update forum tlri")) + } } baseData := getBaseData(c) @@ -557,7 +517,7 @@ func ForumThread(c *RequestContext) ResponseData { var res ResponseData res.MustWriteTemplate("forum_thread.html", forumThreadData{ BaseData: baseData, - Thread: templates.ThreadToTemplate(&thread), + Thread: templates.ThreadToTemplate(thread), Posts: posts, CategoryUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, 1), ReplyUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, *thread.FirstID), @@ -567,26 +527,8 @@ func ForumThread(c *RequestContext) ResponseData { } func ForumPostRedirect(c *RequestContext) ResponseData { - // TODO(compression): This logic for fetching posts by thread id / post id is gonna - // show up in a lot of places. It's used multiple times for forums, and also for blogs. - // Consider compressing this later. - c.Perf.StartBlock("SQL", "Fetch category tree") - categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn) - lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree) - c.Perf.EndBlock() - - currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"]) - if !valid { - return FourOhFour(c) - } - - requestedThreadId, err := strconv.Atoi(c.PathParams["threadid"]) - if err != nil { - return FourOhFour(c) - } - - requestedPostId, err := strconv.Atoi(c.PathParams["postid"]) - if err != nil { + cd, ok := getCommonForumData(c) + if !ok { return FourOhFour(c) } @@ -605,8 +547,8 @@ func ForumPostRedirect(c *RequestContext) ResponseData { AND NOT post.deleted ORDER BY postdate `, - currentCatId, - requestedThreadId, + cd.CatID, + cd.ThreadID, ) if err != nil { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch post ids")) @@ -615,7 +557,7 @@ func ForumPostRedirect(c *RequestContext) ResponseData { c.Perf.EndBlock() postIdx := -1 for i, id := range postQuerySlice { - if id.(*postQuery).PostID == requestedPostId { + if id.(*postQuery).PostID == cd.PostID { postIdx = i break } @@ -634,7 +576,7 @@ func ForumPostRedirect(c *RequestContext) ResponseData { FROM handmade_thread AS thread WHERE thread.id = $1 `, - requestedThreadId, + cd.ThreadID, ) if err != nil { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch thread title")) @@ -646,11 +588,11 @@ func ForumPostRedirect(c *RequestContext) ResponseData { return c.Redirect(hmnurl.BuildForumThreadWithPostHash( c.CurrentProject.Slug, - lineageBuilder.GetSubforumLineageSlugs(currentCatId), - requestedThreadId, + cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID), + cd.ThreadID, threadTitle, page, - requestedPostId, + cd.PostID, ), http.StatusSeeOther) } @@ -660,20 +602,15 @@ func ForumNewThread(c *RequestContext) ResponseData { baseData.MathjaxEnabled = true // TODO(ben): Set breadcrumbs - c.Perf.StartBlock("SQL", "Fetch category tree") - categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn) - lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree) - c.Perf.EndBlock() - - currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"]) - if !valid { + cd, ok := getCommonForumData(c) + if !ok { return FourOhFour(c) } var res ResponseData res.MustWriteTemplate("editor.html", editorData{ BaseData: baseData, - SubmitUrl: hmnurl.BuildForumNewThread(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), true), + SubmitUrl: hmnurl.BuildForumNewThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID), true), SubmitLabel: "Post New Thread", }, c.Perf) return res @@ -686,13 +623,8 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData { } defer tx.Rollback(c.Context()) - c.Perf.StartBlock("SQL", "Fetch category tree") - categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn) - lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree) - c.Perf.EndBlock() - - currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"]) - if !valid { + cd, ok := getCommonForumData(c) + if !ok { return FourOhFour(c) } @@ -705,6 +637,8 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData { sticky = true } + // TODO(ben): Validation (and error handling if ParseForm fails? might not need it since you'll get empty values) + // Create thread var threadId int err = tx.QueryRow(c.Context(), @@ -715,7 +649,7 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData { `, title, sticky, - currentCatId, + cd.CatID, -1, -1, ).Scan(&threadId) @@ -723,7 +657,7 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData { panic(oops.New(err, "failed to create thread")) } - postId, _ := createNewForumPostAndVersion(c.Context(), tx, currentCatId, threadId, c.CurrentUser.ID, c.CurrentProject.ID, unparsed, c.Req.Host, nil) + postId, _ := createNewForumPostAndVersion(c.Context(), tx, cd.CatID, threadId, c.CurrentUser.ID, c.CurrentProject.ID, unparsed, c.Req.Host, nil) // Update thread with post id _, err = tx.Exec(c.Context(), @@ -746,84 +680,30 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create new forum thread")) } - newThreadUrl := hmnurl.BuildForumThread(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), threadId, title, 1) + newThreadUrl := hmnurl.BuildForumThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID), threadId, title, 1) return c.Redirect(newThreadUrl, http.StatusSeeOther) } func ForumPostReply(c *RequestContext) ResponseData { - // TODO(compression): This logic for fetching posts by thread id / post id is gonna - // show up in a lot of places. It's used multiple times for forums, and also for blogs. - // Consider compressing this later. - c.Perf.StartBlock("SQL", "Fetch category tree") - categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn) - lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree) - c.Perf.EndBlock() - - currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"]) - if !valid { + cd, ok := getCommonForumData(c) + if !ok { return FourOhFour(c) } - requestedThreadId, err := strconv.Atoi(c.PathParams["threadid"]) - if err != nil { - return FourOhFour(c) - } - - requestedPostId, err := strconv.Atoi(c.PathParams["postid"]) - if err != nil { - return FourOhFour(c) - } - - c.Perf.StartBlock("SQL", "Fetch post to reply to") - // TODO: Scope this down to just what you need - type postQuery struct { - Thread models.Thread `db:"thread"` - Post models.Post `db:"post"` - CurrentVersion models.PostVersion `db:"ver"` - Author *models.User `db:"author"` - Editor *models.User `db:"editor"` - } - postQueryResult, err := db.QueryOne(c.Context(), c.Conn, postQuery{}, - ` - SELECT $columns - FROM - handmade_thread AS thread - JOIN handmade_post AS post ON post.thread_id = thread.id - JOIN handmade_postversion AS ver ON post.current_id = ver.id - LEFT JOIN auth_user AS author ON post.author_id = author.id - LEFT JOIN auth_user AS editor ON ver.editor_id = editor.id - WHERE - post.category_id = $1 - AND post.thread_id = $2 - AND post.id = $3 - AND NOT post.deleted - ORDER BY postdate - `, - currentCatId, - requestedThreadId, - requestedPostId, - ) - if err != nil { - if errors.Is(err, db.ErrNoMatchingRows) { - return FourOhFour(c) - } else { - return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch reply post")) - } - } - result := postQueryResult.(*postQuery) + postData := cd.FetchPostAndStuff(c.Context(), c.Conn) baseData := getBaseData(c) - baseData.Title = fmt.Sprintf("Replying to \"%s\" | %s", result.Thread.Title, *categoryTree[currentCatId].Name) + baseData.Title = fmt.Sprintf("Replying to \"%s\" | %s", postData.Thread.Title, *cd.CategoryTree[cd.CatID].Name) baseData.MathjaxEnabled = true // TODO(ben): Set breadcrumbs - templatePost := templates.PostToTemplate(&result.Post, result.Author, c.Theme) - templatePost.AddContentVersion(result.CurrentVersion, result.Editor) + templatePost := templates.PostToTemplate(&postData.Post, postData.Author, c.Theme) + templatePost.AddContentVersion(postData.CurrentVersion, postData.Editor) var res ResponseData res.MustWriteTemplate("editor.html", editorData{ BaseData: baseData, - SubmitUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), requestedThreadId, requestedPostId), + SubmitUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID), cd.ThreadID, cd.PostID), SubmitLabel: "Submit Reply", Title: "Replying to post", @@ -833,264 +713,117 @@ func ForumPostReply(c *RequestContext) ResponseData { } func ForumPostReplySubmit(c *RequestContext) ResponseData { + cd, ok := getCommonForumData(c) + if !ok { + return FourOhFour(c) + } + tx, err := c.Conn.Begin(c.Context()) if err != nil { panic(err) } defer tx.Rollback(c.Context()) - c.Perf.StartBlock("SQL", "Fetch category tree") - categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn) - lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree) - c.Perf.EndBlock() - - currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"]) - if !valid { - return FourOhFour(c) - } - - threadId, err := strconv.Atoi(c.PathParams["threadid"]) - if err != nil { - return FourOhFour(c) - } - - postId, err := strconv.Atoi(c.PathParams["postid"]) - if err != nil { - return FourOhFour(c) - } - c.Req.ParseForm() + // TODO(ben): Validation unparsed := c.Req.Form.Get("body") - newPostId, _ := createNewForumPostAndVersion(c.Context(), tx, currentCatId, threadId, c.CurrentUser.ID, c.CurrentProject.ID, unparsed, c.Req.Host, &postId) + newPostId, _ := createNewForumPostAndVersion(c.Context(), tx, cd.CatID, cd.ThreadID, c.CurrentUser.ID, c.CurrentProject.ID, unparsed, c.Req.Host, &cd.PostID) err = tx.Commit(c.Context()) if err != nil { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to reply to forum post")) } - newPostUrl := hmnurl.BuildForumPost(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), threadId, newPostId) + newPostUrl := hmnurl.BuildForumPost(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID), cd.ThreadID, newPostId) return c.Redirect(newPostUrl, http.StatusSeeOther) } func ForumPostEdit(c *RequestContext) ResponseData { - // TODO(compression): This logic for fetching posts by thread id / post id is gonna - // show up in a lot of places. It's used multiple times for forums, and also for blogs. - // Consider compressing this later. - c.Perf.StartBlock("SQL", "Fetch category tree") - categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn) - lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree) - c.Perf.EndBlock() - - currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"]) - if !valid { + cd, ok := getCommonForumData(c) + if !ok { return FourOhFour(c) } - requestedThreadId, err := strconv.Atoi(c.PathParams["threadid"]) - if err != nil { + if !cd.UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser) { return FourOhFour(c) } - requestedPostId, err := strconv.Atoi(c.PathParams["postid"]) - if err != nil { - return FourOhFour(c) - } - - c.Perf.StartBlock("SQL", "Fetch post to edit") - // TODO: Scope this down to just what you need - type postQuery struct { - Thread models.Thread `db:"thread"` - Post models.Post `db:"post"` - CurrentVersion models.PostVersion `db:"ver"` - Author *models.User `db:"author"` - Editor *models.User `db:"editor"` - } - postQueryResult, err := db.QueryOne(c.Context(), c.Conn, postQuery{}, - ` - SELECT $columns - FROM - handmade_thread AS thread - JOIN handmade_post AS post ON post.thread_id = thread.id - JOIN handmade_postversion AS ver ON post.current_id = ver.id - LEFT JOIN auth_user AS author ON post.author_id = author.id - LEFT JOIN auth_user AS editor ON ver.editor_id = editor.id - WHERE - post.category_id = $1 - AND post.thread_id = $2 - AND post.id = $3 - AND NOT post.deleted - ORDER BY postdate - `, - currentCatId, - requestedThreadId, - requestedPostId, - ) - if err != nil { - if errors.Is(err, db.ErrNoMatchingRows) { - return FourOhFour(c) - } else { - return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch reply post")) - } - } - result := postQueryResult.(*postQuery) - - // Ensure that the user is permitted to edit the post - canEdit, err := canEditPost(c.Context(), c.Conn, requestedPostId, *c.CurrentUser) - if err != nil { - return ErrorResponse(http.StatusInternalServerError, err) - } else if !canEdit { - return FourOhFour(c) - } + postData := cd.FetchPostAndStuff(c.Context(), c.Conn) baseData := getBaseData(c) - baseData.Title = fmt.Sprintf("Editing \"%s\" | %s", result.Thread.Title, *categoryTree[currentCatId].Name) + baseData.Title = fmt.Sprintf("Editing \"%s\" | %s", postData.Thread.Title, *cd.CategoryTree[cd.CatID].Name) baseData.MathjaxEnabled = true // TODO(ben): Set breadcrumbs - templatePost := templates.PostToTemplate(&result.Post, result.Author, c.Theme) - templatePost.AddContentVersion(result.CurrentVersion, result.Editor) + templatePost := templates.PostToTemplate(&postData.Post, postData.Author, c.Theme) + templatePost.AddContentVersion(postData.CurrentVersion, postData.Editor) var res ResponseData res.MustWriteTemplate("editor.html", editorData{ BaseData: baseData, - SubmitUrl: hmnurl.BuildForumPostEdit(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), requestedThreadId, requestedPostId), - Title: result.Thread.Title, + SubmitUrl: hmnurl.BuildForumPostEdit(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID), cd.ThreadID, cd.PostID), + Title: postData.Thread.Title, SubmitLabel: "Submit Edited Post", IsEditing: true, - EditInitialContents: result.CurrentVersion.TextRaw, + EditInitialContents: postData.CurrentVersion.TextRaw, }, c.Perf) return res } func ForumPostEditSubmit(c *RequestContext) ResponseData { + cd, ok := getCommonForumData(c) + if !ok { + return FourOhFour(c) + } + + if !cd.UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser) { + return FourOhFour(c) + } + tx, err := c.Conn.Begin(c.Context()) if err != nil { panic(err) } defer tx.Rollback(c.Context()) - c.Perf.StartBlock("SQL", "Fetch category tree") - categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn) - lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree) - c.Perf.EndBlock() - - currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"]) - if !valid { - return FourOhFour(c) - } - - threadId, err := strconv.Atoi(c.PathParams["threadid"]) - if err != nil { - return FourOhFour(c) - } - - postId, err := strconv.Atoi(c.PathParams["postid"]) - if err != nil { - return FourOhFour(c) - } - - // Ensure that the user is permitted to edit the post - canEdit, err := canEditPost(c.Context(), c.Conn, postId, *c.CurrentUser) - if err != nil { - return ErrorResponse(http.StatusInternalServerError, err) - } else if !canEdit { - return FourOhFour(c) - } - c.Req.ParseForm() + // TODO(ben): Validation unparsed := c.Req.Form.Get("body") editReason := c.Req.Form.Get("editreason") - createForumPostVersion(c.Context(), tx, postId, unparsed, c.Req.Host, editReason, &c.CurrentUser.ID) + createForumPostVersion(c.Context(), tx, cd.PostID, unparsed, c.Req.Host, editReason, &c.CurrentUser.ID) err = tx.Commit(c.Context()) if err != nil { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to edit forum post")) } - postUrl := hmnurl.BuildForumPost(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), threadId, postId) + postUrl := hmnurl.BuildForumPost(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID), cd.ThreadID, cd.PostID) return c.Redirect(postUrl, http.StatusSeeOther) } func ForumPostDelete(c *RequestContext) ResponseData { - // TODO(compression): This logic for fetching posts by thread id / post id is gonna - // show up in a lot of places. It's used multiple times for forums, and also for blogs. - // Consider compressing this later. - c.Perf.StartBlock("SQL", "Fetch category tree") - categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn) - lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree) - c.Perf.EndBlock() - - currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"]) - if !valid { + cd, ok := getCommonForumData(c) + if !ok { return FourOhFour(c) } - requestedThreadId, err := strconv.Atoi(c.PathParams["threadid"]) - if err != nil { + if !cd.UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser) { return FourOhFour(c) } - requestedPostId, err := strconv.Atoi(c.PathParams["postid"]) - if err != nil { - return FourOhFour(c) - } - - // Ensure that the user is allowed to delete this post - canEdit, err := canEditPost(c.Context(), c.Conn, requestedPostId, *c.CurrentUser) - if err != nil { - return ErrorResponse(http.StatusInternalServerError, err) - } else if !canEdit { - return FourOhFour(c) - } - - c.Perf.StartBlock("SQL", "Fetch post to delete") - type postQuery struct { - Thread models.Thread `db:"thread"` - Post models.Post `db:"post"` - CurrentVersion models.PostVersion `db:"ver"` - Author *models.User `db:"author"` - Editor *models.User `db:"editor"` - } - postQueryResult, err := db.QueryOne(c.Context(), c.Conn, postQuery{}, - ` - SELECT $columns - FROM - handmade_thread AS thread - JOIN handmade_post AS post ON post.thread_id = thread.id - JOIN handmade_postversion AS ver ON post.current_id = ver.id - LEFT JOIN auth_user AS author ON post.author_id = author.id - LEFT JOIN auth_user AS editor ON ver.editor_id = editor.id - WHERE - post.category_id = $1 - AND post.thread_id = $2 - AND post.id = $3 - AND NOT post.deleted - ORDER BY postdate - `, - currentCatId, - requestedThreadId, - requestedPostId, - ) - if err != nil { - if errors.Is(err, db.ErrNoMatchingRows) { - return FourOhFour(c) - } else { - return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch post to delete")) - } - } - result := postQueryResult.(*postQuery) + postData := cd.FetchPostAndStuff(c.Context(), c.Conn) baseData := getBaseData(c) - baseData.Title = fmt.Sprintf("Deleting post in \"%s\" | %s", result.Thread.Title, *categoryTree[currentCatId].Name) + baseData.Title = fmt.Sprintf("Deleting post in \"%s\" | %s", postData.Thread.Title, *cd.CategoryTree[cd.CatID].Name) baseData.MathjaxEnabled = true // TODO(ben): Set breadcrumbs - templatePost := templates.PostToTemplate(&result.Post, result.Author, c.Theme) - templatePost.AddContentVersion(result.CurrentVersion, result.Editor) + templatePost := templates.PostToTemplate(&postData.Post, postData.Author, c.Theme) + templatePost.AddContentVersion(postData.CurrentVersion, postData.Editor) type forumPostDeleteData struct { templates.BaseData @@ -1101,41 +834,19 @@ func ForumPostDelete(c *RequestContext) ResponseData { var res ResponseData res.MustWriteTemplate("forum_post_delete.html", forumPostDeleteData{ BaseData: baseData, - SubmitUrl: hmnurl.BuildForumPostDelete(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), requestedThreadId, requestedPostId), + SubmitUrl: hmnurl.BuildForumPostDelete(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID), cd.ThreadID, cd.PostID), Post: templatePost, }, c.Perf) return res } func ForumPostDeleteSubmit(c *RequestContext) ResponseData { - // TODO(compression): This logic for fetching posts by thread id / post id is gonna - // show up in a lot of places. It's used multiple times for forums, and also for blogs. - // Consider compressing this later. - c.Perf.StartBlock("SQL", "Fetch category tree") - categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn) - lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree) - c.Perf.EndBlock() - - currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"]) - if !valid { + cd, ok := getCommonForumData(c) + if !ok { return FourOhFour(c) } - threadId, err := strconv.Atoi(c.PathParams["threadid"]) - if err != nil { - return FourOhFour(c) - } - - postId, err := strconv.Atoi(c.PathParams["postid"]) - if err != nil { - return FourOhFour(c) - } - - // Ensure that the user is allowed to delete this post - canEdit, err := canEditPost(c.Context(), c.Conn, postId, *c.CurrentUser) - if err != nil { - return ErrorResponse(http.StatusInternalServerError, err) - } else if !canEdit { + if !cd.UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser) { return FourOhFour(c) } @@ -1153,8 +864,8 @@ func ForumPostDeleteSubmit(c *RequestContext) ResponseData { WHERE thread.id = $2 `, - postId, - threadId, + cd.PostID, + cd.ThreadID, ) if err != nil { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to check if post was the first post in the thread")) @@ -1168,7 +879,7 @@ func ForumPostDeleteSubmit(c *RequestContext) ResponseData { SET deleted = TRUE WHERE id = $1 `, - threadId, + cd.ThreadID, ) _, err = tx.Exec(c.Context(), ` @@ -1176,7 +887,7 @@ func ForumPostDeleteSubmit(c *RequestContext) ResponseData { SET deleted = TRUE WHERE thread_id = $1 `, - threadId, + cd.ThreadID, ) err = tx.Commit(c.Context()) @@ -1184,7 +895,7 @@ func ForumPostDeleteSubmit(c *RequestContext) ResponseData { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete thread and posts when deleting the first post")) } - forumUrl := hmnurl.BuildForumCategory(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), 1) + forumUrl := hmnurl.BuildForumCategory(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID), 1) return c.Redirect(forumUrl, http.StatusSeeOther) } @@ -1195,13 +906,13 @@ func ForumPostDeleteSubmit(c *RequestContext) ResponseData { WHERE id = $1 `, - postId, + cd.PostID, ) if err != nil { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to mark forum post as deleted")) } - err = fixThreadPostIds(c.Context(), tx, threadId) + err = fixThreadPostIds(c.Context(), tx, cd.ThreadID) if err != nil { if errors.Is(err, errThreadEmpty) { panic("it shouldn't be possible to delete the last remaining post in a thread, without it also being the first post in the thread and thus resulting in the whole thread getting deleted earlier") @@ -1215,7 +926,7 @@ func ForumPostDeleteSubmit(c *RequestContext) ResponseData { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete post")) } - threadUrl := hmnurl.BuildForumThread(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), threadId, "", 1) // TODO: Go to the last page of the thread? Or the post before the post we just deleted? + threadUrl := hmnurl.BuildForumThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID), cd.ThreadID, "", 1) // TODO: Go to the last page of the thread? Or the post before the post we just deleted? return c.Redirect(threadUrl, http.StatusSeeOther) } @@ -1294,73 +1005,6 @@ func createForumPostVersion(ctx context.Context, tx pgx.Tx, postId int, unparsed return } -func validateSubforums(lineageBuilder *models.CategoryLineageBuilder, project *models.Project, catPath string) (int, bool) { - if project.ForumID == nil { - return -1, false - } - - subforumCatId := *project.ForumID - if len(catPath) == 0 { - return subforumCatId, true - } - - catPath = strings.ToLower(catPath) - valid := false - catSlugs := strings.Split(catPath, "/") - lastSlug := catSlugs[len(catSlugs)-1] - if len(lastSlug) > 0 { - lastSlugCatId := lineageBuilder.FindIdBySlug(project.ID, lastSlug) - if lastSlugCatId != -1 { - subforumSlugs := lineageBuilder.GetSubforumLineageSlugs(lastSlugCatId) - allMatch := true - for i, subforum := range subforumSlugs { - if subforum != catSlugs[i] { - allMatch = false - break - } - } - valid = allMatch - } - if valid { - subforumCatId = lastSlugCatId - } - } - return subforumCatId, valid -} - -func canEditPost(ctx context.Context, conn *pgxpool.Pool, postId int, currentUser models.User) (bool, error) { - if currentUser.IsStaff { - return true, nil - } - - type postResult struct { - AuthorID *int `db:"author.id"` - } - iresult, err := db.QueryOne(ctx, conn, postResult{}, - ` - SELECT $columns - FROM - handmade_post AS post - LEFT JOIN auth_user AS author ON post.author_id = author.id - WHERE - post.id = $1 - AND NOT post.deleted - ORDER BY postdate - `, - postId, - ) - if err != nil { - if errors.Is(err, db.ErrNoMatchingRows) { - return false, nil - } else { - return false, oops.New(err, "failed to get author of post when checking permissions") - } - } - result := iresult.(*postResult) - - return result.AuthorID != nil && *result.AuthorID == currentUser.ID, nil -} - var errThreadEmpty = errors.New("thread contained no non-deleted posts") /* @@ -1416,3 +1060,250 @@ func fixThreadPostIds(ctx context.Context, tx pgx.Tx, threadId int) error { return nil } + +type commonForumData struct { + c *RequestContext + + CatID int + ThreadID int + PostID int + + CategoryTree models.CategoryTree + LineageBuilder *models.CategoryLineageBuilder +} + +/* +Gets data that is used on basically every forums-related route. Parses path params for category, +thread, and post ids and validates that all those resources do in fact exist. + +Returns false if any data is invalid and you should return a 404. +*/ +func getCommonForumData(c *RequestContext) (commonForumData, bool) { + c.Perf.StartBlock("FORUMS", "Fetch common forum data") + defer c.Perf.EndBlock() + + c.Perf.StartBlock("SQL", "Fetch category tree") + categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn) + lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree) + c.Perf.EndBlock() + + res := commonForumData{ + c: c, + CategoryTree: categoryTree, + LineageBuilder: lineageBuilder, + } + + if cats, hasCats := c.PathParams["cats"]; hasCats { + catId, valid := validateSubforums(lineageBuilder, c.CurrentProject, cats) + if !valid { + return commonForumData{}, false + } + res.CatID = catId + + // No need to validate cat here; it's handled by validateSubforums. + } + + if threadIdStr, hasThreadId := c.PathParams["threadid"]; hasThreadId { + threadId, err := strconv.Atoi(threadIdStr) + if err != nil { + return commonForumData{}, false + } + res.ThreadID = threadId + + c.Perf.StartBlock("SQL", "Verify that the thread exists") + threadExists, err := db.QueryBool(c.Context(), c.Conn, + ` + SELECT COUNT(*) > 0 + FROM handmade_thread + WHERE + id = $1 + AND category_id = $2 + `, + res.ThreadID, + res.CatID, + ) + c.Perf.EndBlock() + if err != nil { + panic(err) + } + if !threadExists { + return commonForumData{}, false + } + } + + if postIdStr, hasPostId := c.PathParams["postid"]; hasPostId { + postId, err := strconv.Atoi(postIdStr) + if err != nil { + return commonForumData{}, false + } + res.PostID = postId + + c.Perf.StartBlock("SQL", "Verify that the post exists") + postExists, err := db.QueryBool(c.Context(), c.Conn, + ` + SELECT COUNT(*) > 0 + FROM handmade_post + WHERE + id = $1 + AND thread_id = $2 + AND category_id = $3 + `, + res.PostID, + res.ThreadID, + res.CatID, + ) + c.Perf.EndBlock() + if err != nil { + panic(err) + } + if !postExists { + return commonForumData{}, false + } + } + + return res, true +} + +/* +Fetches the current thread according to the parsed path params. Since the ID was already validated +in the constructor, this should always succeed. + +It will not, of course, succeed if there was no thread id in the path params, so don't do that. +*/ +func (cd *commonForumData) FetchThread(ctx context.Context, connOrTx db.ConnOrTx) *models.Thread { + cd.c.Perf.StartBlock("SQL", "Fetch current thread") + type threadQueryResult struct { + Thread models.Thread `db:"thread"` + } + irow, err := db.QueryOne(ctx, connOrTx, threadQueryResult{}, + ` + SELECT $columns + FROM + handmade_thread AS thread + JOIN handmade_category AS cat ON cat.id = thread.category_id + WHERE + thread.id = $1 + AND NOT thread.deleted + AND cat.id = $2 + `, + cd.ThreadID, + cd.CatID, // NOTE(asaf): This verifies that the requested thread is under the requested subforum. + ) + cd.c.Perf.EndBlock() + if err != nil { + // We shouldn't encounter db.ErrNoMatchingRows, because validation should have verified that everything exists. + panic(oops.New(err, "failed to fetch thread")) + } + + thread := irow.(*threadQueryResult).Thread + return &thread +} + +type postAndRelatedModels struct { + Thread models.Thread `db:"thread"` + Post models.Post `db:"post"` + CurrentVersion models.PostVersion `db:"ver"` + Author *models.User `db:"author"` + Editor *models.User `db:"editor"` +} + +/* +Fetches the post, the thread, and author / editor information for the post defined by the path +params. Because all the IDs were validated in the constructor, this should always succeed. + +It will not succeed if there were missing path params though. +*/ +func (cd *commonForumData) FetchPostAndStuff(ctx context.Context, connOrTx db.ConnOrTx) *postAndRelatedModels { + cd.c.Perf.StartBlock("SQL", "Fetch post to reply to") + postQueryResult, err := db.QueryOne(ctx, connOrTx, postAndRelatedModels{}, + ` + SELECT $columns + FROM + handmade_thread AS thread + JOIN handmade_post AS post ON post.thread_id = thread.id + JOIN handmade_postversion AS ver ON post.current_id = ver.id + LEFT JOIN auth_user AS author ON post.author_id = author.id + LEFT JOIN auth_user AS editor ON ver.editor_id = editor.id + WHERE + post.category_id = $1 + AND post.thread_id = $2 + AND post.id = $3 + AND NOT post.deleted + `, + cd.CatID, + cd.ThreadID, + cd.PostID, + ) + if err != nil { + // We shouldn't encounter db.ErrNoMatchingRows, because validation should have verified that everything exists. + panic(oops.New(err, "failed to fetch post and related data")) + } + + result := postQueryResult.(*postAndRelatedModels) + return result +} + +func (cd *commonForumData) UserCanEditPost(ctx context.Context, connOrTx db.ConnOrTx, user models.User) bool { + if user.IsStaff { + return true + } + + type postResult struct { + AuthorID *int `db:"post.author_id"` + } + iresult, err := db.QueryOne(ctx, connOrTx, postResult{}, + ` + SELECT $columns + FROM + handmade_post AS post + WHERE + post.id = $1 + AND NOT post.deleted + `, + cd.PostID, + ) + if err != nil { + if errors.Is(err, db.ErrNoMatchingRows) { + return false + } else { + panic(oops.New(err, "failed to get author of post when checking permissions")) + } + } + result := iresult.(*postResult) + + return result.AuthorID != nil && *result.AuthorID == user.ID +} + +func validateSubforums(lineageBuilder *models.CategoryLineageBuilder, project *models.Project, catPath string) (int, bool) { + if project.ForumID == nil { + return -1, false + } + + subforumCatId := *project.ForumID + if len(catPath) == 0 { + return subforumCatId, true + } + + catPath = strings.ToLower(catPath) + valid := false + catSlugs := strings.Split(catPath, "/") + lastSlug := catSlugs[len(catSlugs)-1] + if len(lastSlug) > 0 { + lastSlugCatId := lineageBuilder.FindIdBySlug(project.ID, lastSlug) + if lastSlugCatId != -1 { + subforumSlugs := lineageBuilder.GetSubforumLineageSlugs(lastSlugCatId) + allMatch := true + for i, subforum := range subforumSlugs { + if subforum != catSlugs[i] { + allMatch = false + break + } + } + valid = allMatch + } + if valid { + subforumCatId = lastSlugCatId + } + } + return subforumCatId, valid +}