Implement blog posts
This commit is contained in:
parent
958aeb45e4
commit
b0f75675c8
|
@ -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")
|
||||
}
|
|
@ -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"`
|
||||
|
|
|
@ -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: "",
|
||||
|
|
|
@ -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">✖</a>
|
||||
<a class="edit action button" href="{{ .EditUrl }}" title="Edit">✎</a>
|
||||
{{ 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">↪</a>
|
||||
{{ 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">✖</a>
|
||||
<a class="edit action button" href="{{ .EditUrl }}" title="Edit">✎</a>
|
||||
{{ 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">↪</a>
|
||||
{{ 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 }}
|
|
@ -19,7 +19,7 @@
|
|||
</div>
|
||||
<div class="w-100-l pl3 pl0-l flex flex-column items-center-l">
|
||||
<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 -->
|
||||
<div class="di dn-l ph1">
|
||||
{{ if .Author.IsStaff }}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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{
|
||||
|
|
Loading…
Reference in New Issue