package website import ( "context" "errors" "fmt" "math" "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" "github.com/google/uuid" "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")) } // Update asset usage _, err = tx.Exec(ctx, ` DELETE FROM handmade_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) } type assetId struct { AssetID uuid.UUID `db:"id"` } assetResult, err := db.Query(ctx, tx, assetId{}, ` SELECT $columns FROM handmade_asset WHERE s3_key = ANY($1) `, keys, ) if err != nil { panic(oops.New(err, "failed to get assets matching keys")) } var values [][]interface{} for _, asset := range assetResult.ToSlice() { values = append(values, []interface{}{postId, asset.(*assetId).AssetID}) } _, err = tx.CopyFrom(ctx, pgx.Identifier{"handmade_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, 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 }