From 17f652191d2883c43c21df1c2edbe75104bfcee5 Mon Sep 17 00:00:00 2001 From: Ben Visness Date: Mon, 19 Jul 2021 21:35:22 -0500 Subject: [PATCH] Add forum replies --- src/templates/src/editor.html | 56 +++++- src/templates/src/forum_thread.html | 2 +- src/website/forums.go | 276 ++++++++++++++++++++-------- src/website/routes.go | 4 +- 4 files changed, 263 insertions(+), 75 deletions(-) diff --git a/src/templates/src/editor.html b/src/templates/src/editor.html index e4e071df..d8bf9d3f 100644 --- a/src/templates/src/editor.html +++ b/src/templates/src/editor.html @@ -19,11 +19,16 @@ {{ define "content" }}
+ {{ if .ThreadTitle }} +

{{ .ThreadTitle }}

+ {{ end }}
{{ csrftoken .Session }} - + {{ if not .PostReplyingTo }} + + {{ end }} {{/* TODO: Reintroduce the toolbar in a way that makes sense for Markdown */}} {{/*
@@ -68,6 +73,55 @@ {% endif %} */}} + {{ with .PostReplyingTo }} +

The post you're replying to:

+
+
+ {{ if .Author }} +
+ +
+
+
+
+ {{ .Author.Username }} {{/* TODO: Text scale stuff? Seems unnecessary. */}} + +
+ {{ if .Author.IsStaff }} +
+ {{ end }} +
+
+ {{ if and .Author.Name (ne .Author.Name .Author.Username) }} +
{{ .Author.Name }}
+ {{ end }} +
+ {{ timehtml (relativedate .PostDate) .PostDate }} + {{ if .Editor }} + + Edited by + {{ coalesce .Editor.Name .Editor.Username }} + on {{ timehtml (absolutedate .EditDate) .EditDate }} + {{ with .EditReason }} + Reason: {{ . }} + {{ end }} + + {{ end }} +
+
+ {{ else }} +
Deleted member
+
+ {{ end }} +
+
+
+ {{ .Content }} +
+
+
+ {{ end }} + {{/* {% if context_reply_to %} diff --git a/src/templates/src/forum_thread.html b/src/templates/src/forum_thread.html index c2857be2..0eb2aab2 100644 --- a/src/templates/src/forum_thread.html +++ b/src/templates/src/forum_thread.html @@ -7,7 +7,7 @@ {{ template "pagination.html" .Pagination }}
{{ range .Posts }} -
{{/* TODO: Dynamically switch between bbcode and markdown */}} +
{{ if .Author }}
diff --git a/src/website/forums.go b/src/website/forums.go index 397e93bc..124b2647 100644 --- a/src/website/forums.go +++ b/src/website/forums.go @@ -1,7 +1,9 @@ package website import ( + "context" "errors" + "fmt" "math" "net" "net/http" @@ -16,6 +18,7 @@ import ( "git.handmade.network/hmn/hmn/src/parsing" "git.handmade.network/hmn/hmn/src/templates" "git.handmade.network/hmn/hmn/src/utils" + "github.com/jackc/pgx/v4" ) type forumCategoryData struct { @@ -35,6 +38,18 @@ type forumSubcategoryData struct { TotalThreads int } +type editorData struct { + templates.BaseData + SubmitUrl string + PostTitle string + PostBody string + SubmitLabel string + IsEditing bool // false if new post, true if updating existing one + + ThreadTitle string + PostReplyingTo *templates.Post +} + func ForumCategory(c *RequestContext) ResponseData { const threadsPerPage = 25 @@ -425,6 +440,9 @@ 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) @@ -509,15 +527,6 @@ func ForumPostRedirect(c *RequestContext) ResponseData { ), http.StatusSeeOther) } -type editorData struct { - templates.BaseData - SubmitUrl string - PostTitle string - PostBody string - SubmitLabel string - IsEditing bool // false if new post, true if updating existing one -} - func ForumNewThread(c *RequestContext) ResponseData { baseData := getBaseData(c) baseData.Title = "Create New Thread" @@ -569,17 +578,6 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData { sticky = true } - parsed := parsing.ParsePostInput(unparsed, parsing.RealMarkdown) - now := time.Now() - ip := net.ParseIP(c.Req.RemoteAddr) - - const previewMaxLength = 100 - parsedPlaintext := parsing.ParsePostInput(unparsed, parsing.PlaintextMarkdown) - preview := parsedPlaintext - if len(preview) > previewMaxLength-1 { - preview = preview[:previewMaxLength-1] + "…" - } - // Create thread var threadId int err = tx.QueryRow(c.Context(), @@ -598,58 +596,7 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData { panic(oops.New(err, "failed to create thread")) } - // Create post - var postId int - err = tx.QueryRow(c.Context(), - ` - INSERT INTO handmade_post (postdate, category_id, thread_id, preview, current_id, author_id, category_kind, project_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id - `, - now, - currentCatId, - threadId, - preview, - -1, - c.CurrentUser.ID, - models.CatKindForum, - c.CurrentProject.ID, - ).Scan(&postId) - if err != nil { - panic(oops.New(err, "failed to create post")) - } - - // Create post version - var versionId int - err = tx.QueryRow(c.Context(), - ` - INSERT INTO handmade_postversion (post_id, text_raw, text_parsed, ip, date) - VALUES ($1, $2, $3, $4, $5) - RETURNING id - `, - postId, - unparsed, - parsed, - ip, - now, - ).Scan(&versionId) - if err != nil { - panic(oops.New(err, "failed to create post version")) - } - - // Update post with version id - _, err = tx.Exec(c.Context(), - ` - UPDATE handmade_post - SET current_id = $1 - WHERE id = $2 - `, - versionId, - postId, - ) - if err != nil { - panic(oops.New(err, "failed to set current post version")) - } + postId, _ := createForumPostAndVersion(c.Context(), tx, currentCatId, threadId, c.CurrentUser.ID, c.CurrentProject.ID, unparsed, c.Req.Host) // Update thread with post id _, err = tx.Exec(c.Context(), @@ -676,6 +623,191 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData { 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 { + 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 ids for thread") + // 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) + + baseData := getBaseData(c) + baseData.Title = fmt.Sprintf("Replying to \"%s\" | %s", result.Thread.Title, *categoryTree[currentCatId].Name) + baseData.MathjaxEnabled = true + // TODO(ben): Set breadcrumbs + + templatePost := templates.PostToTemplate(&result.Post, result.Author, c.Theme) + templatePost.AddContentVersion(result.CurrentVersion, result.Editor) + + var res ResponseData + res.MustWriteTemplate("editor.html", editorData{ + BaseData: baseData, + SubmitUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), requestedThreadId, requestedPostId), + SubmitLabel: "Submit Reply", + + ThreadTitle: result.Thread.Title, + PostReplyingTo: &templatePost, + }, c.Perf) + return res +} + +func ForumPostReplySubmit(c *RequestContext) ResponseData { + 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) + } + + c.Req.ParseForm() + + unparsed := c.Req.Form.Get("body") + + postId, _ := createForumPostAndVersion(c.Context(), tx, currentCatId, threadId, c.CurrentUser.ID, c.CurrentProject.ID, unparsed, c.Req.Host) + + err = tx.Commit(c.Context()) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create new forum thread")) + } + + newPostUrl := hmnurl.BuildForumPost(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), threadId, postId) + return c.Redirect(newPostUrl, http.StatusSeeOther) +} + +func createForumPostAndVersion(ctx context.Context, tx pgx.Tx, catId, threadId, userId, projectId int, unparsedContent string, ipString string) (postId, versionId int) { + parsed := parsing.ParsePostInput(unparsedContent, parsing.RealMarkdown) + now := time.Now() + ip := net.ParseIP(ipString) + + const previewMaxLength = 100 + parsedPlaintext := parsing.ParsePostInput(unparsedContent, parsing.PlaintextMarkdown) + preview := parsedPlaintext + if len(preview) > previewMaxLength-1 { + preview = preview[:previewMaxLength-1] + "…" + } + + // Create post + err := tx.QueryRow(ctx, + ` + INSERT INTO handmade_post (postdate, category_id, thread_id, preview, current_id, author_id, category_kind, project_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id + `, + now, + catId, + threadId, + preview, + -1, + userId, + models.CatKindForum, + projectId, + ).Scan(&postId) + if err != nil { + panic(oops.New(err, "failed to create post")) + } + + // Create post version + err = tx.QueryRow(ctx, + ` + INSERT INTO handmade_postversion (post_id, text_raw, text_parsed, ip, date) + VALUES ($1, $2, $3, $4, $5) + RETURNING id + `, + postId, + unparsedContent, + parsed, + ip, + now, + ).Scan(&versionId) + if err != nil { + panic(oops.New(err, "failed to create post version")) + } + + // Update post with version id + _, err = tx.Exec(ctx, + ` + UPDATE handmade_post + SET current_id = $1 + WHERE id = $2 + `, + versionId, + postId, + ) + if err != nil { + panic(oops.New(err, "failed to set current post version")) + } + + return +} + func validateSubforums(lineageBuilder *models.CategoryLineageBuilder, project *models.Project, catPath string) (int, bool) { if project.ForumID == nil { return -1, false diff --git a/src/website/routes.go b/src/website/routes.go index 983dab85..3fb581d2 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -153,11 +153,13 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt mainRoutes.GET(hmnurl.RegexProjectNotApproved, ProjectHomepage) // NOTE(asaf): Any-project routes: - mainRoutes.Handle([]string{http.MethodGet, http.MethodPost}, hmnurl.RegexForumNewThread, authMiddleware(ForumNewThread)) + mainRoutes.GET(hmnurl.RegexForumNewThread, authMiddleware(ForumNewThread)) mainRoutes.POST(hmnurl.RegexForumNewThreadSubmit, authMiddleware(csrfMiddleware(ForumNewThreadSubmit))) mainRoutes.GET(hmnurl.RegexForumThread, ForumThread) mainRoutes.GET(hmnurl.RegexForumCategory, ForumCategory) mainRoutes.GET(hmnurl.RegexForumPost, ForumPostRedirect) + mainRoutes.GET(hmnurl.RegexForumPostReply, authMiddleware(ForumPostReply)) + mainRoutes.POST(hmnurl.RegexForumPostReply, authMiddleware(ForumPostReplySubmit)) mainRoutes.GET(hmnurl.RegexProjectCSS, ProjectCSS)