Implement blog posts

This commit is contained in:
Ben Visness 2021-07-30 14:59:48 -05:00
parent 958aeb45e4
commit b0f75675c8
12 changed files with 577 additions and 174 deletions

View File

@ -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")
}

View File

@ -10,7 +10,6 @@ type Post struct {
// TODO: Document each of these // TODO: Document each of these
AuthorID *int `db:"author_id"` AuthorID *int `db:"author_id"`
ParentID *int `db:"parent_id"`
ThreadID int `db:"thread_id"` ThreadID int `db:"thread_id"`
CurrentID int `db:"current_id"` CurrentID int `db:"current_id"`
ProjectID int `db:"project_id"` ProjectID int `db:"project_id"`

View File

@ -15,7 +15,7 @@ func PostToTemplate(p *models.Post, author *models.User, currentTheme string) Po
return Post{ return Post{
ID: p.ID, ID: p.ID,
// Urls not set here. See AddUrls. // Urls not set here. They vary per thread type. Set 'em yourself!
Preview: p.Preview, Preview: p.Preview,
ReadOnly: p.ReadOnly, 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{ var LifecycleBadgeClasses = map[models.ProjectLifecycle]string{
models.ProjectLifecycleUnapproved: "", models.ProjectLifecycleUnapproved: "",
models.ProjectLifecycleApprovalRequired: "", models.ProjectLifecycleApprovalRequired: "",

View File

@ -0,0 +1,119 @@
{{ template "base.html" . }}
{{ define "content" }}
<div class="mw7 margin-center tl post ph3 ph0-ns">
<h1>{{ .Thread.Title }}</h1>
{{ with .MainPost }}
<div class="flex justify-between items-center mt2 mb3">
<div class="flex items-center">
<div class="avatar-icon contain bg-center" style="background-image:url('{{ .Author.AvatarUrl }}');"></div>
<div class="flex flex-column ml2">
<div>
<a class="username" href="{{ .Author.ProfileUrl }}" target="_blank">{{ .Author.Name }}</a>
<div class="di ph1">
{{ if .Author.IsStaff }}
<div class="badge staff"></div>
{{ end }}
</div>
</div>
<div class="c--dim f7">{{ timehtml (absoluteshortdate .PostDate) .PostDate }}</div>
</div>
</div>
<div>
{{ if $.User }}
<div class="flex">
{{ if or (eq .Author.ID $.User.ID) $.User.IsStaff }}
<a class="delete action button" href="{{ .DeleteUrl }}" title="Delete">&#10006;</a>&nbsp;
<a class="edit action button" href="{{ .EditUrl }}" title="Edit">&#9998;</a>&nbsp;
{{ end }}
{{ if or (not $.Thread.Locked) $.User.IsStaff }}
{{ if $.Thread.Locked }}
WARNING: locked thread - use power responsibly!
{{ end }}
<a class="reply action button" href="{{ .ReplyUrl }}" title="Reply">&hookrightarrow;</a>&nbsp;
{{ end }}
</div>
{{ end }}
</div>
</div>
{{ end }}
<!-- Main post -->
<div class="mb3">
<div class="contents overflow-x-auto">
{{ .MainPost.Content }}
</div>
</div>
<div class="optionbar"></div>
{{ range .Comments }}
<div class="pa3 flex items-start background-even">
<div>
<div class="avatar-icon contain bg-center" style="background-image:url('{{ .Author.AvatarUrl }}');"></div>
</div>
<div class="pl3 flex flex-column w-100">
<div class="flex justify-between">
<div>
<div>
<a class="username" href="{{ .Author.ProfileUrl }}" target="_blank">{{ .Author.Username }}</a>
<div class="di ph1">
{{ if .Author.IsStaff }}
<div class="badge staff"></div>
{{ end }}
</div>
</div>
<div class="c--dim f7">
{{ if and .Author.Name (ne .Author.Name .Author.Username) }}
{{ .Author.Name }},
{{ end }}
{{ 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>
{{ if $.User }}
<div class="flex">
{{ if or (eq .Author.ID $.User.ID) $.User.IsStaff }}
<a class="delete action button" href="{{ .DeleteUrl }}" title="Delete">&#10006;</a>&nbsp;
<a class="edit action button" href="{{ .EditUrl }}" title="Edit">&#9998;</a>&nbsp;
{{ end }}
{{ if or (not $.Thread.Locked) $.User.IsStaff }}
{{ if $.Thread.Locked }}
WARNING: locked thread - use power responsibly!
{{ end }}
<a class="reply action button" href="{{ .ReplyUrl }}" title="Reply">&hookrightarrow;</a>&nbsp;
{{ end }}
</div>
{{ end }}
</div>
</div>
<div class="w-100 pt3">
<div class="contents overflow-x-auto">
{{ .Content }}
</div>
</div>
</div>
</div>
{{ end }}
<div class="optionbar bottom">
<div class="options">
{{ if $.User }}
<a class="button" href="{{ .ReplyLink }}"><span class="big pr1">+</span> Add Comment</a>
{{ else }}
<a class="button" href="{{ .LoginLink }}">Log in to comment</a>
{{ end }}
</div>
</div>
</div>
{{ end }}

View File

@ -19,7 +19,7 @@
</div> </div>
<div class="w-100-l pl3 pl0-l flex flex-column items-center-l"> <div class="w-100-l pl3 pl0-l flex flex-column items-center-l">
<div> <div>
<a class="username" href="{{ .Author.ProfileUrl }}" target="_blank">{{ .Author.Username }}</a> {{/* TODO: Text scale stuff? Seems unnecessary. */}} <a class="username" href="{{ .Author.ProfileUrl }}" target="_blank">{{ .Author.Username }}</a>
<!-- Mobile badges --> <!-- Mobile badges -->
<div class="di dn-l ph1"> <div class="di dn-l ph1">
{{ if .Author.IsStaff }} {{ if .Author.IsStaff }}

153
src/website/blogs.go Normal file
View File

@ -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)
}

View File

@ -421,7 +421,7 @@ func ForumThread(c *RequestContext) ResponseData {
currentSubforumSlugs := cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID) 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, numPosts, err := db.QueryInt(c.Context(), c.Conn,
` `
@ -452,52 +452,23 @@ func ForumThread(c *RequestContext) ResponseData {
} }
c.Perf.StartBlock("SQL", "Fetch posts") c.Perf.StartBlock("SQL", "Fetch posts")
type postsQueryResult struct { _, postsAndStuff := FetchThreadPostsAndStuff(
Post models.Post `db:"post"` c.Context(),
Ver models.PostVersion `db:"ver"` c.Conn,
Author *models.User `db:"author"` cd.ThreadID,
Editor *models.User `db:"editor"` page, threadViewPostsPerPage,
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,
) )
c.Perf.EndBlock() c.Perf.EndBlock()
if err != nil {
panic(err)
}
defer itPosts.Close()
var posts []templates.Post var posts []templates.Post
for _, irow := range itPosts.ToSlice() { for _, p := range postsAndStuff {
row := irow.(*postsQueryResult) 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) if p.ReplyPost != nil {
post.AddContentVersion(row.Ver, row.Editor) reply := templates.PostToTemplate(p.ReplyPost, p.ReplyAuthor, c.Theme)
post.AddUrls(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, post.ID) addForumUrlsToPost(&reply, 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)
post.ReplyPost = &reply post.ReplyPost = &reply
} }
@ -531,7 +502,7 @@ func ForumThread(c *RequestContext) ResponseData {
var res ResponseData var res ResponseData
res.MustWriteTemplate("forum_thread.html", forumThreadData{ res.MustWriteTemplate("forum_thread.html", forumThreadData{
BaseData: baseData, BaseData: baseData,
Thread: templates.ThreadToTemplate(thread), Thread: templates.ThreadToTemplate(&thread),
Posts: posts, Posts: posts,
SubforumUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, 1), SubforumUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, 1),
ReplyUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, *thread.FirstID), ReplyUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, *thread.FirstID),
@ -704,7 +675,7 @@ func ForumPostReply(c *RequestContext) ResponseData {
return FourOhFour(c) return FourOhFour(c)
} }
postData := cd.FetchPostAndStuff(c.Context(), c.Conn) postData := FetchPostAndStuff(c.Context(), c.Conn, cd.ThreadID, cd.PostID)
baseData := getBaseData(c) baseData := getBaseData(c)
baseData.Title = fmt.Sprintf("Replying to \"%s\" | %s", postData.Thread.Title, cd.SubforumTree[cd.SubforumID].Name) 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) return FourOhFour(c)
} }
postData := cd.FetchPostAndStuff(c.Context(), c.Conn) postData := FetchPostAndStuff(c.Context(), c.Conn, cd.ThreadID, cd.PostID)
baseData := getBaseData(c) baseData := getBaseData(c)
baseData.Title = fmt.Sprintf("Editing \"%s\" | %s", postData.Thread.Title, cd.SubforumTree[cd.SubforumID].Name) 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) return FourOhFour(c)
} }
postData := cd.FetchPostAndStuff(c.Context(), c.Conn) postData := FetchPostAndStuff(c.Context(), c.Conn, cd.ThreadID, cd.PostID)
baseData := getBaseData(c) baseData := getBaseData(c)
baseData.Title = fmt.Sprintf("Deleting post in \"%s\" | %s", postData.Thread.Title, cd.SubforumTree[cd.SubforumID].Name) 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 WHERE
id = $1 id = $1
AND subforum_id = $2 AND subforum_id = $2
AND NOT deleted
`, `,
res.ThreadID, res.ThreadID,
res.SubforumID, res.SubforumID,
@ -1159,6 +1131,7 @@ func getCommonForumData(c *RequestContext) (commonForumData, bool) {
WHERE WHERE
id = $1 id = $1
AND thread_id = $2 AND thread_id = $2
AND NOT deleted
`, `,
res.PostID, res.PostID,
res.ThreadID, res.ThreadID,
@ -1175,114 +1148,6 @@ func getCommonForumData(c *RequestContext) (commonForumData, bool) {
return res, true 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) { func validateSubforums(lineageBuilder *models.SubforumLineageBuilder, project *models.Project, sfPath string) (int, bool) {
if project.ForumID == nil { if project.ForumID == nil {
return -1, false return -1, false
@ -1316,3 +1181,10 @@ func validateSubforums(lineageBuilder *models.SubforumLineageBuilder, project *m
} }
return subforumId, valid 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)
}

View File

@ -156,7 +156,7 @@ func Index(c *RequestContext) ResponseData {
featurable := (!proj.IsHMN() && featurable := (!proj.IsHMN() &&
projectPost.Post.ThreadType == models.ThreadTypeProjectArticle && projectPost.Post.ThreadType == models.ThreadTypeProjectArticle &&
projectPost.Post.ParentID == nil && *projectPost.Thread.FirstID == projectPost.Post.ID &&
landingPageProject.FeaturedPost == nil) landingPageProject.FeaturedPost == nil)
if featurable { if featurable {

View File

@ -19,6 +19,7 @@ func UrlForGenericPost(thread *models.Thread, post *models.Post, lineageBuilder
} }
var PostTypeMap = map[models.ThreadType][]templates.PostType{ var PostTypeMap = map[models.ThreadType][]templates.PostType{
// { First post , Subsequent post }
models.ThreadTypeProjectArticle: {templates.PostTypeBlogPost, templates.PostTypeBlogComment}, models.ThreadTypeProjectArticle: {templates.PostTypeBlogPost, templates.PostTypeBlogComment},
models.ThreadTypeForumPost: {templates.PostTypeForumThread, templates.PostTypeForumReply}, models.ThreadTypeForumPost: {templates.PostTypeForumThread, templates.PostTypeForumReply},
} }
@ -76,11 +77,11 @@ func MakePostListItem(
postType := templates.PostTypeUnknown postType := templates.PostTypeUnknown
postTypeOptions, found := PostTypeMap[post.ThreadType] postTypeOptions, found := PostTypeMap[post.ThreadType]
if found { if found {
var hasParent int isNotFirst := 0
if post.ParentID != nil { if *thread.FirstID != post.ID {
hasParent = 1 isNotFirst = 1
} }
postType = postTypeOptions[hasParent] postType = postTypeOptions[isNotFirst]
} }
result.PostType = postType result.PostType = postType
result.PostTypePrefix = PostTypePrefix[result.PostType] result.PostTypePrefix = PostTypePrefix[result.PostType]

View File

@ -166,6 +166,9 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
mainRoutes.GET(hmnurl.RegexForumPostDelete, authMiddleware(ForumPostDelete)) mainRoutes.GET(hmnurl.RegexForumPostDelete, authMiddleware(ForumPostDelete))
mainRoutes.POST(hmnurl.RegexForumPostDelete, authMiddleware(csrfMiddleware(ForumPostDeleteSubmit))) 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.RegexPodcast, PodcastIndex)
mainRoutes.GET(hmnurl.RegexPodcastEdit, PodcastEdit) mainRoutes.GET(hmnurl.RegexPodcastEdit, PodcastEdit)
mainRoutes.POST(hmnurl.RegexPodcastEdit, PodcastEditSubmit) mainRoutes.POST(hmnurl.RegexPodcastEdit, PodcastEditSubmit)

View File

@ -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
}

View File

@ -11,7 +11,7 @@ import (
) )
var TimelineTypeMap = map[models.ThreadType][]templates.TimelineType{ var TimelineTypeMap = map[models.ThreadType][]templates.TimelineType{
// { No parent , Has parent } // { First post , Subsequent post }
models.ThreadTypeProjectArticle: {templates.TimelineTypeBlogPost, templates.TimelineTypeBlogComment}, models.ThreadTypeProjectArticle: {templates.TimelineTypeBlogPost, templates.TimelineTypeBlogComment},
models.ThreadTypeForumPost: {templates.TimelineTypeForumThread, templates.TimelineTypeForumReply}, models.ThreadTypeForumPost: {templates.TimelineTypeForumThread, templates.TimelineTypeForumReply},
} }
@ -50,11 +50,11 @@ func PostToTimelineItem(lineageBuilder *models.SubforumLineageBuilder, post *mod
itemType := templates.TimelineTypeUnknown itemType := templates.TimelineTypeUnknown
typeByCatKind, found := TimelineTypeMap[post.ThreadType] typeByCatKind, found := TimelineTypeMap[post.ThreadType]
if found { if found {
hasParent := 0 isNotFirst := 0
if post.ParentID != nil { if *thread.FirstID != post.ID {
hasParent = 1 isNotFirst = 1
} }
itemType = typeByCatKind[hasParent] itemType = typeByCatKind[isNotFirst]
} }
return templates.TimelineItem{ return templates.TimelineItem{