diff --git a/src/db/db.go b/src/db/db.go index accfb961..56154223 100644 --- a/src/db/db.go +++ b/src/db/db.go @@ -54,6 +54,11 @@ func typeIsQueryable(t reflect.Type) bool { return false } +// This interface should match both a direct pgx connection or a pgx transaction. +type ConnOrTx interface { + Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) +} + var connInfo = pgtype.NewConnInfo() func NewConn() *pgx.Conn { @@ -211,7 +216,7 @@ func followPathThroughStructs(structPtrVal reflect.Value, path []int) (reflect.V return val, field } -func Query(ctx context.Context, conn *pgxpool.Pool, destExample interface{}, query string, args ...interface{}) (*StructQueryIterator, error) { +func Query(ctx context.Context, conn ConnOrTx, destExample interface{}, query string, args ...interface{}) (*StructQueryIterator, error) { destType := reflect.TypeOf(destExample) columnNames, fieldPaths, err := getColumnNamesAndPaths(destType, nil, "") if err != nil { @@ -279,7 +284,7 @@ func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix strin var ErrNoMatchingRows = errors.New("no matching rows") -func QueryOne(ctx context.Context, conn *pgxpool.Pool, destExample interface{}, query string, args ...interface{}) (interface{}, error) { +func QueryOne(ctx context.Context, conn ConnOrTx, destExample interface{}, query string, args ...interface{}) (interface{}, error) { rows, err := Query(ctx, conn, destExample, query, args...) if err != nil { return nil, err @@ -294,7 +299,7 @@ func QueryOne(ctx context.Context, conn *pgxpool.Pool, destExample interface{}, return result, nil } -func QueryScalar(ctx context.Context, conn *pgxpool.Pool, query string, args ...interface{}) (interface{}, error) { +func QueryScalar(ctx context.Context, conn ConnOrTx, query string, args ...interface{}) (interface{}, error) { rows, err := conn.Query(ctx, query, args...) if err != nil { return nil, err @@ -317,7 +322,7 @@ func QueryScalar(ctx context.Context, conn *pgxpool.Pool, query string, args ... return nil, ErrNoMatchingRows } -func QueryInt(ctx context.Context, conn *pgxpool.Pool, query string, args ...interface{}) (int, error) { +func QueryInt(ctx context.Context, conn ConnOrTx, query string, args ...interface{}) (int, error) { result, err := QueryScalar(ctx, conn, query, args...) if err != nil { return 0, err @@ -334,3 +339,17 @@ func QueryInt(ctx context.Context, conn *pgxpool.Pool, query string, args ...int return 0, oops.New(nil, "QueryInt got a non-int result: %v", result) } } + +func QueryBool(ctx context.Context, conn ConnOrTx, query string, args ...interface{}) (bool, error) { + result, err := QueryScalar(ctx, conn, query, args...) + if err != nil { + return false, err + } + + switch r := result.(type) { + case bool: + return r, nil + default: + return false, oops.New(nil, "QueryBool got a non-bool result: %v", result) + } +} diff --git a/src/templates/src/editor.html b/src/templates/src/editor.html index 41733318..2ef0e33b 100644 --- a/src/templates/src/editor.html +++ b/src/templates/src/editor.html @@ -75,51 +75,7 @@ {{ 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 }} -
-
-
+ {{ template "forum_post_standalone.html" . }} {{ end }} {{/* diff --git a/src/templates/src/forum_post_delete.html b/src/templates/src/forum_post_delete.html new file mode 100644 index 00000000..a49463ed --- /dev/null +++ b/src/templates/src/forum_post_delete.html @@ -0,0 +1,11 @@ +{{ template "base.html" . }} + +{{ define "content" }} +
+

Are you sure you want to delete this post?

+ {{ template "forum_post_standalone.html" .Post }} +
+ +
+
+{{ end }} diff --git a/src/templates/src/include/forum_post_standalone.html b/src/templates/src/include/forum_post_standalone.html new file mode 100644 index 00000000..72def488 --- /dev/null +++ b/src/templates/src/include/forum_post_standalone.html @@ -0,0 +1,40 @@ +
+
+
+ +
+
+
+
+ {{ .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 }} +
+
+
+
+
+ {{ .Content }} +
+
+
diff --git a/src/website/forums.go b/src/website/forums.go index 9c844a54..8b7cb441 100644 --- a/src/website/forums.go +++ b/src/website/forums.go @@ -19,6 +19,7 @@ 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 { @@ -821,8 +822,10 @@ func ForumPostEdit(c *RequestContext) ResponseData { result := postQueryResult.(*postQuery) // Ensure that the user is permitted to edit the post - isPostAuthor := result.Author != nil && result.Author.ID == c.CurrentUser.ID - if !(isPostAuthor || c.CurrentUser.IsStaff) { + canEdit, err := canEditPost(c.Context(), c.Conn, requestedPostId, *c.CurrentUser) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, err) + } else if !canEdit { return FourOhFour(c) } @@ -875,33 +878,10 @@ func ForumPostEditSubmit(c *RequestContext) ResponseData { } // Ensure that the user is permitted to edit the post - type postResult struct { - AuthorID *int `db:"author.id"` - } - iresult, err := db.QueryOne(c.Context(), c.Conn, postResult{}, - ` - SELECT $columns - FROM - handmade_post AS post - LEFT JOIN auth_user AS author ON post.author_id = author.id - WHERE - post.category_id = $1 - AND post.thread_id = $2 - AND post.id = $3 - AND NOT post.deleted - ORDER BY postdate - `, - currentCatId, - threadId, - postId, - ) - if err != nil && !errors.Is(err, db.ErrNoMatchingRows) { - return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get author of post to delete")) - } - result := iresult.(*postResult) - - isPostAuthor := result.AuthorID != nil && *result.AuthorID == c.CurrentUser.ID - if !(isPostAuthor || c.CurrentUser.IsStaff) { + canEdit, err := canEditPost(c.Context(), c.Conn, postId, *c.CurrentUser) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, err) + } else if !canEdit { return FourOhFour(c) } @@ -920,6 +900,210 @@ func ForumPostEditSubmit(c *RequestContext) ResponseData { 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 { + 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) + } + + // 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) + + baseData := getBaseData(c) + baseData.Title = fmt.Sprintf("Deleting post in \"%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) + + type forumPostDeleteData struct { + templates.BaseData + Post templates.Post + SubmitUrl string + } + + var res ResponseData + res.MustWriteTemplate("forum_post_delete.html", forumPostDeleteData{ + BaseData: baseData, + SubmitUrl: hmnurl.BuildForumPostDelete(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), requestedThreadId, requestedPostId), + 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 { + 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 { + return FourOhFour(c) + } + + tx, err := c.Conn.Begin(c.Context()) + if err != nil { + panic(err) + } + defer tx.Rollback(c.Context()) + + isFirstPost, err := db.QueryBool(c.Context(), tx, + ` + SELECT thread.first_id = $1 + FROM + handmade_thread AS thread + WHERE + thread.id = $2 + `, + postId, + threadId, + ) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to check if post was the first post in the thread")) + } + + if isFirstPost { + // Just delete the whole thread and all its posts. + _, err = tx.Exec(c.Context(), + ` + UPDATE handmade_thread + SET deleted = TRUE + WHERE id = $1 + `, + threadId, + ) + _, err = tx.Exec(c.Context(), + ` + UPDATE handmade_post + SET deleted = TRUE + WHERE thread_id = $1 + `, + threadId, + ) + + err = tx.Commit(c.Context()) + if err != nil { + 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) + return c.Redirect(forumUrl, http.StatusSeeOther) + } + + _, err = tx.Exec(c.Context(), + ` + UPDATE handmade_post + SET deleted = TRUE + WHERE + id = $1 + `, + postId, + ) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to mark forum post as deleted")) + } + + err = fixThreadPostIds(c.Context(), tx, 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") + } else { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fix up thread post ids")) + } + } + + err = tx.Commit(c.Context()) + if err != nil { + 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? + return c.Redirect(threadUrl, http.StatusSeeOther) +} + func createNewForumPostAndVersion(ctx context.Context, tx pgx.Tx, catId, threadId, userId, projectId int, unparsedContent string, ipString string, replyId *int) (postId, versionId int) { // Create post err := tx.QueryRow(ctx, @@ -1028,3 +1212,92 @@ func validateSubforums(lineageBuilder *models.CategoryLineageBuilder, project *m } 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") + +/* +Ensures that the first_id and last_id on the thread are still good. + +Returns errThreadEmpty if the thread contains no visible posts any more. +You should probably mark the thread as deleted in this case. +*/ +func fixThreadPostIds(ctx context.Context, tx pgx.Tx, threadId int) error { + postsIter, err := db.Query(ctx, tx, models.Post{}, + ` + SELECT $columns + FROM handmade_post + WHERE + thread_id = $1 + AND NOT deleted + `, + threadId, + ) + if err != nil { + return oops.New(err, "failed to fetch posts when fixing up thread") + } + + var firstPost, lastPost *models.Post + for _, ipost := range postsIter.ToSlice() { + post := ipost.(*models.Post) + + if firstPost == nil || post.PostDate.Before(firstPost.PostDate) { + firstPost = post + } + if lastPost == nil || post.PostDate.After(lastPost.PostDate) { + lastPost = post + } + } + + if firstPost == nil || lastPost == nil { + return errThreadEmpty + } + + _, err = tx.Exec(ctx, + ` + UPDATE handmade_thread + SET first_id = $1, last_id = $2 + WHERE id = $3 + `, + firstPost.ID, + lastPost.ID, + threadId, + ) + if err != nil { + return oops.New(err, "failed to update thread first/last ids") + } + + return nil +} diff --git a/src/website/routes.go b/src/website/routes.go index a81a61a3..cd67f805 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -162,7 +162,8 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt mainRoutes.POST(hmnurl.RegexForumPostReply, authMiddleware(ForumPostReplySubmit)) mainRoutes.GET(hmnurl.RegexForumPostEdit, authMiddleware(ForumPostEdit)) mainRoutes.POST(hmnurl.RegexForumPostEdit, authMiddleware(ForumPostEditSubmit)) - // mainRoutes.GET(hmnurl.RegexForumPostDelete, authMiddleware(ForumPostDelete)) + mainRoutes.GET(hmnurl.RegexForumPostDelete, authMiddleware(ForumPostDelete)) + mainRoutes.POST(hmnurl.RegexForumPostDelete, authMiddleware(ForumPostDeleteSubmit)) mainRoutes.GET(hmnurl.RegexProjectCSS, ProjectCSS)