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 }}
-
-
-
+ {{ 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 }}
+
+
+
+
+
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)