490 lines
12 KiB
Go
490 lines
12 KiB
Go
package website
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"net"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.handmade.network/hmn/hmn/src/db"
|
|
"git.handmade.network/hmn/hmn/src/logging"
|
|
"git.handmade.network/hmn/hmn/src/models"
|
|
"git.handmade.network/hmn/hmn/src/oops"
|
|
"git.handmade.network/hmn/hmn/src/parsing"
|
|
"github.com/jackc/pgx/v4"
|
|
)
|
|
|
|
type postAndRelatedModels struct {
|
|
Thread models.Thread
|
|
Post models.Post
|
|
CurrentVersion models.PostVersion
|
|
|
|
Author *models.User
|
|
Editor *models.User
|
|
|
|
ReplyPost *models.Post
|
|
ReplyAuthor *models.User
|
|
}
|
|
|
|
/*
|
|
Fetches the thread defined by your (already parsed) path params.
|
|
|
|
YOU MUST VERIFY THAT THE THREAD ID IS VALID BEFORE CALLING THIS FUNCTION. It will
|
|
not check, for example, that the thread belongs to the correct subforum.
|
|
*/
|
|
func FetchThread(ctx context.Context, connOrTx db.ConnOrTx, threadId int) models.Thread {
|
|
type threadQueryResult struct {
|
|
Thread models.Thread `db:"thread"`
|
|
}
|
|
irow, err := db.QueryOne(ctx, connOrTx, threadQueryResult{},
|
|
`
|
|
SELECT $columns
|
|
FROM
|
|
handmade_thread AS thread
|
|
WHERE
|
|
id = $1
|
|
AND NOT deleted
|
|
`,
|
|
threadId,
|
|
)
|
|
if err != nil {
|
|
// We shouldn't encounter db.ErrNoMatchingRows, because validation should have verified that everything exists.
|
|
panic(oops.New(err, "failed to fetch thread"))
|
|
}
|
|
|
|
thread := irow.(*threadQueryResult).Thread
|
|
return thread
|
|
}
|
|
|
|
/*
|
|
Fetches the post, the thread, and author / editor information for the post defined in
|
|
your path params.
|
|
|
|
YOU MUST VERIFY THAT THE THREAD ID AND POST ID ARE VALID BEFORE CALLING THIS FUNCTION.
|
|
It will not check that the post belongs to the correct subforum, for example, or the
|
|
correct project blog. This logic varies per route and per use of threads, so it doesn't
|
|
happen here.
|
|
*/
|
|
func FetchPostAndStuff(
|
|
ctx context.Context,
|
|
connOrTx db.ConnOrTx,
|
|
threadId, postId int,
|
|
) postAndRelatedModels {
|
|
type resultRow 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"`
|
|
ReplyPost *models.Post `db:"reply"`
|
|
ReplyAuthor *models.User `db:"reply_author"`
|
|
}
|
|
postQueryResult, err := db.QueryOne(ctx, connOrTx, resultRow{},
|
|
`
|
|
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
|
|
LEFT JOIN handmade_post AS reply ON post.reply_id = reply.id
|
|
LEFT JOIN auth_user AS reply_author ON reply.author_id = reply_author.id
|
|
WHERE
|
|
post.thread_id = $1
|
|
AND post.id = $2
|
|
AND NOT post.deleted
|
|
`,
|
|
threadId,
|
|
postId,
|
|
)
|
|
if err != nil {
|
|
// We shouldn't encounter db.ErrNoMatchingRows, because validation should have verified that everything exists.
|
|
panic(oops.New(err, "failed to fetch post and related data"))
|
|
}
|
|
|
|
result := postQueryResult.(*resultRow)
|
|
return postAndRelatedModels{
|
|
Thread: result.Thread,
|
|
Post: result.Post,
|
|
CurrentVersion: result.CurrentVersion,
|
|
Author: result.Author,
|
|
Editor: result.Editor,
|
|
ReplyPost: result.ReplyPost,
|
|
ReplyAuthor: result.ReplyAuthor,
|
|
}
|
|
}
|
|
|
|
/*
|
|
Fetches all the posts (and related models) for a given thread.
|
|
|
|
YOU MUST VERIFY THAT THE THREAD ID IS VALID BEFORE CALLING THIS FUNCTION. It will
|
|
not check, for example, that the thread belongs to the correct subforum.
|
|
*/
|
|
func FetchThreadPostsAndStuff(
|
|
ctx context.Context,
|
|
connOrTx db.ConnOrTx,
|
|
threadId int,
|
|
page, postsPerPage int,
|
|
) (models.Thread, []postAndRelatedModels, string) {
|
|
limit := postsPerPage
|
|
offset := (page - 1) * postsPerPage
|
|
if postsPerPage == 0 {
|
|
limit = math.MaxInt32
|
|
offset = 0
|
|
}
|
|
|
|
thread := FetchThread(ctx, connOrTx, threadId)
|
|
|
|
type postResult struct {
|
|
Post models.Post `db:"post"`
|
|
CurrentVersion models.PostVersion `db:"ver"`
|
|
Author *models.User `db:"author"`
|
|
Editor *models.User `db:"editor"`
|
|
ReplyPost *models.Post `db:"reply"`
|
|
ReplyAuthor *models.User `db:"reply_author"`
|
|
}
|
|
itPosts, err := db.Query(ctx, connOrTx, postResult{},
|
|
`
|
|
SELECT $columns
|
|
FROM
|
|
handmade_post AS post
|
|
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
|
|
LEFT JOIN handmade_post AS reply ON post.reply_id = reply.id
|
|
LEFT JOIN auth_user AS reply_author ON reply.author_id = reply_author.id
|
|
WHERE
|
|
post.thread_id = $1
|
|
AND NOT post.deleted
|
|
ORDER BY post.postdate
|
|
LIMIT $2 OFFSET $3
|
|
`,
|
|
thread.ID,
|
|
limit,
|
|
offset,
|
|
)
|
|
if err != nil {
|
|
panic(oops.New(err, "failed to fetch posts for thread"))
|
|
}
|
|
defer itPosts.Close()
|
|
|
|
var posts []postAndRelatedModels
|
|
for {
|
|
irow, hasNext := itPosts.Next()
|
|
if !hasNext {
|
|
break
|
|
}
|
|
|
|
row := irow.(*postResult)
|
|
posts = append(posts, postAndRelatedModels{
|
|
Thread: thread,
|
|
Post: row.Post,
|
|
CurrentVersion: row.CurrentVersion,
|
|
Author: row.Author,
|
|
Editor: row.Editor,
|
|
ReplyPost: row.ReplyPost,
|
|
ReplyAuthor: row.ReplyAuthor,
|
|
})
|
|
}
|
|
|
|
preview, err := db.QueryString(ctx, connOrTx,
|
|
`
|
|
SELECT post.preview
|
|
FROM
|
|
handmade_post AS post
|
|
JOIN handmade_thread AS thread ON post.thread_id = thread.id
|
|
JOIN handmade_postversion AS ver ON post.current_id = ver.id
|
|
WHERE
|
|
post.thread_id = $1
|
|
AND thread.first_id = post.id
|
|
`,
|
|
thread.ID,
|
|
)
|
|
if err != nil && !errors.Is(err, db.ErrNoMatchingRows) {
|
|
panic(oops.New(err, "failed to fetch posts for thread"))
|
|
}
|
|
|
|
return thread, posts, preview
|
|
}
|
|
|
|
func UserCanEditPost(ctx context.Context, connOrTx db.ConnOrTx, user models.User, postId int) bool {
|
|
if user.IsStaff {
|
|
return true
|
|
}
|
|
|
|
type postResult struct {
|
|
AuthorID *int `db:"post.author_id"`
|
|
}
|
|
iresult, err := db.QueryOne(ctx, connOrTx, postResult{},
|
|
`
|
|
SELECT $columns
|
|
FROM
|
|
handmade_post AS post
|
|
WHERE
|
|
post.id = $1
|
|
AND NOT post.deleted
|
|
`,
|
|
postId,
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, db.ErrNoMatchingRows) {
|
|
return false
|
|
} else {
|
|
panic(oops.New(err, "failed to get author of post when checking permissions"))
|
|
}
|
|
}
|
|
result := iresult.(*postResult)
|
|
|
|
return result.AuthorID != nil && *result.AuthorID == user.ID
|
|
}
|
|
|
|
func CreateNewPost(
|
|
ctx context.Context,
|
|
tx pgx.Tx,
|
|
projectId int,
|
|
threadId int, threadType models.ThreadType,
|
|
userId int,
|
|
replyId *int,
|
|
unparsedContent string,
|
|
ipString string,
|
|
) (postId, versionId int) {
|
|
// Create post
|
|
err := tx.QueryRow(ctx,
|
|
`
|
|
INSERT INTO handmade_post (postdate, thread_id, thread_type, current_id, author_id, project_id, reply_id, preview)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
RETURNING id
|
|
`,
|
|
time.Now(),
|
|
threadId,
|
|
threadType,
|
|
-1,
|
|
userId,
|
|
projectId,
|
|
replyId,
|
|
"", // empty preview, will be updated later
|
|
).Scan(&postId)
|
|
if err != nil {
|
|
panic(oops.New(err, "failed to create post"))
|
|
}
|
|
|
|
// Create and associate version
|
|
versionId = CreatePostVersion(ctx, tx, postId, unparsedContent, ipString, "", nil)
|
|
|
|
// Fix up thread
|
|
err = FixThreadPostIds(ctx, tx, threadId)
|
|
if err != nil {
|
|
panic(oops.New(err, "failed to fix up thread post IDs"))
|
|
}
|
|
|
|
// Track a project update
|
|
updateEntries := []string{"all_last_updated"}
|
|
switch threadType {
|
|
case models.ThreadTypeForumPost:
|
|
updateEntries = append(updateEntries, "forum_last_updated")
|
|
case models.ThreadTypeProjectBlogPost, models.ThreadTypePersonalBlogPost:
|
|
updateEntries = append(updateEntries, "blog_last_updated")
|
|
}
|
|
for i := range updateEntries {
|
|
updateEntries[i] = fmt.Sprintf("%s = $2", updateEntries[i])
|
|
}
|
|
updates := strings.Join(updateEntries, ", ")
|
|
|
|
_, err = tx.Exec(ctx,
|
|
`
|
|
UPDATE handmade_project
|
|
SET `+updates+`
|
|
WHERE
|
|
id = $1
|
|
`,
|
|
projectId,
|
|
time.Now(),
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
func DeletePost(
|
|
ctx context.Context,
|
|
tx pgx.Tx,
|
|
threadId, postId int,
|
|
) (threadDeleted bool) {
|
|
isFirstPost, err := db.QueryBool(ctx, tx,
|
|
`
|
|
SELECT thread.first_id = $1
|
|
FROM
|
|
handmade_thread AS thread
|
|
WHERE
|
|
thread.id = $2
|
|
`,
|
|
postId,
|
|
threadId,
|
|
)
|
|
if err != nil {
|
|
panic(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(ctx,
|
|
`
|
|
UPDATE handmade_thread
|
|
SET deleted = TRUE
|
|
WHERE id = $1
|
|
`,
|
|
threadId,
|
|
)
|
|
_, err = tx.Exec(ctx,
|
|
`
|
|
UPDATE handmade_post
|
|
SET deleted = TRUE
|
|
WHERE thread_id = $1
|
|
`,
|
|
threadId,
|
|
)
|
|
|
|
return true
|
|
}
|
|
|
|
_, err = tx.Exec(ctx,
|
|
`
|
|
UPDATE handmade_post
|
|
SET deleted = TRUE
|
|
WHERE
|
|
id = $1
|
|
`,
|
|
postId,
|
|
)
|
|
if err != nil {
|
|
panic(oops.New(err, "failed to mark forum post as deleted"))
|
|
}
|
|
|
|
err = FixThreadPostIds(ctx, 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 {
|
|
panic(oops.New(err, "failed to fix up thread post ids"))
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
const maxPostContentLength = 200000
|
|
|
|
func CreatePostVersion(ctx context.Context, tx pgx.Tx, postId int, unparsedContent string, ipString string, editReason string, editorId *int) (versionId int) {
|
|
if len(unparsedContent) > maxPostContentLength {
|
|
logging.ExtractLogger(ctx).Warn().
|
|
Str("preview", unparsedContent[:400]).
|
|
Msg("Somebody attempted to create an extremely long post. Content was truncated.")
|
|
unparsedContent = unparsedContent[:maxPostContentLength-1]
|
|
}
|
|
|
|
parsed := parsing.ParseMarkdown(unparsedContent, parsing.ForumRealMarkdown)
|
|
ip := net.ParseIP(ipString)
|
|
|
|
const previewMaxLength = 100
|
|
parsedPlaintext := parsing.ParseMarkdown(unparsedContent, parsing.PlaintextMarkdown)
|
|
preview := parsedPlaintext
|
|
if len(preview) > previewMaxLength-1 {
|
|
preview = preview[:previewMaxLength-1] + "…"
|
|
}
|
|
|
|
// Create post version
|
|
err := tx.QueryRow(ctx,
|
|
`
|
|
INSERT INTO handmade_postversion (post_id, text_raw, text_parsed, ip, date, edit_reason, editor_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING id
|
|
`,
|
|
postId,
|
|
unparsedContent,
|
|
parsed,
|
|
ip,
|
|
time.Now(),
|
|
editReason,
|
|
editorId,
|
|
).Scan(&versionId)
|
|
if err != nil {
|
|
panic(oops.New(err, "failed to create post version"))
|
|
}
|
|
|
|
// Update post with version id and preview
|
|
_, err = tx.Exec(ctx,
|
|
`
|
|
UPDATE handmade_post
|
|
SET current_id = $1, preview = $2
|
|
WHERE id = $3
|
|
`,
|
|
versionId,
|
|
preview,
|
|
postId,
|
|
)
|
|
if err != nil {
|
|
panic(oops.New(err, "failed to set current post version and preview"))
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|