hmn/src/website/forums.go

1002 lines
29 KiB
Go

package website
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/hmndata"
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/logging"
"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/utils"
)
type forumData struct {
templates.BaseData
NewThreadUrl string
MarkReadUrl string
Threads []templates.ThreadListItem
Pagination templates.Pagination
Subforums []forumSubforumData
}
type forumSubforumData struct {
Name string
Url string
Threads []templates.ThreadListItem
TotalThreads int
}
type editorData struct {
templates.BaseData
SubmitUrl string
SubmitLabel string
// The following are filled out automatically by the
// getEditorDataFor* functions.
PostTitle string
CanEditPostTitle bool
IsEditing bool
EditInitialContents string
PostReplyingTo *templates.Post
ShowEduOptions bool
PreviewClass string
TextEditor templates.TextEditor
}
func getEditorDataForNew(urlContext *hmnurl.UrlContext, currentUser *models.User, baseData templates.BaseData, replyPost *templates.Post) editorData {
result := editorData{
BaseData: baseData,
CanEditPostTitle: replyPost == nil,
PostReplyingTo: replyPost,
TextEditor: templates.TextEditor{
MaxFileSize: AssetMaxSize(currentUser),
UploadUrl: urlContext.BuildAssetUpload(),
},
}
if replyPost != nil {
result.PostTitle = "Replying to post"
}
return result
}
func getEditorDataForEdit(urlContext *hmnurl.UrlContext, currentUser *models.User, baseData templates.BaseData, p hmndata.PostAndStuff) editorData {
return editorData{
BaseData: baseData,
PostTitle: p.Thread.Title,
CanEditPostTitle: p.Thread.FirstID == p.Post.ID,
IsEditing: true,
EditInitialContents: p.CurrentVersion.TextRaw,
TextEditor: templates.TextEditor{
MaxFileSize: AssetMaxSize(currentUser),
UploadUrl: urlContext.BuildAssetUpload(),
},
}
}
func Forum(c *RequestContext) ResponseData {
const threadsPerPage = 25
cd, ok := getCommonForumData(c)
if !ok {
return FourOhFour(c)
}
currentSubforumSlugs := cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID)
numThreads, err := hmndata.CountThreads(c, c.Conn, c.CurrentUser, hmndata.ThreadsQuery{
ProjectIDs: []int{c.CurrentProject.ID},
ThreadTypes: []models.ThreadType{models.ThreadTypeForumPost},
SubforumIDs: []int{cd.SubforumID},
})
if err != nil {
panic(oops.New(err, "failed to get count of threads"))
}
numPages := utils.NumPages(numThreads, threadsPerPage)
page, ok := ParsePageNumber(c, "page", numPages)
if !ok {
return c.Redirect(c.UrlContext.BuildForum(currentSubforumSlugs, page), http.StatusSeeOther)
}
howManyThreadsToSkip := (page - 1) * threadsPerPage
mainThreads, err := hmndata.FetchThreads(c, c.Conn, c.CurrentUser, hmndata.ThreadsQuery{
ProjectIDs: []int{c.CurrentProject.ID},
ThreadTypes: []models.ThreadType{models.ThreadTypeForumPost},
SubforumIDs: []int{cd.SubforumID},
Limit: threadsPerPage,
Offset: howManyThreadsToSkip,
})
makeThreadListItem := func(row hmndata.ThreadAndStuff) templates.ThreadListItem {
return templates.ThreadListItem{
Title: row.Thread.Title,
Url: c.UrlContext.BuildForumThread(cd.LineageBuilder.GetSubforumLineageSlugs(*row.Thread.SubforumID), row.Thread.ID, row.Thread.Title, 1),
FirstUser: templates.UserToTemplate(row.FirstPostAuthor, c.Theme),
FirstDate: row.FirstPost.PostDate,
LastUser: templates.UserToTemplate(row.LastPostAuthor, c.Theme),
LastDate: row.LastPost.PostDate,
Unread: row.Unread,
}
}
var threads []templates.ThreadListItem
for _, row := range mainThreads {
threads = append(threads, makeThreadListItem(row))
}
// ---------------------
// Subforum things
// ---------------------
var subforums []forumSubforumData
if page == 1 {
subforumNodes := cd.SubforumTree[cd.SubforumID].Children
for _, sfNode := range subforumNodes {
numThreads, err := hmndata.CountThreads(c, c.Conn, c.CurrentUser, hmndata.ThreadsQuery{
ProjectIDs: []int{c.CurrentProject.ID},
ThreadTypes: []models.ThreadType{models.ThreadTypeForumPost},
SubforumIDs: []int{sfNode.ID},
})
if err != nil {
panic(oops.New(err, "failed to get count of threads"))
}
subforumThreads, err := hmndata.FetchThreads(c, c.Conn, c.CurrentUser, hmndata.ThreadsQuery{
ProjectIDs: []int{c.CurrentProject.ID},
ThreadTypes: []models.ThreadType{models.ThreadTypeForumPost},
SubforumIDs: []int{sfNode.ID},
Limit: 3,
})
var threads []templates.ThreadListItem
for _, row := range subforumThreads {
threads = append(threads, makeThreadListItem(row))
}
subforums = append(subforums, forumSubforumData{
Name: sfNode.Name,
Url: c.UrlContext.BuildForum(cd.LineageBuilder.GetSubforumLineageSlugs(sfNode.ID), 1),
Threads: threads,
TotalThreads: numThreads,
})
}
}
// ---------------------
// Template assembly
// ---------------------
baseData := getBaseData(
c,
fmt.Sprintf("%s Forums", c.CurrentProject.Name),
SubforumBreadcrumbs(c.UrlContext, cd.LineageBuilder, cd.SubforumID),
)
var res ResponseData
res.MustWriteTemplate("forum.html", forumData{
BaseData: baseData,
NewThreadUrl: c.UrlContext.BuildForumNewThread(currentSubforumSlugs, false),
MarkReadUrl: c.UrlContext.BuildForumMarkRead(cd.SubforumID),
Threads: threads,
Pagination: templates.Pagination{
Current: page,
Total: numPages,
FirstUrl: c.UrlContext.BuildForum(currentSubforumSlugs, 1),
LastUrl: c.UrlContext.BuildForum(currentSubforumSlugs, numPages),
NextUrl: c.UrlContext.BuildForum(currentSubforumSlugs, utils.IntClamp(1, page+1, numPages)),
PreviousUrl: c.UrlContext.BuildForum(currentSubforumSlugs, utils.IntClamp(1, page-1, numPages)),
},
Subforums: subforums,
}, c.Perf)
return res
}
func ForumMarkRead(c *RequestContext) ResponseData {
c.Perf.StartBlock("SQL", "Fetch subforum tree")
subforumTree := models.GetFullSubforumTree(c, c.Conn)
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
c.Perf.EndBlock()
sfId, err := strconv.Atoi(c.PathParams["sfid"])
if err != nil {
return FourOhFour(c)
}
tx, err := c.Conn.Begin(c)
if err != nil {
panic(err)
}
defer tx.Rollback(c)
sfIds := []int{sfId}
if sfId == 0 {
// Mark literally everything as read
_, err := tx.Exec(c,
`
UPDATE hmn_user
SET marked_all_read_at = NOW()
WHERE id = $1
`,
c.CurrentUser.ID,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to mark all posts as read"))
}
// Delete thread unread info
_, err = tx.Exec(c,
`
DELETE FROM thread_last_read_info
WHERE user_id = $1;
`,
c.CurrentUser.ID,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete thread unread info"))
}
// Delete subforum unread info
_, err = tx.Exec(c,
`
DELETE FROM subforum_last_read_info
WHERE user_id = $1;
`,
c.CurrentUser.ID,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete subforum unread info"))
}
} else {
c.Perf.StartBlock("SQL", "Update SLRIs")
_, err = tx.Exec(c,
`
INSERT INTO subforum_last_read_info (subforum_id, user_id, lastread)
SELECT id, $2, $3
FROM subforum
WHERE id = ANY ($1)
ON CONFLICT (subforum_id, user_id) DO UPDATE
SET lastread = EXCLUDED.lastread
`,
sfIds,
c.CurrentUser.ID,
time.Now(),
)
c.Perf.EndBlock()
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update forum slris"))
}
c.Perf.StartBlock("SQL", "Delete TLRIs")
_, err = tx.Exec(c,
`
DELETE FROM thread_last_read_info
WHERE
user_id = $2
AND thread_id IN (
SELECT id
FROM thread
WHERE
subforum_id = ANY ($1)
)
`,
sfIds,
c.CurrentUser.ID,
)
c.Perf.EndBlock()
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete unnecessary tlris"))
}
}
err = tx.Commit(c)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to commit SLRI/TLRI updates"))
}
var redirUrl string
if sfId == 0 {
redirUrl = hmnurl.BuildFeed()
} else {
redirUrl = c.UrlContext.BuildForum(lineageBuilder.GetSubforumLineageSlugs(sfId), 1)
}
return c.Redirect(redirUrl, http.StatusSeeOther)
}
type forumThreadData struct {
templates.BaseData
Thread templates.Thread
Posts []templates.Post
SubforumUrl string
ReplyUrl string
Pagination templates.Pagination
}
// How many posts to display on a single page of a forum thread.
var threadPostsPerPage = 15
func ForumThread(c *RequestContext) ResponseData {
cd, ok := getCommonForumData(c)
if !ok {
return FourOhFour(c)
}
threads, err := hmndata.FetchThreads(c, c.Conn, c.CurrentUser, hmndata.ThreadsQuery{
ProjectIDs: []int{c.CurrentProject.ID},
ThreadIDs: []int{cd.ThreadID},
})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get thread"))
}
if len(threads) == 0 {
return FourOhFour(c)
}
threadResult := threads[0]
thread := threadResult.Thread
currentSubforumSlugs := cd.LineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID)
if *thread.SubforumID != cd.SubforumID {
correctThreadUrl := c.UrlContext.BuildForumThread(currentSubforumSlugs, thread.ID, thread.Title, 1)
return c.Redirect(correctThreadUrl, http.StatusSeeOther)
}
numPosts, err := hmndata.CountPosts(c, c.Conn, c.CurrentUser, hmndata.PostsQuery{
ProjectIDs: []int{c.CurrentProject.ID},
ThreadTypes: []models.ThreadType{models.ThreadTypeForumPost},
ThreadIDs: []int{cd.ThreadID},
})
if err != nil {
panic(oops.New(err, "failed to get count of posts for thread"))
}
page, numPages, ok := getPageInfo(c.PathParams["page"], numPosts, threadPostsPerPage)
if !ok {
urlNoPage := c.UrlContext.BuildForumThread(currentSubforumSlugs, thread.ID, thread.Title, 1)
return c.Redirect(urlNoPage, http.StatusSeeOther)
}
pagination := templates.Pagination{
Current: page,
Total: numPages,
FirstUrl: c.UrlContext.BuildForumThread(currentSubforumSlugs, thread.ID, thread.Title, 1),
LastUrl: c.UrlContext.BuildForumThread(currentSubforumSlugs, thread.ID, thread.Title, numPages),
NextUrl: c.UrlContext.BuildForumThread(currentSubforumSlugs, thread.ID, thread.Title, utils.IntClamp(1, page+1, numPages)),
PreviousUrl: c.UrlContext.BuildForumThread(currentSubforumSlugs, thread.ID, thread.Title, utils.IntClamp(1, page-1, numPages)),
}
postsAndStuff, err := hmndata.FetchPosts(c, c.Conn, c.CurrentUser, hmndata.PostsQuery{
ProjectIDs: []int{c.CurrentProject.ID},
ThreadIDs: []int{thread.ID},
Limit: threadPostsPerPage,
Offset: (page - 1) * threadPostsPerPage,
})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch thread posts"))
}
var posts []templates.Post
for _, p := range postsAndStuff {
post := templates.PostToTemplate(&p.Post, p.Author, c.Theme)
post.AddContentVersion(p.CurrentVersion, p.Editor)
addForumUrlsToPost(c.UrlContext, &post, currentSubforumSlugs, thread.ID, post.ID)
if p.ReplyPost != nil {
reply := templates.PostToTemplate(p.ReplyPost, p.ReplyAuthor, c.Theme)
addForumUrlsToPost(c.UrlContext, &reply, currentSubforumSlugs, thread.ID, reply.ID)
post.ReplyPost = &reply
}
addAuthorCountsToPost(c, c.Conn, &post)
posts = append(posts, post)
}
// Update thread last read info
if c.CurrentUser != nil {
c.Perf.StartBlock("SQL", "Update TLRI")
_, err = c.Conn.Exec(c,
`
INSERT INTO thread_last_read_info (thread_id, user_id, lastread)
VALUES ($1, $2, $3)
ON CONFLICT (thread_id, user_id) DO UPDATE
SET lastread = EXCLUDED.lastread
`,
cd.ThreadID,
c.CurrentUser.ID,
time.Now(),
)
c.Perf.EndBlock()
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update forum tlri"))
}
}
baseData := getBaseData(c, thread.Title, SubforumBreadcrumbs(c.UrlContext, cd.LineageBuilder, cd.SubforumID))
baseData.OpenGraphItems = append(baseData.OpenGraphItems, templates.OpenGraphItem{
Property: "og:description",
Value: threadResult.FirstPost.Preview,
})
var res ResponseData
res.MustWriteTemplate("forum_thread.html", forumThreadData{
BaseData: baseData,
Thread: templates.ThreadToTemplate(&thread),
Posts: posts,
SubforumUrl: c.UrlContext.BuildForum(currentSubforumSlugs, 1),
ReplyUrl: c.UrlContext.BuildForumPostReply(currentSubforumSlugs, thread.ID, thread.FirstID),
Pagination: pagination,
}, c.Perf)
return res
}
func ForumPostRedirect(c *RequestContext) ResponseData {
cd, ok := getCommonForumData(c)
if !ok {
return FourOhFour(c)
}
posts, err := hmndata.FetchPosts(c, c.Conn, c.CurrentUser, hmndata.PostsQuery{
ProjectIDs: []int{c.CurrentProject.ID},
ThreadTypes: []models.ThreadType{models.ThreadTypeForumPost},
ThreadIDs: []int{cd.ThreadID},
})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch posts for redirect"))
}
var post hmndata.PostAndStuff
postIdx := -1
for i, p := range posts {
if p.Post.ID == cd.PostID {
post = p
postIdx = i
break
}
}
if postIdx == -1 {
return FourOhFour(c)
}
page := (postIdx / threadPostsPerPage) + 1
return c.Redirect(c.UrlContext.BuildForumThreadWithPostHash(
cd.LineageBuilder.GetSubforumLineageSlugs(*post.Thread.SubforumID),
post.Thread.ID,
post.Thread.Title,
page,
post.Post.ID,
), http.StatusSeeOther)
}
func ForumNewThread(c *RequestContext) ResponseData {
cd, ok := getCommonForumData(c)
if !ok {
return FourOhFour(c)
}
baseData := getBaseData(c, "Create New Thread", SubforumBreadcrumbs(c.UrlContext, cd.LineageBuilder, cd.SubforumID))
editData := getEditorDataForNew(c.UrlContext, c.CurrentUser, baseData, nil)
editData.SubmitUrl = c.UrlContext.BuildForumNewThread(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), true)
editData.SubmitLabel = "Post New Thread"
var res ResponseData
res.MustWriteTemplate("editor.html", editData, c.Perf)
return res
}
func ForumNewThreadSubmit(c *RequestContext) ResponseData {
tx, err := c.Conn.Begin(c)
if err != nil {
panic(err)
}
defer tx.Rollback(c)
cd, ok := getCommonForumData(c)
if !ok {
return FourOhFour(c)
}
err = c.Req.ParseForm()
if err != nil {
return c.ErrorResponse(http.StatusBadRequest, oops.New(err, "the form data was invalid"))
}
title := c.Req.Form.Get("title")
unparsed := c.Req.Form.Get("body")
sticky := false
if c.CurrentUser.IsStaff && c.Req.Form.Get("sticky") != "" {
sticky = true
}
if title == "" {
return c.RejectRequest("You must provide a title for your post.")
}
if unparsed == "" {
return c.RejectRequest("You must provide a body for your post.")
}
// Create thread
var threadId int
err = tx.QueryRow(c,
`
INSERT INTO thread (title, sticky, type, project_id, subforum_id, first_id, last_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id
`,
title,
sticky,
models.ThreadTypeForumPost,
c.CurrentProject.ID,
cd.SubforumID,
-1,
-1,
).Scan(&threadId)
if err != nil {
panic(oops.New(err, "failed to create thread"))
}
// Create everything else
hmndata.CreateNewPost(c, tx, c.CurrentProject.ID, threadId, models.ThreadTypeForumPost, c.CurrentUser.ID, nil, unparsed, c.Req.Host)
err = tx.Commit(c)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create new forum thread"))
}
newThreadUrl := c.UrlContext.BuildForumThread(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), threadId, title, 1)
return c.Redirect(newThreadUrl, http.StatusSeeOther)
}
func ForumPostReply(c *RequestContext) ResponseData {
cd, ok := getCommonForumData(c)
if !ok {
return FourOhFour(c)
}
post, err := hmndata.FetchThreadPost(c, c.Conn, c.CurrentUser, cd.ThreadID, cd.PostID, hmndata.PostsQuery{
ProjectIDs: []int{c.CurrentProject.ID},
ThreadTypes: []models.ThreadType{models.ThreadTypeForumPost},
})
if errors.Is(err, db.NotFound) {
return FourOhFour(c)
} else if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch post for reply"))
}
if *post.Thread.SubforumID != cd.SubforumID {
correctUrl := c.UrlContext.BuildForumPostReply(cd.LineageBuilder.GetSubforumLineageSlugs(*post.Thread.SubforumID), post.Thread.ID, post.Post.ID)
return c.Redirect(correctUrl, http.StatusSeeOther)
}
baseData := getBaseData(
c,
fmt.Sprintf("Replying to post | %s", cd.SubforumTree[*post.Thread.SubforumID].Name),
ForumThreadBreadcrumbs(c.UrlContext, cd.LineageBuilder, &post.Thread),
)
replyPost := templates.PostToTemplate(&post.Post, post.Author, c.Theme)
replyPost.AddContentVersion(post.CurrentVersion, post.Editor)
editData := getEditorDataForNew(c.UrlContext, c.CurrentUser, baseData, &replyPost)
editData.SubmitUrl = c.UrlContext.BuildForumPostReply(cd.LineageBuilder.GetSubforumLineageSlugs(*post.Thread.SubforumID), post.Thread.ID, post.Post.ID)
editData.SubmitLabel = "Submit Reply"
var res ResponseData
res.MustWriteTemplate("editor.html", editData, c.Perf)
return res
}
func ForumPostReplySubmit(c *RequestContext) ResponseData {
cd, ok := getCommonForumData(c)
if !ok {
return FourOhFour(c)
}
tx, err := c.Conn.Begin(c)
if err != nil {
panic(err)
}
defer tx.Rollback(c)
err = c.Req.ParseForm()
if err != nil {
return c.ErrorResponse(http.StatusBadRequest, oops.New(nil, "the form data was invalid"))
}
unparsed := c.Req.Form.Get("body")
if unparsed == "" {
return c.RejectRequest("Your reply cannot be empty.")
}
post, err := hmndata.FetchThreadPost(c, c.Conn, c.CurrentUser, cd.ThreadID, cd.PostID, hmndata.PostsQuery{
ProjectIDs: []int{c.CurrentProject.ID},
ThreadTypes: []models.ThreadType{models.ThreadTypeForumPost},
})
if errors.Is(err, db.NotFound) {
return FourOhFour(c)
}
// Replies to the OP should not be considered replies
var replyPostId *int
if post.Post.ID != post.Thread.FirstID {
replyPostId = &post.Post.ID
}
newPostId, _ := hmndata.CreateNewPost(c, tx, c.CurrentProject.ID, post.Thread.ID, models.ThreadTypeForumPost, c.CurrentUser.ID, replyPostId, unparsed, c.Req.Host)
err = tx.Commit(c)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to reply to forum post"))
}
newPostUrl := c.UrlContext.BuildForumPost(cd.LineageBuilder.GetSubforumLineageSlugs(*post.Thread.SubforumID), post.Thread.ID, newPostId)
return c.Redirect(newPostUrl, http.StatusSeeOther)
}
func ForumPostEdit(c *RequestContext) ResponseData {
cd, ok := getCommonForumData(c)
if !ok {
return FourOhFour(c)
}
if !hmndata.UserCanEditPost(c, c.Conn, *c.CurrentUser, cd.PostID) {
return FourOhFour(c)
}
post, err := hmndata.FetchThreadPost(c, c.Conn, c.CurrentUser, cd.ThreadID, cd.PostID, hmndata.PostsQuery{
ProjectIDs: []int{c.CurrentProject.ID},
ThreadTypes: []models.ThreadType{models.ThreadTypeForumPost},
})
if errors.Is(err, db.NotFound) {
return FourOhFour(c)
} else if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch post for editing"))
}
if *post.Thread.SubforumID != cd.SubforumID {
correctUrl := c.UrlContext.BuildForumPostEdit(cd.LineageBuilder.GetSubforumLineageSlugs(*post.Thread.SubforumID), post.Thread.ID, post.Post.ID)
return c.Redirect(correctUrl, http.StatusSeeOther)
}
title := ""
if post.Thread.FirstID == post.Post.ID {
title = fmt.Sprintf("Editing \"%s\" | %s", post.Thread.Title, cd.SubforumTree[*post.Thread.SubforumID].Name)
} else {
title = fmt.Sprintf("Editing Post | %s", cd.SubforumTree[*post.Thread.SubforumID].Name)
}
baseData := getBaseData(c, title, ForumThreadBreadcrumbs(c.UrlContext, cd.LineageBuilder, &post.Thread))
editData := getEditorDataForEdit(c.UrlContext, c.CurrentUser, baseData, post)
editData.SubmitUrl = c.UrlContext.BuildForumPostEdit(cd.LineageBuilder.GetSubforumLineageSlugs(*post.Thread.SubforumID), post.Thread.ID, post.Post.ID)
editData.SubmitLabel = "Submit Edited Post"
var res ResponseData
res.MustWriteTemplate("editor.html", editData, c.Perf)
return res
}
func ForumPostEditSubmit(c *RequestContext) ResponseData {
cd, ok := getCommonForumData(c)
if !ok {
return FourOhFour(c)
}
if !hmndata.UserCanEditPost(c, c.Conn, *c.CurrentUser, cd.PostID) {
return FourOhFour(c)
}
tx, err := c.Conn.Begin(c)
if err != nil {
panic(err)
}
defer tx.Rollback(c)
post, err := hmndata.FetchThreadPost(c, tx, c.CurrentUser, cd.ThreadID, cd.PostID, hmndata.PostsQuery{
ProjectIDs: []int{c.CurrentProject.ID},
ThreadTypes: []models.ThreadType{models.ThreadTypeForumPost},
})
if errors.Is(err, db.NotFound) {
return FourOhFour(c)
} else if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get forum post to submit edits"))
}
c.Req.ParseForm()
title := c.Req.Form.Get("title")
unparsed := c.Req.Form.Get("body")
editReason := c.Req.Form.Get("editreason")
if title != "" && post.Thread.FirstID != post.Post.ID {
return c.RejectRequest("You can only edit the title by editing the first post.")
}
if unparsed == "" {
return c.RejectRequest("You must provide a body for your post.")
}
hmndata.CreatePostVersion(c, tx, post.Post.ID, unparsed, c.Req.Host, editReason, &c.CurrentUser.ID)
if title != "" {
_, err := tx.Exec(c,
`
UPDATE thread SET title = $1 WHERE id = $2
`,
title,
post.Thread.ID,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update thread title"))
}
}
err = tx.Commit(c)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to edit forum post"))
}
postUrl := c.UrlContext.BuildForumPost(cd.LineageBuilder.GetSubforumLineageSlugs(*post.Thread.SubforumID), post.Thread.ID, post.Post.ID)
return c.Redirect(postUrl, http.StatusSeeOther)
}
func ForumPostDelete(c *RequestContext) ResponseData {
cd, ok := getCommonForumData(c)
if !ok {
return FourOhFour(c)
}
if !hmndata.UserCanEditPost(c, c.Conn, *c.CurrentUser, cd.PostID) {
return FourOhFour(c)
}
post, err := hmndata.FetchThreadPost(c, c.Conn, c.CurrentUser, cd.ThreadID, cd.PostID, hmndata.PostsQuery{
ProjectIDs: []int{c.CurrentProject.ID},
ThreadTypes: []models.ThreadType{models.ThreadTypeForumPost},
})
if errors.Is(err, db.NotFound) {
return FourOhFour(c)
} else if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch post for delete"))
}
if *post.Thread.SubforumID != cd.SubforumID {
correctUrl := c.UrlContext.BuildForumPostDelete(cd.LineageBuilder.GetSubforumLineageSlugs(*post.Thread.SubforumID), post.Thread.ID, post.Post.ID)
return c.Redirect(correctUrl, http.StatusSeeOther)
}
baseData := getBaseData(
c,
fmt.Sprintf("Deleting post in \"%s\" | %s", post.Thread.Title, cd.SubforumTree[*post.Thread.SubforumID].Name),
ForumThreadBreadcrumbs(c.UrlContext, cd.LineageBuilder, &post.Thread),
)
templatePost := templates.PostToTemplate(&post.Post, post.Author, c.Theme)
templatePost.AddContentVersion(post.CurrentVersion, post.Editor)
type forumPostDeleteData struct {
templates.BaseData
Post templates.Post
SubmitUrl string
}
var res ResponseData
res.MustWriteTemplate("forum_post_delete.html", forumPostDeleteData{
BaseData: baseData,
SubmitUrl: c.UrlContext.BuildForumPostDelete(cd.LineageBuilder.GetSubforumLineageSlugs(*post.Thread.SubforumID), post.Thread.ID, post.Post.ID),
Post: templatePost,
}, c.Perf)
return res
}
func ForumPostDeleteSubmit(c *RequestContext) ResponseData {
cd, ok := getCommonForumData(c)
if !ok {
return FourOhFour(c)
}
if !hmndata.UserCanEditPost(c, c.Conn, *c.CurrentUser, cd.PostID) {
return FourOhFour(c)
}
tx, err := c.Conn.Begin(c)
if err != nil {
panic(err)
}
defer tx.Rollback(c)
threadDeleted := hmndata.DeletePost(c, tx, cd.ThreadID, cd.PostID)
err = tx.Commit(c)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete post"))
}
if threadDeleted {
forumUrl := c.UrlContext.BuildForum(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), 1)
return c.Redirect(forumUrl, http.StatusSeeOther)
} else {
threadUrl := c.UrlContext.BuildForumThread(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), 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)
}
}
func WikiArticleRedirect(c *RequestContext) ResponseData {
threadIdStr := c.PathParams["threadid"]
threadId, err := strconv.Atoi(threadIdStr)
if err != nil {
panic(err)
}
thread, err := hmndata.FetchThread(c, c.Conn, c.CurrentUser, threadId, hmndata.ThreadsQuery{
ProjectIDs: []int{c.CurrentProject.ID},
// This is the rare query where we want all thread types!
})
if errors.Is(err, db.NotFound) {
return FourOhFour(c)
} else if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to look up wiki thread"))
}
c.Perf.StartBlock("SQL", "Fetch subforum tree")
subforumTree := models.GetFullSubforumTree(c, c.Conn)
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
c.Perf.EndBlock()
dest := UrlForGenericThread(c.UrlContext, &thread.Thread, lineageBuilder)
return c.Redirect(dest, http.StatusFound)
}
type commonForumData struct {
c *RequestContext
SubforumID int
ThreadID int
PostID int
SubforumTree models.SubforumTree
LineageBuilder *models.SubforumLineageBuilder
}
/*
Gets data that is used on basically every forums-related route. Parses path params for subforum,
thread, and post ids.
Does NOT validate that the requested thread and post ID are valid.
If this returns false, then something was malformed and you should 404.
*/
func getCommonForumData(c *RequestContext) (commonForumData, bool) {
c.Perf.StartBlock("FORUMS", "Fetch common forum data")
defer c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Fetch subforum tree")
subforumTree := models.GetFullSubforumTree(c, c.Conn)
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
c.Perf.EndBlock()
res := commonForumData{
c: c,
SubforumTree: subforumTree,
LineageBuilder: lineageBuilder,
}
if subforums, hasSubforums := c.PathParams["subforums"]; hasSubforums {
sfId, valid := validateSubforums(lineageBuilder, c.CurrentProject, subforums)
if !valid {
return commonForumData{}, false
}
res.SubforumID = sfId
}
if threadIdStr, hasThreadId := c.PathParams["threadid"]; hasThreadId {
threadId, err := strconv.Atoi(threadIdStr)
if err != nil {
return commonForumData{}, false
}
res.ThreadID = threadId
}
if postIdStr, hasPostId := c.PathParams["postid"]; hasPostId {
postId, err := strconv.Atoi(postIdStr)
if err != nil {
return commonForumData{}, false
}
res.PostID = postId
}
return res, true
}
func validateSubforums(lineageBuilder *models.SubforumLineageBuilder, project *models.Project, sfPath string) (int, bool) {
if project.ForumID == nil {
return -1, false
}
subforumId := *project.ForumID
if len(sfPath) == 0 {
return subforumId, true
}
sfPath = strings.ToLower(sfPath)
valid := false
sfSlugs := strings.Split(sfPath, "/")
lastSlug := sfSlugs[len(sfSlugs)-1]
if len(lastSlug) > 0 {
lastSlugSfId := lineageBuilder.FindIdBySlug(project.ID, lastSlug)
if lastSlugSfId != -1 {
subforumSlugs := lineageBuilder.GetSubforumLineageSlugs(lastSlugSfId)
allMatch := true
for i, subforum := range subforumSlugs {
if subforum != sfSlugs[i] {
allMatch = false
break
}
}
valid = allMatch
}
if valid {
subforumId = lastSlugSfId
}
}
return subforumId, valid
}
func addForumUrlsToPost(urlContext *hmnurl.UrlContext, p *templates.Post, subforums []string, threadId int, postId int) {
p.Url = urlContext.BuildForumPost(subforums, threadId, postId)
p.DeleteUrl = urlContext.BuildForumPostDelete(subforums, threadId, postId)
p.EditUrl = urlContext.BuildForumPostEdit(subforums, threadId, postId)
p.ReplyUrl = urlContext.BuildForumPostReply(subforums, threadId, postId)
}
// Takes a template post and adds information about how many posts the user has made
// on the site.
func addAuthorCountsToPost(ctx context.Context, conn db.ConnOrTx, p *templates.Post) {
numPosts, err := db.QueryOneScalar[int](ctx, conn,
`
SELECT COUNT(*)
FROM
post
JOIN project ON post.project_id = project.id
WHERE
post.author_id = $1
AND NOT post.deleted
AND project.lifecycle = ANY ($2)
`,
p.Author.ID,
models.VisibleProjectLifecycles,
)
if err != nil {
logging.ExtractLogger(ctx).Warn().Err(err).Msg("failed to get count of user posts")
} else {
p.AuthorNumPosts = numPosts
}
numProjects, err := db.QueryOneScalar[int](ctx, conn,
`
SELECT COUNT(*)
FROM
project
JOIN user_project AS uproj ON uproj.project_id = project.id
WHERE
project.lifecycle = ANY ($1)
AND uproj.user_id = $2
`,
models.VisibleProjectLifecycles,
p.Author.ID,
)
if err != nil {
logging.ExtractLogger(ctx).Warn().Err(err).Msg("failed to get count of user projects")
} else {
p.AuthorNumProjects = numProjects
}
}