Add forum post deletion
This commit is contained in:
parent
a2eacd6d00
commit
7f3c818a8f
27
src/db/db.go
27
src/db/db.go
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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 }}
|
||||||
|
|
||||||
{{/*
|
{{/*
|
||||||
|
|
|
@ -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 }}
|
|
@ -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>
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue