Add forum post deletion

This commit is contained in:
Ben Visness 2021-07-21 23:42:34 -05:00
parent a2eacd6d00
commit 7f3c818a8f
6 changed files with 379 additions and 79 deletions

View File

@ -54,6 +54,11 @@ func typeIsQueryable(t reflect.Type) bool {
return false 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() var connInfo = pgtype.NewConnInfo()
func NewConn() *pgx.Conn { func NewConn() *pgx.Conn {
@ -211,7 +216,7 @@ func followPathThroughStructs(structPtrVal reflect.Value, path []int) (reflect.V
return val, field 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) destType := reflect.TypeOf(destExample)
columnNames, fieldPaths, err := getColumnNamesAndPaths(destType, nil, "") columnNames, fieldPaths, err := getColumnNamesAndPaths(destType, nil, "")
if err != nil { if err != nil {
@ -279,7 +284,7 @@ func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix strin
var ErrNoMatchingRows = errors.New("no matching rows") 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...) rows, err := Query(ctx, conn, destExample, query, args...)
if err != nil { if err != nil {
return nil, err return nil, err
@ -294,7 +299,7 @@ func QueryOne(ctx context.Context, conn *pgxpool.Pool, destExample interface{},
return result, nil 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...) rows, err := conn.Query(ctx, query, args...)
if err != nil { if err != nil {
return nil, err return nil, err
@ -317,7 +322,7 @@ func QueryScalar(ctx context.Context, conn *pgxpool.Pool, query string, args ...
return nil, ErrNoMatchingRows 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...) result, err := QueryScalar(ctx, conn, query, args...)
if err != nil { if err != nil {
return 0, err 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) 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)
}
}

View File

@ -75,51 +75,7 @@
{{ with .PostReplyingTo }} {{ with .PostReplyingTo }}
<h4 class="mt3">The post you're replying to:</h4> <h4 class="mt3">The post you're replying to:</h4>
<div class="bg--dim pa3 br3"> {{ template "forum_post_standalone.html" . }}
<div class="w-100 flex items-center">
{{ if .Author }}
<div class="w-20 mw3 w3">
<!-- Mobile avatar -->
<div class="aspect-ratio--1x1 contain bg-center" style="background-image:url('{{ .Author.AvatarUrl }}');"></div>
</div>
<div class="pl3 flex flex-column">
<div>
<a class="username" href="{{ .Author.ProfileUrl }}" target="_blank">{{ .Author.Username }}</a> {{/* TODO: Text scale stuff? Seems unnecessary. */}}
<!-- Mobile badges -->
<div class="di ph1">
{{ if .Author.IsStaff }}
<div class="badge staff"></div>
{{ end }}
</div>
</div>
{{ if and .Author.Name (ne .Author.Name .Author.Username) }}
<div class="c--dim f7"> {{ .Author.Name }} </div>
{{ end }}
<div class="c--dim f7">
{{ timehtml (relativedate .PostDate) .PostDate }}
{{ if .Editor }}
<span class="pl3">
Edited by
<a class="name" href="{{ .Editor.ProfileUrl }}" target="_blank">{{ coalesce .Editor.Name .Editor.Username }}</a>
on {{ timehtml (absolutedate .EditDate) .EditDate }}
{{ with .EditReason }}
Reason: {{ . }}
{{ end }}
</span>
{{ end }}
</div>
</div>
{{ else }}
<div class="username">Deleted member</div>
<div class="avatar" style="background-image:url('{{ .Author.AvatarUrl }}');"></div>
{{ end }}
</div>
<div class="w-100 pt3">
<div class="contents overflow-x-auto">
{{ .Content }}
</div>
</div>
</div>
{{ end }} {{ end }}
{{/* {{/*

View File

@ -0,0 +1,11 @@
{{ template "base.html" . }}
{{ define "content" }}
<div class="mw7 margin-center">
<h3 class="mb3">Are you sure you want to delete this post?</h3>
{{ template "forum_post_standalone.html" .Post }}
<form action="{{ .SubmitUrl }}" method="POST" class="pv3 flex justify-end">
<input type="submit" value="Delete Post">
</form>
</div>
{{ end }}

View File

@ -0,0 +1,40 @@
<div class="bg--dim pa3 br3 tl">
<div class="w-100 flex items-center">
<div class="w-20 mw3 w3">
<!-- Mobile avatar -->
<div class="aspect-ratio--1x1 contain bg-center" style="background-image:url('{{ .Author.AvatarUrl }}');"></div>
</div>
<div class="pl3 flex flex-column">
<div>
<a class="username" href="{{ .Author.ProfileUrl }}" target="_blank">{{ .Author.Username }}</a> {{/* TODO: Text scale stuff? Seems unnecessary. */}}
<!-- Mobile badges -->
<div class="di ph1">
{{ if .Author.IsStaff }}
<div class="badge staff"></div>
{{ end }}
</div>
</div>
{{ if and .Author.Name (ne .Author.Name .Author.Username) }}
<div class="c--dim f7"> {{ .Author.Name }} </div>
{{ end }}
<div class="c--dim f7">
{{ timehtml (relativedate .PostDate) .PostDate }}
{{ if .Editor }}
<span class="pl3">
Edited by
<a class="name" href="{{ .Editor.ProfileUrl }}" target="_blank">{{ coalesce .Editor.Name .Editor.Username }}</a>
on {{ timehtml (absolutedate .EditDate) .EditDate }}
{{ with .EditReason }}
Reason: {{ . }}
{{ end }}
</span>
{{ end }}
</div>
</div>
</div>
<div class="w-100 pt3">
<div class="contents overflow-x-auto">
{{ .Content }}
</div>
</div>
</div>

View File

@ -19,6 +19,7 @@ import (
"git.handmade.network/hmn/hmn/src/templates" "git.handmade.network/hmn/hmn/src/templates"
"git.handmade.network/hmn/hmn/src/utils" "git.handmade.network/hmn/hmn/src/utils"
"github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/pgxpool"
) )
type forumCategoryData struct { type forumCategoryData struct {
@ -821,8 +822,10 @@ func ForumPostEdit(c *RequestContext) ResponseData {
result := postQueryResult.(*postQuery) result := postQueryResult.(*postQuery)
// Ensure that the user is permitted to edit the post // Ensure that the user is permitted to edit the post
isPostAuthor := result.Author != nil && result.Author.ID == c.CurrentUser.ID canEdit, err := canEditPost(c.Context(), c.Conn, requestedPostId, *c.CurrentUser)
if !(isPostAuthor || c.CurrentUser.IsStaff) { if err != nil {
return ErrorResponse(http.StatusInternalServerError, err)
} else if !canEdit {
return FourOhFour(c) return FourOhFour(c)
} }
@ -875,33 +878,10 @@ func ForumPostEditSubmit(c *RequestContext) ResponseData {
} }
// Ensure that the user is permitted to edit the post // Ensure that the user is permitted to edit the post
type postResult struct { canEdit, err := canEditPost(c.Context(), c.Conn, postId, *c.CurrentUser)
AuthorID *int `db:"author.id"` if err != nil {
} return ErrorResponse(http.StatusInternalServerError, err)
iresult, err := db.QueryOne(c.Context(), c.Conn, postResult{}, } else if !canEdit {
`
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) {
return FourOhFour(c) return FourOhFour(c)
} }
@ -920,6 +900,210 @@ func ForumPostEditSubmit(c *RequestContext) ResponseData {
return c.Redirect(postUrl, http.StatusSeeOther) 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) { func createNewForumPostAndVersion(ctx context.Context, tx pgx.Tx, catId, threadId, userId, projectId int, unparsedContent string, ipString string, replyId *int) (postId, versionId int) {
// Create post // Create post
err := tx.QueryRow(ctx, err := tx.QueryRow(ctx,
@ -1028,3 +1212,92 @@ func validateSubforums(lineageBuilder *models.CategoryLineageBuilder, project *m
} }
return subforumCatId, valid 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
}

View File

@ -162,7 +162,8 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
mainRoutes.POST(hmnurl.RegexForumPostReply, authMiddleware(ForumPostReplySubmit)) mainRoutes.POST(hmnurl.RegexForumPostReply, authMiddleware(ForumPostReplySubmit))
mainRoutes.GET(hmnurl.RegexForumPostEdit, authMiddleware(ForumPostEdit)) mainRoutes.GET(hmnurl.RegexForumPostEdit, authMiddleware(ForumPostEdit))
mainRoutes.POST(hmnurl.RegexForumPostEdit, authMiddleware(ForumPostEditSubmit)) 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) mainRoutes.GET(hmnurl.RegexProjectCSS, ProjectCSS)