943 lines
23 KiB
Go
943 lines
23 KiB
Go
package hmndata
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.handmade.network/hmn/hmn/src/db"
|
|
"git.handmade.network/hmn/hmn/src/hmnurl"
|
|
"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"
|
|
"git.handmade.network/hmn/hmn/src/perf"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
type ThreadsQuery struct {
|
|
// Available on all thread queries.
|
|
ProjectIDs []int // if empty, all projects
|
|
ThreadTypes []models.ThreadType // if empty, all types (you do not want to do this)
|
|
SubforumIDs []int // if empty, all subforums
|
|
|
|
// Ignored when using FetchThread.
|
|
ThreadIDs []int
|
|
|
|
// Ignored when using FetchThread or CountThreads.
|
|
Limit, Offset int // if empty, no pagination
|
|
OrderByCreated bool // defaults to order by last updated
|
|
}
|
|
|
|
type ThreadAndStuff struct {
|
|
Project models.Project `db:"project"`
|
|
Thread models.Thread `db:"thread"`
|
|
FirstPost models.Post `db:"first_post"`
|
|
LastPost models.Post `db:"last_post"`
|
|
FirstPostCurrentVersion models.PostVersion `db:"first_version"`
|
|
LastPostCurrentVersion models.PostVersion `db:"last_version"`
|
|
FirstPostAuthor *models.User `db:"first_author"` // Can be nil in case of a deleted user
|
|
LastPostAuthor *models.User `db:"last_author"` // Can be nil in case of a deleted user
|
|
Unread bool
|
|
}
|
|
|
|
/*
|
|
Fetches threads and related models from the database according to all the given
|
|
query params. For the most correct results, provide as much information as you have
|
|
on hand.
|
|
*/
|
|
func FetchThreads(
|
|
ctx context.Context,
|
|
dbConn db.ConnOrTx,
|
|
currentUser *models.User,
|
|
q ThreadsQuery,
|
|
) ([]ThreadAndStuff, error) {
|
|
perf := perf.ExtractPerf(ctx)
|
|
perf.StartBlock("SQL", "Fetch threads")
|
|
defer perf.EndBlock()
|
|
|
|
var qb db.QueryBuilder
|
|
|
|
var currentUserID *int
|
|
if currentUser != nil {
|
|
currentUserID = ¤tUser.ID
|
|
}
|
|
|
|
qb.Add(
|
|
`
|
|
SELECT $columns
|
|
FROM
|
|
thread
|
|
JOIN project ON thread.project_id = project.id
|
|
JOIN post AS first_post ON first_post.id = thread.first_id
|
|
JOIN post AS last_post ON last_post.id = thread.last_id
|
|
JOIN post_version AS first_version ON first_version.id = first_post.current_id
|
|
JOIN post_version AS last_version ON last_version.id = last_post.current_id
|
|
LEFT JOIN hmn_user AS first_author ON first_author.id = first_post.author_id
|
|
LEFT JOIN asset AS first_author_avatar ON first_author_avatar.id = first_author.avatar_asset_id
|
|
LEFT JOIN hmn_user AS last_author ON last_author.id = last_post.author_id
|
|
LEFT JOIN asset AS last_author_avatar ON last_author_avatar.id = last_author.avatar_asset_id
|
|
LEFT JOIN thread_last_read_info AS tlri ON (
|
|
tlri.thread_id = thread.id
|
|
AND tlri.user_id = $?
|
|
)
|
|
LEFT JOIN subforum_last_read_info AS slri ON (
|
|
slri.subforum_id = thread.subforum_id
|
|
AND slri.user_id = $?
|
|
)
|
|
WHERE
|
|
NOT thread.deleted
|
|
AND ( -- project has valid lifecycle
|
|
NOT project.hidden AND project.lifecycle = ANY($?)
|
|
OR project.id = $?
|
|
)
|
|
`,
|
|
currentUserID,
|
|
currentUserID,
|
|
models.VisibleProjectLifecycles,
|
|
models.HMNProjectID,
|
|
)
|
|
if len(q.ProjectIDs) > 0 {
|
|
qb.Add(`AND project.id = ANY ($?)`, q.ProjectIDs)
|
|
}
|
|
if len(q.ThreadTypes) > 0 {
|
|
qb.Add(`AND thread.type = ANY ($?)`, q.ThreadTypes)
|
|
}
|
|
if len(q.SubforumIDs) > 0 {
|
|
qb.Add(`AND thread.subforum_id = ANY ($?)`, q.SubforumIDs)
|
|
}
|
|
if len(q.ThreadIDs) > 0 {
|
|
qb.Add(`AND thread.id = ANY ($?)`, q.ThreadIDs)
|
|
}
|
|
if currentUser == nil {
|
|
qb.Add(
|
|
`AND first_author.status = $? -- thread author is Approved`,
|
|
models.UserStatusApproved,
|
|
)
|
|
} else if !currentUser.IsStaff {
|
|
qb.Add(
|
|
`
|
|
AND (
|
|
first_author.status = $? -- thread author is Approved
|
|
OR first_author.id = $? -- current user is the thread author
|
|
)
|
|
`,
|
|
models.UserStatusApproved,
|
|
currentUserID,
|
|
)
|
|
}
|
|
if q.OrderByCreated {
|
|
qb.Add(`ORDER BY first_post.postdate DESC`)
|
|
} else {
|
|
qb.Add(`ORDER BY last_post.postdate DESC`)
|
|
}
|
|
if q.Limit > 0 {
|
|
qb.Add(`LIMIT $? OFFSET $?`, q.Limit, q.Offset)
|
|
}
|
|
|
|
type resultRow struct {
|
|
ThreadAndStuff
|
|
FirstPostAuthorAvatar *models.Asset `db:"first_author_avatar"`
|
|
LastPostAuthorAvatar *models.Asset `db:"last_author_avatar"`
|
|
ThreadLastReadTime *time.Time `db:"tlri.lastread"`
|
|
ForumLastReadTime *time.Time `db:"slri.lastread"`
|
|
}
|
|
|
|
rows, err := db.Query[resultRow](ctx, dbConn, qb.String(), qb.Args()...)
|
|
if err != nil {
|
|
return nil, oops.New(err, "failed to fetch threads")
|
|
}
|
|
|
|
result := make([]ThreadAndStuff, len(rows))
|
|
for i, row := range rows {
|
|
if row.FirstPostAuthor != nil {
|
|
row.FirstPostAuthor.AvatarAsset = row.FirstPostAuthorAvatar
|
|
}
|
|
if row.LastPostAuthor != nil {
|
|
row.LastPostAuthor.AvatarAsset = row.LastPostAuthorAvatar
|
|
}
|
|
|
|
hasRead := false
|
|
if currentUser != nil && currentUser.MarkedAllReadAt.After(row.LastPost.PostDate) {
|
|
hasRead = true
|
|
} else if row.ThreadLastReadTime != nil && row.ThreadLastReadTime.After(row.LastPost.PostDate) {
|
|
hasRead = true
|
|
} else if row.ForumLastReadTime != nil && row.ForumLastReadTime.After(row.LastPost.PostDate) {
|
|
hasRead = true
|
|
}
|
|
row.Unread = !hasRead
|
|
|
|
result[i] = row.ThreadAndStuff
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
/*
|
|
Fetches a single thread and related data. A wrapper around FetchThreads.
|
|
As with FetchThreads, provide as much information as you know to get the
|
|
most correct results.
|
|
|
|
Returns db.NotFound if no result is found.
|
|
*/
|
|
func FetchThread(
|
|
ctx context.Context,
|
|
dbConn db.ConnOrTx,
|
|
currentUser *models.User,
|
|
threadID int,
|
|
q ThreadsQuery,
|
|
) (ThreadAndStuff, error) {
|
|
q.ThreadIDs = []int{threadID}
|
|
q.Limit = 1
|
|
q.Offset = 0
|
|
|
|
res, err := FetchThreads(ctx, dbConn, currentUser, q)
|
|
if err != nil {
|
|
return ThreadAndStuff{}, oops.New(err, "failed to fetch thread")
|
|
}
|
|
|
|
if len(res) == 0 {
|
|
return ThreadAndStuff{}, db.NotFound
|
|
}
|
|
|
|
return res[0], nil
|
|
}
|
|
|
|
func CountThreads(
|
|
ctx context.Context,
|
|
dbConn db.ConnOrTx,
|
|
currentUser *models.User,
|
|
q ThreadsQuery,
|
|
) (int, error) {
|
|
perf := perf.ExtractPerf(ctx)
|
|
perf.StartBlock("SQL", "Count threads")
|
|
defer perf.EndBlock()
|
|
|
|
var qb db.QueryBuilder
|
|
|
|
var currentUserID *int
|
|
if currentUser != nil {
|
|
currentUserID = ¤tUser.ID
|
|
}
|
|
|
|
qb.Add(
|
|
`
|
|
SELECT COUNT(*)
|
|
FROM
|
|
thread
|
|
JOIN project ON thread.project_id = project.id
|
|
JOIN post AS first_post ON first_post.id = thread.first_id
|
|
LEFT JOIN hmn_user AS first_author ON first_author.id = first_post.author_id
|
|
LEFT JOIN asset AS first_author_avatar ON first_author_avatar.id = first_author.avatar_asset_id
|
|
WHERE
|
|
NOT thread.deleted
|
|
AND ( -- project has valid lifecycle
|
|
NOT project.hidden AND project.lifecycle = ANY($?)
|
|
OR project.id = $?
|
|
)
|
|
`,
|
|
models.VisibleProjectLifecycles,
|
|
models.HMNProjectID,
|
|
)
|
|
if len(q.ProjectIDs) > 0 {
|
|
qb.Add(`AND project.id = ANY ($?)`, q.ProjectIDs)
|
|
}
|
|
if len(q.ThreadTypes) > 0 {
|
|
qb.Add(`AND thread.type = ANY ($?)`, q.ThreadTypes)
|
|
}
|
|
if len(q.SubforumIDs) > 0 {
|
|
qb.Add(`AND thread.subforum_id = ANY ($?)`, q.SubforumIDs)
|
|
}
|
|
if currentUser == nil {
|
|
qb.Add(
|
|
`AND first_author.status = $? -- thread author is Approved`,
|
|
models.UserStatusApproved,
|
|
)
|
|
} else if !currentUser.IsStaff {
|
|
qb.Add(
|
|
`
|
|
AND (
|
|
first_author.status = $? -- thread author is Approved
|
|
OR first_author.id = $? -- current user is the thread author
|
|
)
|
|
`,
|
|
models.UserStatusApproved,
|
|
currentUserID,
|
|
)
|
|
}
|
|
|
|
count, err := db.QueryOneScalar[int](ctx, dbConn, qb.String(), qb.Args()...)
|
|
if err != nil {
|
|
return 0, oops.New(err, "failed to fetch count of threads")
|
|
}
|
|
|
|
return count, nil
|
|
}
|
|
|
|
type PostsQuery struct {
|
|
// Available on all post queries.
|
|
ProjectIDs []int
|
|
UserIDs []int
|
|
ThreadTypes []models.ThreadType
|
|
|
|
// Ignored when using FetchPost.
|
|
ThreadIDs []int
|
|
PostIDs []int
|
|
|
|
// Ignored when using FetchPost or CountPosts.
|
|
Limit, Offset int
|
|
SortDescending bool
|
|
}
|
|
|
|
type PostAndStuff struct {
|
|
Project models.Project `db:"project"`
|
|
Thread models.Thread `db:"thread"`
|
|
Unread bool
|
|
Post models.Post `db:"post"`
|
|
CurrentVersion models.PostVersion `db:"ver"`
|
|
Author *models.User `db:"author"` // Can be nil in case of a deleted user
|
|
Editor *models.User `db:"editor"`
|
|
ReplyPost *models.Post `db:"reply_post"`
|
|
ReplyAuthor *models.User `db:"reply_author"`
|
|
}
|
|
|
|
/*
|
|
Fetches posts and related models from the database according to all the given
|
|
query params. For the most correct results, provide as much information as you have
|
|
on hand.
|
|
*/
|
|
func FetchPosts(
|
|
ctx context.Context,
|
|
dbConn db.ConnOrTx,
|
|
currentUser *models.User,
|
|
q PostsQuery,
|
|
) ([]PostAndStuff, error) {
|
|
perf := perf.ExtractPerf(ctx)
|
|
perf.StartBlock("SQL", "Fetch posts")
|
|
defer perf.EndBlock()
|
|
|
|
var qb db.QueryBuilder
|
|
|
|
var currentUserID *int
|
|
if currentUser != nil {
|
|
currentUserID = ¤tUser.ID
|
|
}
|
|
|
|
type resultRow struct {
|
|
PostAndStuff
|
|
AuthorAvatar *models.Asset `db:"author_avatar"`
|
|
EditorAvatar *models.Asset `db:"editor_avatar"`
|
|
ReplyAuthorAvatar *models.Asset `db:"reply_author_avatar"`
|
|
ThreadLastReadTime *time.Time `db:"tlri.lastread"`
|
|
ForumLastReadTime *time.Time `db:"slri.lastread"`
|
|
}
|
|
|
|
qb.Add(
|
|
`
|
|
SELECT $columns
|
|
FROM
|
|
post
|
|
JOIN thread ON post.thread_id = thread.id
|
|
JOIN project ON post.project_id = project.id
|
|
JOIN post_version AS ver ON ver.id = post.current_id
|
|
LEFT JOIN hmn_user AS author ON author.id = post.author_id
|
|
LEFT JOIN asset AS author_avatar ON author_avatar.id = author.avatar_asset_id
|
|
LEFT JOIN hmn_user AS editor ON ver.editor_id = editor.id
|
|
LEFT JOIN asset AS editor_avatar ON editor_avatar.id = editor.avatar_asset_id
|
|
LEFT JOIN thread_last_read_info AS tlri ON (
|
|
tlri.thread_id = thread.id
|
|
AND tlri.user_id = $?
|
|
)
|
|
LEFT JOIN subforum_last_read_info AS slri ON (
|
|
slri.subforum_id = thread.subforum_id
|
|
AND slri.user_id = $?
|
|
)
|
|
-- Unconditionally fetch reply info, but make sure to check it
|
|
-- later and possibly remove these fields if the permission
|
|
-- check fails.
|
|
LEFT JOIN post AS reply_post ON reply_post.id = post.reply_id
|
|
LEFT JOIN hmn_user AS reply_author ON reply_post.author_id = reply_author.id
|
|
LEFT JOIN asset AS reply_author_avatar ON reply_author_avatar.id = reply_author.avatar_asset_id
|
|
WHERE
|
|
NOT thread.deleted
|
|
AND NOT post.deleted
|
|
AND ( -- project has valid lifecycle
|
|
NOT project.hidden AND project.lifecycle = ANY($?)
|
|
OR project.id = $?
|
|
)
|
|
`,
|
|
currentUserID,
|
|
currentUserID,
|
|
models.VisibleProjectLifecycles,
|
|
models.HMNProjectID,
|
|
)
|
|
if len(q.ProjectIDs) > 0 {
|
|
qb.Add(`AND project.id = ANY ($?)`, q.ProjectIDs)
|
|
}
|
|
if len(q.UserIDs) > 0 {
|
|
qb.Add(`AND post.author_id = ANY ($?)`, q.UserIDs)
|
|
}
|
|
if len(q.ThreadIDs) > 0 {
|
|
qb.Add(`AND post.thread_id = ANY ($?)`, q.ThreadIDs)
|
|
}
|
|
if len(q.ThreadTypes) > 0 {
|
|
qb.Add(`AND thread.type = ANY ($?)`, q.ThreadTypes)
|
|
}
|
|
if len(q.PostIDs) > 0 {
|
|
qb.Add(`AND post.id = ANY ($?)`, q.PostIDs)
|
|
}
|
|
if currentUser == nil {
|
|
qb.Add(
|
|
`AND author.status = $? -- post author is Approved`,
|
|
models.UserStatusApproved,
|
|
)
|
|
} else if !currentUser.IsStaff {
|
|
qb.Add(
|
|
`
|
|
AND (
|
|
author.status = $? -- post author is Approved
|
|
OR author.id = $? -- current user is the post author
|
|
)
|
|
`,
|
|
models.UserStatusApproved,
|
|
currentUserID,
|
|
)
|
|
}
|
|
qb.Add(`ORDER BY post.postdate`)
|
|
if q.SortDescending {
|
|
qb.Add(`DESC`)
|
|
}
|
|
if q.Limit > 0 {
|
|
qb.Add(`LIMIT $? OFFSET $?`, q.Limit, q.Offset)
|
|
}
|
|
|
|
rows, err := db.Query[resultRow](ctx, dbConn, qb.String(), qb.Args()...)
|
|
if err != nil {
|
|
return nil, oops.New(err, "failed to fetch posts")
|
|
}
|
|
|
|
result := make([]PostAndStuff, len(rows))
|
|
for i, row := range rows {
|
|
if row.Author != nil {
|
|
row.Author.AvatarAsset = row.AuthorAvatar
|
|
}
|
|
if row.Editor != nil {
|
|
row.Editor.AvatarAsset = row.EditorAvatar
|
|
}
|
|
if row.ReplyAuthor != nil {
|
|
row.ReplyAuthor.AvatarAsset = row.ReplyAuthorAvatar
|
|
}
|
|
|
|
hasRead := false
|
|
if currentUser != nil && currentUser.MarkedAllReadAt.After(row.Post.PostDate) {
|
|
hasRead = true
|
|
} else if row.ThreadLastReadTime != nil && row.ThreadLastReadTime.After(row.Post.PostDate) {
|
|
hasRead = true
|
|
} else if row.ForumLastReadTime != nil && row.ForumLastReadTime.After(row.Post.PostDate) {
|
|
hasRead = true
|
|
}
|
|
row.Unread = !hasRead
|
|
|
|
if row.ReplyPost != nil && row.ReplyAuthor != nil {
|
|
replyAuthorIsNotApproved := row.ReplyAuthor.Status != models.UserStatusApproved
|
|
canSeeUnapprovedReply := currentUser != nil && (row.ReplyAuthor.ID == currentUser.ID || currentUser.IsStaff)
|
|
if replyAuthorIsNotApproved && !canSeeUnapprovedReply {
|
|
row.ReplyPost = nil
|
|
row.ReplyAuthor = nil
|
|
}
|
|
}
|
|
|
|
result[i] = row.PostAndStuff
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
/*
|
|
Fetches posts for a given thread. A convenient wrapper around FetchPosts that returns
|
|
the posts and the actual thread model.
|
|
|
|
Return db.NotFound if nothing is found (no thread or no posts).
|
|
*/
|
|
func FetchThreadPosts(
|
|
ctx context.Context,
|
|
dbConn db.ConnOrTx,
|
|
currentUser *models.User,
|
|
threadID int,
|
|
q PostsQuery,
|
|
) (models.Thread, []PostAndStuff, error) {
|
|
q.ThreadIDs = []int{threadID}
|
|
|
|
res, err := FetchPosts(ctx, dbConn, currentUser, q)
|
|
if err != nil {
|
|
return models.Thread{}, nil, oops.New(err, "failed to fetch posts for thread")
|
|
}
|
|
|
|
if len(res) == 0 {
|
|
// We shouldn't have threads without posts anyway.
|
|
return models.Thread{}, nil, db.NotFound
|
|
}
|
|
|
|
return res[0].Thread, res, nil
|
|
}
|
|
|
|
/*
|
|
Fetches a single post for a thread and its related data. A wrapper
|
|
around FetchPosts. As with FetchPosts, provide as much information
|
|
as you know to get the most correct results.
|
|
|
|
Returns db.NotFound if no result is found.
|
|
*/
|
|
func FetchThreadPost(
|
|
ctx context.Context,
|
|
dbConn db.ConnOrTx,
|
|
currentUser *models.User,
|
|
threadID, postID int,
|
|
q PostsQuery,
|
|
) (PostAndStuff, error) {
|
|
q.ThreadIDs = []int{threadID}
|
|
q.PostIDs = []int{postID}
|
|
q.Limit = 1
|
|
q.Offset = 0
|
|
|
|
res, err := FetchPosts(ctx, dbConn, currentUser, q)
|
|
if err != nil {
|
|
return PostAndStuff{}, oops.New(err, "failed to fetch post")
|
|
}
|
|
|
|
if len(res) == 0 {
|
|
return PostAndStuff{}, db.NotFound
|
|
}
|
|
|
|
return res[0], nil
|
|
}
|
|
|
|
/*
|
|
Fetches a single post and its related data. A wrapper
|
|
around FetchPosts. As with FetchPosts, provide as much information
|
|
as you know to get the most correct results.
|
|
|
|
Returns db.NotFound if no result is found.
|
|
*/
|
|
func FetchPost(
|
|
ctx context.Context,
|
|
dbConn db.ConnOrTx,
|
|
currentUser *models.User,
|
|
postID int,
|
|
q PostsQuery,
|
|
) (PostAndStuff, error) {
|
|
q.PostIDs = []int{postID}
|
|
q.Limit = 1
|
|
q.Offset = 0
|
|
|
|
res, err := FetchPosts(ctx, dbConn, currentUser, q)
|
|
if err != nil {
|
|
return PostAndStuff{}, oops.New(err, "failed to fetch post")
|
|
}
|
|
|
|
if len(res) == 0 {
|
|
return PostAndStuff{}, db.NotFound
|
|
}
|
|
|
|
return res[0], nil
|
|
}
|
|
|
|
func CountPosts(
|
|
ctx context.Context,
|
|
dbConn db.ConnOrTx,
|
|
currentUser *models.User,
|
|
q PostsQuery,
|
|
) (int, error) {
|
|
perf := perf.ExtractPerf(ctx)
|
|
perf.StartBlock("SQL", "Count posts")
|
|
defer perf.EndBlock()
|
|
|
|
var qb db.QueryBuilder
|
|
|
|
var currentUserID *int
|
|
if currentUser != nil {
|
|
currentUserID = ¤tUser.ID
|
|
}
|
|
|
|
qb.Add(
|
|
`
|
|
SELECT COUNT(*)
|
|
FROM
|
|
post
|
|
JOIN thread ON post.thread_id = thread.id
|
|
JOIN project ON post.project_id = project.id
|
|
LEFT JOIN hmn_user AS author ON author.id = post.author_id
|
|
LEFT JOIN asset AS author_avatar ON author_avatar.id = author.avatar_asset_id
|
|
WHERE
|
|
NOT thread.deleted
|
|
AND NOT post.deleted
|
|
AND ( -- project has valid lifecycle
|
|
NOT project.hidden AND project.lifecycle = ANY($?)
|
|
OR project.id = $?
|
|
)
|
|
`,
|
|
models.VisibleProjectLifecycles,
|
|
models.HMNProjectID,
|
|
)
|
|
if len(q.ProjectIDs) > 0 {
|
|
qb.Add(`AND project.id = ANY ($?)`, q.ProjectIDs)
|
|
}
|
|
if len(q.UserIDs) > 0 {
|
|
qb.Add(`AND post.author_id = ANY ($?)`, q.UserIDs)
|
|
}
|
|
if len(q.ThreadIDs) > 0 {
|
|
qb.Add(`AND post.thread_id = ANY ($?)`, q.ThreadIDs)
|
|
}
|
|
if len(q.ThreadTypes) > 0 {
|
|
qb.Add(`AND thread.type = ANY ($?)`, q.ThreadTypes)
|
|
}
|
|
if currentUser == nil {
|
|
qb.Add(
|
|
`AND author.status = $? -- post author is Approved`,
|
|
models.UserStatusApproved,
|
|
)
|
|
} else if !currentUser.IsStaff {
|
|
qb.Add(
|
|
`
|
|
AND (
|
|
author.status = $? -- post author is Approved
|
|
OR author.id = $? -- current user is the post author
|
|
)
|
|
`,
|
|
models.UserStatusApproved,
|
|
currentUserID,
|
|
)
|
|
}
|
|
|
|
count, err := db.QueryOneScalar[int](ctx, dbConn, qb.String(), qb.Args()...)
|
|
if err != nil {
|
|
return 0, oops.New(err, "failed to count posts")
|
|
}
|
|
|
|
return count, nil
|
|
}
|
|
|
|
func UserCanEditPost(ctx context.Context, connOrTx db.ConnOrTx, user models.User, postId int) bool {
|
|
if user.IsStaff {
|
|
return true
|
|
}
|
|
|
|
authorID, err := db.QueryOneScalar[*int](ctx, connOrTx,
|
|
`
|
|
SELECT post.author_id
|
|
FROM
|
|
post
|
|
WHERE
|
|
post.id = $1
|
|
AND NOT post.deleted
|
|
`,
|
|
postId,
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, db.NotFound) {
|
|
return false
|
|
} else {
|
|
panic(oops.New(err, "failed to get author of post when checking permissions"))
|
|
}
|
|
}
|
|
|
|
return authorID != nil && *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 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 project
|
|
SET `+updates+`
|
|
WHERE
|
|
id = $1
|
|
`,
|
|
projectId,
|
|
time.Now(),
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
func DeletePost(
|
|
ctx context.Context,
|
|
tx pgx.Tx,
|
|
threadId, postId int,
|
|
) (threadDeleted bool) {
|
|
type threadInfo struct {
|
|
FirstPostID int `db:"first_id"`
|
|
Deleted bool `db:"deleted"`
|
|
}
|
|
info, err := db.QueryOne[threadInfo](ctx, tx,
|
|
`
|
|
SELECT $columns
|
|
FROM
|
|
thread
|
|
WHERE
|
|
thread.id = $1
|
|
`,
|
|
threadId,
|
|
)
|
|
if err != nil {
|
|
panic(oops.New(err, "failed to fetch thread info"))
|
|
}
|
|
if info.Deleted {
|
|
return true
|
|
}
|
|
isFirstPost := info.FirstPostID == postId
|
|
|
|
if isFirstPost {
|
|
// Just delete the whole thread and all its posts.
|
|
_, err = tx.Exec(ctx,
|
|
`
|
|
UPDATE thread
|
|
SET deleted = TRUE
|
|
WHERE id = $1
|
|
`,
|
|
threadId,
|
|
)
|
|
_, err = tx.Exec(ctx,
|
|
`
|
|
UPDATE post
|
|
SET deleted = TRUE
|
|
WHERE thread_id = $1
|
|
`,
|
|
threadId,
|
|
)
|
|
|
|
return true
|
|
}
|
|
|
|
_, err = tx.Exec(ctx,
|
|
`
|
|
UPDATE 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 post_version (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 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"))
|
|
}
|
|
|
|
// Update asset usage
|
|
|
|
_, err = tx.Exec(ctx,
|
|
`
|
|
DELETE FROM post_asset_usage
|
|
WHERE post_id = $1
|
|
`,
|
|
postId,
|
|
)
|
|
|
|
matches := hmnurl.RegexS3Asset.FindAllStringSubmatch(unparsedContent, -1)
|
|
keyIdx := hmnurl.RegexS3Asset.SubexpIndex("key")
|
|
|
|
var keys []string
|
|
for _, match := range matches {
|
|
key := match[keyIdx]
|
|
keys = append(keys, key)
|
|
}
|
|
|
|
assetIDs, err := db.QueryScalar[uuid.UUID](ctx, tx,
|
|
`
|
|
SELECT id
|
|
FROM asset
|
|
WHERE s3_key = ANY($1)
|
|
`,
|
|
keys,
|
|
)
|
|
if err != nil {
|
|
panic(oops.New(err, "failed to get assets matching keys"))
|
|
}
|
|
|
|
var values [][]interface{}
|
|
|
|
for _, assetID := range assetIDs {
|
|
values = append(values, []interface{}{postId, assetID})
|
|
}
|
|
|
|
_, err = tx.CopyFrom(ctx, pgx.Identifier{"post_asset_usage"}, []string{"post_id", "asset_id"}, pgx.CopyFromRows(values))
|
|
if err != nil {
|
|
panic(oops.New(err, "failed to insert post asset usage"))
|
|
}
|
|
|
|
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, conn db.ConnOrTx, threadId int) error {
|
|
posts, err := db.Query[models.Post](ctx, conn,
|
|
`
|
|
SELECT $columns
|
|
FROM 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 _, post := range posts {
|
|
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 = conn.Exec(ctx,
|
|
`
|
|
UPDATE 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
|
|
}
|