diff --git a/src/migration/migrations/2021-07-30T164240Z_DropPostParentID.go b/src/migration/migrations/2021-07-30T164240Z_DropPostParentID.go new file mode 100644 index 0000000..b2152c2 --- /dev/null +++ b/src/migration/migrations/2021-07-30T164240Z_DropPostParentID.go @@ -0,0 +1,44 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "git.handmade.network/hmn/hmn/src/oops" + "github.com/jackc/pgx/v4" +) + +func init() { + registerMigration(DropPostParentID{}) +} + +type DropPostParentID struct{} + +func (m DropPostParentID) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2021, 7, 30, 16, 42, 40, 0, time.UTC)) +} + +func (m DropPostParentID) Name() string { + return "DropPostParentID" +} + +func (m DropPostParentID) Description() string { + return "Drop the parent_id field from posts" +} + +func (m DropPostParentID) Up(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + ALTER TABLE handmade_post + DROP parent_id; + `) + if err != nil { + return oops.New(err, "failed to drop parent_id field") + } + + return nil +} + +func (m DropPostParentID) Down(ctx context.Context, tx pgx.Tx) error { + panic("Implement me") +} diff --git a/src/models/post.go b/src/models/post.go index 64bf147..cb56de0 100644 --- a/src/models/post.go +++ b/src/models/post.go @@ -10,7 +10,6 @@ type Post struct { // TODO: Document each of these AuthorID *int `db:"author_id"` - ParentID *int `db:"parent_id"` ThreadID int `db:"thread_id"` CurrentID int `db:"current_id"` ProjectID int `db:"project_id"` diff --git a/src/templates/mapping.go b/src/templates/mapping.go index eed5634..3f780ae 100644 --- a/src/templates/mapping.go +++ b/src/templates/mapping.go @@ -15,7 +15,7 @@ func PostToTemplate(p *models.Post, author *models.User, currentTheme string) Po return Post{ ID: p.ID, - // Urls not set here. See AddUrls. + // Urls not set here. They vary per thread type. Set 'em yourself! Preview: p.Preview, ReadOnly: p.ReadOnly, @@ -38,13 +38,6 @@ func (p *Post) AddContentVersion(ver models.PostVersion, editor *models.User) { } } -func (p *Post) AddUrls(projectSlug string, subforums []string, threadId int, postId int) { - p.Url = hmnurl.BuildForumPost(projectSlug, subforums, threadId, postId) - p.DeleteUrl = hmnurl.BuildForumPostDelete(projectSlug, subforums, threadId, postId) - p.EditUrl = hmnurl.BuildForumPostEdit(projectSlug, subforums, threadId, postId) - p.ReplyUrl = hmnurl.BuildForumPostReply(projectSlug, subforums, threadId, postId) -} - var LifecycleBadgeClasses = map[models.ProjectLifecycle]string{ models.ProjectLifecycleUnapproved: "", models.ProjectLifecycleApprovalRequired: "", diff --git a/src/templates/src/blog_post.html b/src/templates/src/blog_post.html new file mode 100644 index 0000000..b5f2f4c --- /dev/null +++ b/src/templates/src/blog_post.html @@ -0,0 +1,119 @@ +{{ template "base.html" . }} + +{{ define "content" }} +
+

{{ .Thread.Title }}

+ {{ with .MainPost }} +
+
+
+
+
+ {{ .Author.Name }} +
+ {{ if .Author.IsStaff }} +
+ {{ end }} +
+
+
{{ timehtml (absoluteshortdate .PostDate) .PostDate }}
+
+
+
+ {{ if $.User }} +
+ {{ if or (eq .Author.ID $.User.ID) $.User.IsStaff }} +   +   + {{ end }} + {{ if or (not $.Thread.Locked) $.User.IsStaff }} + {{ if $.Thread.Locked }} + WARNING: locked thread - use power responsibly! + {{ end }} +   + {{ end }} +
+ {{ end }} +
+
+ {{ end }} + + +
+
+ {{ .MainPost.Content }} +
+
+ +
+ + {{ range .Comments }} +
+
+
+
+
+
+
+
+ {{ .Author.Username }} +
+ {{ if .Author.IsStaff }} +
+ {{ end }} +
+
+
+ {{ if and .Author.Name (ne .Author.Name .Author.Username) }} + {{ .Author.Name }}, + {{ end }} + {{ timehtml (relativedate .PostDate) .PostDate }} + {{ if .Editor }} + + Edited by + {{ coalesce .Editor.Name .Editor.Username }} + on {{ timehtml (absolutedate .EditDate) .EditDate }} + {{ with .EditReason }} + Reason: {{ . }} + {{ end }} + + {{ end }} +
+
+
+ {{ if $.User }} +
+ {{ if or (eq .Author.ID $.User.ID) $.User.IsStaff }} +   +   + {{ end }} + {{ if or (not $.Thread.Locked) $.User.IsStaff }} + {{ if $.Thread.Locked }} + WARNING: locked thread - use power responsibly! + {{ end }} +   + {{ end }} +
+ {{ end }} +
+
+
+
+ {{ .Content }} +
+
+
+
+ {{ end }} + +
+
+ {{ if $.User }} + + Add Comment + {{ else }} + Log in to comment + {{ end }} +
+
+
+{{ end }} diff --git a/src/templates/src/forum_thread.html b/src/templates/src/forum_thread.html index 553f06d..b0147b8 100644 --- a/src/templates/src/forum_thread.html +++ b/src/templates/src/forum_thread.html @@ -19,7 +19,7 @@
- {{ .Author.Username }} {{/* TODO: Text scale stuff? Seems unnecessary. */}} + {{ .Author.Username }}
{{ if .Author.IsStaff }} diff --git a/src/website/blogs.go b/src/website/blogs.go new file mode 100644 index 0000000..d609914 --- /dev/null +++ b/src/website/blogs.go @@ -0,0 +1,153 @@ +package website + +import ( + "net/http" + "strconv" + + "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/hmnurl" + "git.handmade.network/hmn/hmn/src/templates" +) + +func BlogThread(c *RequestContext) ResponseData { + type blogPostData struct { + templates.BaseData + Thread templates.Thread + MainPost templates.Post + Comments []templates.Post + ReplyLink string + LoginLink string + } + + cd, ok := getCommonBlogData(c) + if !ok { + return FourOhFour(c) + } + + thread, posts := FetchThreadPostsAndStuff( + c.Context(), + c.Conn, + cd.ThreadID, + 0, 0, + ) + + var templatePosts []templates.Post + for _, p := range posts { + post := templates.PostToTemplate(&p.Post, p.Author, c.Theme) + post.AddContentVersion(p.CurrentVersion, p.Editor) + addBlogUrlsToPost(&post, c.CurrentProject.Slug, p.Thread.ID, p.Post.ID) + + if p.ReplyPost != nil { + reply := templates.PostToTemplate(p.ReplyPost, p.ReplyAuthor, c.Theme) + addBlogUrlsToPost(&reply, c.CurrentProject.Slug, p.Thread.ID, p.Post.ID) + post.ReplyPost = &reply + } + + templatePosts = append(templatePosts, post) + } + + baseData := getBaseData(c) + baseData.Title = thread.Title + + var res ResponseData + res.MustWriteTemplate("blog_post.html", blogPostData{ + BaseData: baseData, + Thread: templates.ThreadToTemplate(&thread), + MainPost: templatePosts[0], + Comments: templatePosts[1:], + }, c.Perf) + return res +} + +func BlogPostRedirectToThread(c *RequestContext) ResponseData { + cd, ok := getCommonBlogData(c) + if !ok { + return FourOhFour(c) + } + + thread := FetchThread(c.Context(), c.Conn, cd.ThreadID) + + threadUrl := hmnurl.BuildBlogThread(c.CurrentProject.Slug, cd.ThreadID, thread.Title, 1) + return c.Redirect(threadUrl, http.StatusFound) +} + +type commonBlogData struct { + c *RequestContext + + ThreadID int + PostID int +} + +func getCommonBlogData(c *RequestContext) (commonBlogData, bool) { + c.Perf.StartBlock("BLOGS", "Fetch common blog data") + defer c.Perf.EndBlock() + + res := commonBlogData{ + c: c, + } + + if threadIdStr, hasThreadId := c.PathParams["threadid"]; hasThreadId { + threadId, err := strconv.Atoi(threadIdStr) + if err != nil { + return commonBlogData{}, false + } + res.ThreadID = threadId + + c.Perf.StartBlock("SQL", "Verify that the thread exists") + threadExists, err := db.QueryBool(c.Context(), c.Conn, + ` + SELECT COUNT(*) > 0 + FROM handmade_thread + WHERE + id = $1 + AND project_id = $2 + `, + res.ThreadID, + c.CurrentProject.ID, + ) + c.Perf.EndBlock() + if err != nil { + panic(err) + } + if !threadExists { + return commonBlogData{}, false + } + } + + if postIdStr, hasPostId := c.PathParams["postid"]; hasPostId { + postId, err := strconv.Atoi(postIdStr) + if err != nil { + return commonBlogData{}, false + } + res.PostID = postId + + c.Perf.StartBlock("SQL", "Verify that the post exists") + postExists, err := db.QueryBool(c.Context(), c.Conn, + ` + SELECT COUNT(*) > 0 + FROM handmade_post + WHERE + id = $1 + AND thread_id = $2 + `, + res.PostID, + res.ThreadID, + ) + c.Perf.EndBlock() + if err != nil { + panic(err) + } + if !postExists { + return commonBlogData{}, false + } + } + + return res, true +} + +func addBlogUrlsToPost(p *templates.Post, projectSlug string, threadId int, postId int) { + p.Url = hmnurl.BuildBlogPost(projectSlug, threadId, postId) + p.DeleteUrl = hmnurl.BuildBlogPostDelete(projectSlug, threadId, postId) + p.EditUrl = hmnurl.BuildBlogPostEdit(projectSlug, threadId, postId) + p.ReplyUrl = hmnurl.BuildBlogPostReply(projectSlug, threadId, postId) +} diff --git a/src/website/forums.go b/src/website/forums.go index 1a10f9e..6f869d2 100644 --- a/src/website/forums.go +++ b/src/website/forums.go @@ -421,7 +421,7 @@ func ForumThread(c *RequestContext) ResponseData { currentSubforumSlugs := cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID) - thread := cd.FetchThread(c.Context(), c.Conn) + thread := FetchThread(c.Context(), c.Conn, cd.ThreadID) numPosts, err := db.QueryInt(c.Context(), c.Conn, ` @@ -452,52 +452,23 @@ func ForumThread(c *RequestContext) ResponseData { } c.Perf.StartBlock("SQL", "Fetch posts") - type postsQueryResult struct { - Post models.Post `db:"post"` - Ver 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(c.Context(), c.Conn, postsQueryResult{}, - ` - 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, - threadViewPostsPerPage, - (page-1)*threadViewPostsPerPage, + _, postsAndStuff := FetchThreadPostsAndStuff( + c.Context(), + c.Conn, + cd.ThreadID, + page, threadViewPostsPerPage, ) c.Perf.EndBlock() - if err != nil { - panic(err) - } - defer itPosts.Close() var posts []templates.Post - for _, irow := range itPosts.ToSlice() { - row := irow.(*postsQueryResult) + for _, p := range postsAndStuff { + post := templates.PostToTemplate(&p.Post, p.Author, c.Theme) + post.AddContentVersion(p.CurrentVersion, p.Editor) + addForumUrlsToPost(&post, c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, post.ID) - post := templates.PostToTemplate(&row.Post, row.Author, c.Theme) - post.AddContentVersion(row.Ver, row.Editor) - post.AddUrls(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, post.ID) - - if row.ReplyPost != nil { - reply := templates.PostToTemplate(row.ReplyPost, row.ReplyAuthor, c.Theme) - reply.AddUrls(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, post.ID) + if p.ReplyPost != nil { + reply := templates.PostToTemplate(p.ReplyPost, p.ReplyAuthor, c.Theme) + addForumUrlsToPost(&reply, c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, post.ID) post.ReplyPost = &reply } @@ -531,7 +502,7 @@ func ForumThread(c *RequestContext) ResponseData { var res ResponseData res.MustWriteTemplate("forum_thread.html", forumThreadData{ BaseData: baseData, - Thread: templates.ThreadToTemplate(thread), + Thread: templates.ThreadToTemplate(&thread), Posts: posts, SubforumUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, 1), ReplyUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, *thread.FirstID), @@ -704,7 +675,7 @@ func ForumPostReply(c *RequestContext) ResponseData { return FourOhFour(c) } - postData := cd.FetchPostAndStuff(c.Context(), c.Conn) + postData := FetchPostAndStuff(c.Context(), c.Conn, cd.ThreadID, cd.PostID) baseData := getBaseData(c) baseData.Title = fmt.Sprintf("Replying to \"%s\" | %s", postData.Thread.Title, cd.SubforumTree[cd.SubforumID].Name) @@ -764,7 +735,7 @@ func ForumPostEdit(c *RequestContext) ResponseData { return FourOhFour(c) } - postData := cd.FetchPostAndStuff(c.Context(), c.Conn) + postData := FetchPostAndStuff(c.Context(), c.Conn, cd.ThreadID, cd.PostID) baseData := getBaseData(c) baseData.Title = fmt.Sprintf("Editing \"%s\" | %s", postData.Thread.Title, cd.SubforumTree[cd.SubforumID].Name) @@ -829,7 +800,7 @@ func ForumPostDelete(c *RequestContext) ResponseData { return FourOhFour(c) } - postData := cd.FetchPostAndStuff(c.Context(), c.Conn) + postData := FetchPostAndStuff(c.Context(), c.Conn, cd.ThreadID, cd.PostID) baseData := getBaseData(c) baseData.Title = fmt.Sprintf("Deleting post in \"%s\" | %s", postData.Thread.Title, cd.SubforumTree[cd.SubforumID].Name) @@ -1131,6 +1102,7 @@ func getCommonForumData(c *RequestContext) (commonForumData, bool) { WHERE id = $1 AND subforum_id = $2 + AND NOT deleted `, res.ThreadID, res.SubforumID, @@ -1159,6 +1131,7 @@ func getCommonForumData(c *RequestContext) (commonForumData, bool) { WHERE id = $1 AND thread_id = $2 + AND NOT deleted `, res.PostID, res.ThreadID, @@ -1175,114 +1148,6 @@ func getCommonForumData(c *RequestContext) (commonForumData, bool) { return res, true } -/* -Fetches the current thread according to the parsed path params. Since the ID was already validated -in the constructor, this should always succeed. - -It will not, of course, succeed if there was no thread id in the path params, so don't do that. -*/ -func (cd *commonForumData) FetchThread(ctx context.Context, connOrTx db.ConnOrTx) *models.Thread { - cd.c.Perf.StartBlock("SQL", "Fetch current thread") - type threadQueryResult struct { - Thread models.Thread `db:"thread"` - } - irow, err := db.QueryOne(ctx, connOrTx, threadQueryResult{}, - ` - SELECT $columns - FROM - handmade_thread AS thread - JOIN handmade_subforum AS sf ON sf.id = thread.subforum_id - WHERE - thread.id = $1 - AND NOT thread.deleted - AND sf.id = $2 - `, - cd.ThreadID, - cd.SubforumID, // NOTE(asaf): This verifies that the requested thread is under the requested subforum. - ) - cd.c.Perf.EndBlock() - 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 -} - -type postAndRelatedModels 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"` -} - -/* -Fetches the post, the thread, and author / editor information for the post defined by the path -params. Because all the IDs were validated in the constructor, this should always succeed. - -It will not succeed if there were missing path params though. -*/ -func (cd *commonForumData) FetchPostAndStuff(ctx context.Context, connOrTx db.ConnOrTx) *postAndRelatedModels { - cd.c.Perf.StartBlock("SQL", "Fetch post to reply to") - postQueryResult, err := db.QueryOne(ctx, connOrTx, postAndRelatedModels{}, - ` - 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.thread_id = $1 - AND post.id = $2 - AND NOT post.deleted - `, - cd.ThreadID, - cd.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.(*postAndRelatedModels) - return result -} - -func (cd *commonForumData) UserCanEditPost(ctx context.Context, connOrTx db.ConnOrTx, user models.User) 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 - `, - cd.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 validateSubforums(lineageBuilder *models.SubforumLineageBuilder, project *models.Project, sfPath string) (int, bool) { if project.ForumID == nil { return -1, false @@ -1316,3 +1181,10 @@ func validateSubforums(lineageBuilder *models.SubforumLineageBuilder, project *m } return subforumId, valid } + +func addForumUrlsToPost(p *templates.Post, projectSlug string, subforums []string, threadId int, postId int) { + p.Url = hmnurl.BuildForumPost(projectSlug, subforums, threadId, postId) + p.DeleteUrl = hmnurl.BuildForumPostDelete(projectSlug, subforums, threadId, postId) + p.EditUrl = hmnurl.BuildForumPostEdit(projectSlug, subforums, threadId, postId) + p.ReplyUrl = hmnurl.BuildForumPostReply(projectSlug, subforums, threadId, postId) +} diff --git a/src/website/landing.go b/src/website/landing.go index 0ba76df..b0877eb 100644 --- a/src/website/landing.go +++ b/src/website/landing.go @@ -156,7 +156,7 @@ func Index(c *RequestContext) ResponseData { featurable := (!proj.IsHMN() && projectPost.Post.ThreadType == models.ThreadTypeProjectArticle && - projectPost.Post.ParentID == nil && + *projectPost.Thread.FirstID == projectPost.Post.ID && landingPageProject.FeaturedPost == nil) if featurable { diff --git a/src/website/post_helper.go b/src/website/post_helper.go index 93afa22..6adf487 100644 --- a/src/website/post_helper.go +++ b/src/website/post_helper.go @@ -19,6 +19,7 @@ func UrlForGenericPost(thread *models.Thread, post *models.Post, lineageBuilder } var PostTypeMap = map[models.ThreadType][]templates.PostType{ + // { First post , Subsequent post } models.ThreadTypeProjectArticle: {templates.PostTypeBlogPost, templates.PostTypeBlogComment}, models.ThreadTypeForumPost: {templates.PostTypeForumThread, templates.PostTypeForumReply}, } @@ -76,11 +77,11 @@ func MakePostListItem( postType := templates.PostTypeUnknown postTypeOptions, found := PostTypeMap[post.ThreadType] if found { - var hasParent int - if post.ParentID != nil { - hasParent = 1 + isNotFirst := 0 + if *thread.FirstID != post.ID { + isNotFirst = 1 } - postType = postTypeOptions[hasParent] + postType = postTypeOptions[isNotFirst] } result.PostType = postType result.PostTypePrefix = PostTypePrefix[result.PostType] diff --git a/src/website/routes.go b/src/website/routes.go index 3780478..25cc438 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -166,6 +166,9 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt mainRoutes.GET(hmnurl.RegexForumPostDelete, authMiddleware(ForumPostDelete)) mainRoutes.POST(hmnurl.RegexForumPostDelete, authMiddleware(csrfMiddleware(ForumPostDeleteSubmit))) + mainRoutes.GET(hmnurl.RegexBlogThread, BlogThread) + mainRoutes.GET(hmnurl.RegexBlogPost, BlogPostRedirectToThread) + mainRoutes.GET(hmnurl.RegexPodcast, PodcastIndex) mainRoutes.GET(hmnurl.RegexPodcastEdit, PodcastEdit) mainRoutes.POST(hmnurl.RegexPodcastEdit, PodcastEditSubmit) diff --git a/src/website/threads_and_posts_helper.go b/src/website/threads_and_posts_helper.go new file mode 100644 index 0000000..75bfc44 --- /dev/null +++ b/src/website/threads_and_posts_helper.go @@ -0,0 +1,219 @@ +package website + +import ( + "context" + "errors" + "math" + + "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/models" + "git.handmade.network/hmn/hmn/src/oops" +) + +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) { + 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, + }) + } + + return thread, posts +} + +func (cd *commonForumData) UserCanEditPost(ctx context.Context, connOrTx db.ConnOrTx, user models.User) 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 + `, + cd.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 +} diff --git a/src/website/timeline_helper.go b/src/website/timeline_helper.go index 6d6eab6..b7d7c40 100644 --- a/src/website/timeline_helper.go +++ b/src/website/timeline_helper.go @@ -11,7 +11,7 @@ import ( ) var TimelineTypeMap = map[models.ThreadType][]templates.TimelineType{ - // { No parent , Has parent } + // { First post , Subsequent post } models.ThreadTypeProjectArticle: {templates.TimelineTypeBlogPost, templates.TimelineTypeBlogComment}, models.ThreadTypeForumPost: {templates.TimelineTypeForumThread, templates.TimelineTypeForumReply}, } @@ -50,11 +50,11 @@ func PostToTimelineItem(lineageBuilder *models.SubforumLineageBuilder, post *mod itemType := templates.TimelineTypeUnknown typeByCatKind, found := TimelineTypeMap[post.ThreadType] if found { - hasParent := 0 - if post.ParentID != nil { - hasParent = 1 + isNotFirst := 0 + if *thread.FirstID != post.ID { + isNotFirst = 1 } - itemType = typeByCatKind[hasParent] + itemType = typeByCatKind[isNotFirst] } return templates.TimelineItem{