hmn/src/website/forums.go

596 lines
17 KiB
Go

package website
import (
"errors"
"math"
"net/http"
"strconv"
"strings"
"time"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/templates"
"git.handmade.network/hmn/hmn/src/utils"
)
type forumCategoryData struct {
templates.BaseData
NewThreadUrl string
MarkReadUrl string
Threads []templates.ThreadListItem
Pagination templates.Pagination
Subcategories []forumSubcategoryData
}
type forumSubcategoryData struct {
Name string
Url string
Threads []templates.ThreadListItem
TotalThreads int
}
func ForumCategory(c *RequestContext) ResponseData {
const threadsPerPage = 25
c.Perf.StartBlock("SQL", "Fetch category tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
c.Perf.EndBlock()
currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"])
if !valid {
return FourOhFour(c)
}
currentSubforumSlugs := lineageBuilder.GetSubforumLineageSlugs(currentCatId)
c.Perf.StartBlock("SQL", "Fetch count of page threads")
numThreads, err := db.QueryInt(c.Context(), c.Conn,
`
SELECT COUNT(*)
FROM handmade_thread AS thread
WHERE
thread.category_id = $1
AND NOT thread.deleted
`,
currentCatId,
)
if err != nil {
panic(oops.New(err, "failed to get count of threads"))
}
c.Perf.EndBlock()
numPages := int(math.Ceil(float64(numThreads) / threadsPerPage))
page := 1
pageString, hasPage := c.PathParams["page"]
if hasPage && pageString != "" {
if pageParsed, err := strconv.Atoi(pageString); err == nil {
page = pageParsed
} else {
return c.Redirect(hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, 1), http.StatusSeeOther)
}
}
if page < 1 || numPages < page {
return c.Redirect(hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, utils.IntClamp(1, page, numPages)), http.StatusSeeOther)
}
howManyThreadsToSkip := (page - 1) * threadsPerPage
var currentUserId *int
if c.CurrentUser != nil {
currentUserId = &c.CurrentUser.ID
}
c.Perf.StartBlock("SQL", "Fetch page threads")
type threadQueryResult struct {
Thread models.Thread `db:"thread"`
FirstPost models.Post `db:"firstpost"`
LastPost models.Post `db:"lastpost"`
FirstUser *models.User `db:"firstuser"`
LastUser *models.User `db:"lastuser"`
ThreadLastReadTime *time.Time `db:"tlri.lastread"`
CatLastReadTime *time.Time `db:"clri.lastread"`
}
itMainThreads, err := db.Query(c.Context(), c.Conn, threadQueryResult{},
`
SELECT $columns
FROM
handmade_thread AS thread
JOIN handmade_post AS firstpost ON thread.first_id = firstpost.id
JOIN handmade_post AS lastpost ON thread.last_id = lastpost.id
LEFT JOIN auth_user AS firstuser ON firstpost.author_id = firstuser.id
LEFT JOIN auth_user AS lastuser ON lastpost.author_id = lastuser.id
LEFT JOIN handmade_threadlastreadinfo AS tlri ON (
tlri.thread_id = thread.id
AND tlri.user_id = $2
)
LEFT JOIN handmade_categorylastreadinfo AS clri ON (
clri.category_id = $1
AND clri.user_id = $2
)
WHERE
thread.category_id = $1
AND NOT thread.deleted
ORDER BY lastpost.postdate DESC
LIMIT $3 OFFSET $4
`,
currentCatId,
currentUserId,
threadsPerPage,
howManyThreadsToSkip,
)
if err != nil {
panic(oops.New(err, "failed to fetch threads"))
}
c.Perf.EndBlock()
defer itMainThreads.Close()
makeThreadListItem := func(row *threadQueryResult) templates.ThreadListItem {
hasRead := false
if row.ThreadLastReadTime != nil && row.ThreadLastReadTime.After(row.LastPost.PostDate) {
hasRead = true
} else if row.CatLastReadTime != nil && row.CatLastReadTime.After(row.LastPost.PostDate) {
hasRead = true
}
return templates.ThreadListItem{
Title: row.Thread.Title,
Url: hmnurl.BuildForumThread(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(row.Thread.CategoryID), row.Thread.ID, row.Thread.Title, 1),
FirstUser: templates.UserToTemplate(row.FirstUser, c.Theme),
FirstDate: row.FirstPost.PostDate,
LastUser: templates.UserToTemplate(row.LastUser, c.Theme),
LastDate: row.LastPost.PostDate,
Unread: !hasRead,
}
}
var threads []templates.ThreadListItem
for _, irow := range itMainThreads.ToSlice() {
row := irow.(*threadQueryResult)
threads = append(threads, makeThreadListItem(row))
}
// ---------------------
// Subcategory things
// ---------------------
var subcats []forumSubcategoryData
if page == 1 {
subcatNodes := categoryTree[currentCatId].Children
for _, catNode := range subcatNodes {
c.Perf.StartBlock("SQL", "Fetch count of subcategory threads")
// TODO(asaf): [PERF] [MINOR] Consider replacing querying count per subcat with a single query for all cats with GROUP BY.
numThreads, err := db.QueryInt(c.Context(), c.Conn,
`
SELECT COUNT(*)
FROM handmade_thread AS thread
WHERE
thread.category_id = $1
AND NOT thread.deleted
`,
catNode.ID,
)
if err != nil {
panic(oops.New(err, "failed to get count of threads"))
}
c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Fetch subcategory threads")
// TODO(asaf): [PERF] [MINOR] Consider batching these.
itThreads, err := db.Query(c.Context(), c.Conn, threadQueryResult{},
`
SELECT $columns
FROM
handmade_thread AS thread
JOIN handmade_post AS firstpost ON thread.first_id = firstpost.id
JOIN handmade_post AS lastpost ON thread.last_id = lastpost.id
LEFT JOIN auth_user AS firstuser ON firstpost.author_id = firstuser.id
LEFT JOIN auth_user AS lastuser ON lastpost.author_id = lastuser.id
LEFT JOIN handmade_threadlastreadinfo AS tlri ON (
tlri.thread_id = thread.id
AND tlri.user_id = $2
)
LEFT JOIN handmade_categorylastreadinfo AS clri ON (
clri.category_id = $1
AND clri.user_id = $2
)
WHERE
thread.category_id = $1
AND NOT thread.deleted
ORDER BY lastpost.postdate DESC
LIMIT 3
`,
catNode.ID,
currentUserId,
)
if err != nil {
panic(err)
}
defer itThreads.Close()
c.Perf.EndBlock()
var threads []templates.ThreadListItem
for _, irow := range itThreads.ToSlice() {
threadRow := irow.(*threadQueryResult)
threads = append(threads, makeThreadListItem(threadRow))
}
subcats = append(subcats, forumSubcategoryData{
Name: *catNode.Name,
Url: hmnurl.BuildForumCategory(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(catNode.ID), 1),
Threads: threads,
TotalThreads: numThreads,
})
}
}
// ---------------------
// Template assembly
// ---------------------
baseData := getBaseData(c)
baseData.Title = c.CurrentProject.Name + " Forums"
baseData.Breadcrumbs = []templates.Breadcrumb{ // TODO(ben): This is wrong; it needs to account for subcategories.
{
Name: c.CurrentProject.Name,
Url: hmnurl.BuildProjectHomepage(c.CurrentProject.Slug),
},
{
Name: "Forums",
Url: hmnurl.BuildForumCategory(c.CurrentProject.Slug, nil, 1),
Current: true,
},
}
currentSubforums := lineageBuilder.GetSubforumLineage(currentCatId)
for i, subforum := range currentSubforums {
baseData.Breadcrumbs = append(baseData.Breadcrumbs, templates.Breadcrumb{
Name: *subforum.Name, // NOTE(asaf): All subforum categories must have names.
Url: hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs[0:i+1], 1),
})
}
var res ResponseData
err = res.WriteTemplate("forum_category.html", forumCategoryData{
BaseData: baseData,
NewThreadUrl: hmnurl.BuildForumNewThread(c.CurrentProject.Slug, currentSubforumSlugs, false),
MarkReadUrl: hmnurl.BuildMarkRead(currentCatId),
Threads: threads,
Pagination: templates.Pagination{
Current: page,
Total: numPages,
FirstUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, 1),
LastUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, numPages),
NextUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, utils.IntClamp(1, page+1, numPages)),
PreviousUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, utils.IntClamp(1, page-1, numPages)),
},
Subcategories: subcats,
}, c.Perf)
if err != nil {
panic(err)
}
return res
}
type forumThreadData struct {
templates.BaseData
Thread templates.Thread
Posts []templates.Post
CategoryUrl string
ReplyUrl string
Pagination templates.Pagination
}
var threadViewPostsPerPage = 15
func ForumThread(c *RequestContext) ResponseData {
c.Perf.StartBlock("SQL", "Fetch category tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
c.Perf.EndBlock()
currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"])
if !valid {
return FourOhFour(c)
}
threadId, err := strconv.Atoi(c.PathParams["threadid"])
if err != nil {
return FourOhFour(c)
}
currentSubforumSlugs := lineageBuilder.GetSubforumLineageSlugs(currentCatId)
c.Perf.StartBlock("SQL", "Fetch current thread")
type threadQueryResult struct {
Thread models.Thread `db:"thread"`
}
irow, err := db.QueryOne(c.Context(), c.Conn, threadQueryResult{},
`
SELECT $columns
FROM
handmade_thread AS thread
JOIN handmade_category AS cat ON cat.id = thread.category_id
WHERE
thread.id = $1
AND NOT thread.deleted
AND cat.id = $2
`,
threadId,
currentCatId, // NOTE(asaf): This verifies that the requested thread is under the requested subforum.
)
c.Perf.EndBlock()
if err != nil {
if errors.Is(err, db.ErrNoMatchingRows) {
return FourOhFour(c)
} else {
panic(err)
}
}
thread := irow.(*threadQueryResult).Thread
numPosts, err := db.QueryInt(c.Context(), c.Conn,
`
SELECT COUNT(*)
FROM handmade_post
WHERE
thread_id = $1
AND NOT deleted
`,
thread.ID,
)
if err != nil {
panic(oops.New(err, "failed to get count of posts for thread"))
}
page, numPages, ok := getPageInfo(c.PathParams["page"], numPosts, threadViewPostsPerPage)
if !ok {
urlNoPage := hmnurl.BuildForumThread(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.Title, 1)
return c.Redirect(urlNoPage, http.StatusSeeOther)
}
pagination := templates.Pagination{
Current: page,
Total: numPages,
FirstUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.Title, 1),
LastUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.Title, numPages),
NextUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.Title, utils.IntClamp(1, page+1, numPages)),
PreviousUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.Title, utils.IntClamp(1, page-1, numPages)),
}
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"`
}
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
WHERE
post.thread_id = $1
AND NOT post.deleted
ORDER BY postdate
LIMIT $2 OFFSET $3
`,
thread.ID,
threadViewPostsPerPage,
(page-1)*threadViewPostsPerPage,
)
c.Perf.EndBlock()
if err != nil {
panic(err)
}
defer itPosts.Close()
var posts []templates.Post
for _, irow := range itPosts.ToSlice() {
row := irow.(*postsQueryResult)
post := templates.PostToTemplate(&row.Post, row.Author, c.Theme)
post.AddContentVersion(row.Ver, row.Editor, c.Theme)
post.AddUrls(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, post.ID)
posts = append(posts, post)
}
baseData := getBaseData(c)
baseData.Title = thread.Title
// TODO(asaf): Set breadcrumbs
var res ResponseData
err = res.WriteTemplate("forum_thread.html", forumThreadData{
BaseData: baseData,
Thread: templates.ThreadToTemplate(&thread),
Posts: posts,
CategoryUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, 1),
ReplyUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, *thread.FirstID),
Pagination: pagination,
}, c.Perf)
if err != nil {
panic(err)
}
return res
}
func ForumPostRedirect(c *RequestContext) ResponseData {
c.Perf.StartBlock("SQL", "Fetch category tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
c.Perf.EndBlock()
currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"])
if !valid {
return FourOhFour(c)
}
requestedThreadId, err := strconv.Atoi(c.PathParams["threadid"])
if err != nil {
return FourOhFour(c)
}
requestedPostId, err := strconv.Atoi(c.PathParams["postid"])
if err != nil {
return FourOhFour(c)
}
c.Perf.StartBlock("SQL", "Fetch post ids for thread")
type postQuery struct {
PostID int `db:"post.id"`
}
postQueryResult, err := db.Query(c.Context(), c.Conn, postQuery{},
`
SELECT $columns
FROM
handmade_post AS post
WHERE
post.category_id = $1
AND post.thread_id = $2
AND NOT post.deleted
ORDER BY postdate
`,
currentCatId,
requestedThreadId,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch post ids"))
}
postQuerySlice := postQueryResult.ToSlice()
c.Perf.EndBlock()
postIdx := -1
for i, id := range postQuerySlice {
if id.(*postQuery).PostID == requestedPostId {
postIdx = i
break
}
}
if postIdx == -1 {
return FourOhFour(c)
}
c.Perf.StartBlock("SQL", "Fetch thread title")
type threadTitleQuery struct {
ThreadTitle string `db:"thread.title"`
}
threadTitleQueryResult, err := db.QueryOne(c.Context(), c.Conn, threadTitleQuery{},
`
SELECT $columns
FROM handmade_thread AS thread
WHERE thread.id = $1
`,
requestedThreadId,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch thread title"))
}
c.Perf.EndBlock()
threadTitle := threadTitleQueryResult.(*threadTitleQuery).ThreadTitle
page := (postIdx / threadViewPostsPerPage) + 1
return c.Redirect(hmnurl.BuildForumThreadWithPostHash(
c.CurrentProject.Slug,
lineageBuilder.GetSubforumLineageSlugs(currentCatId),
requestedThreadId,
threadTitle,
page,
requestedPostId,
), http.StatusSeeOther)
}
type editorData struct {
templates.BaseData
SubmitUrl string
PostTitle string
PostBody string
SubmitLabel string
PreviewLabel string
}
func ForumNewThread(c *RequestContext) ResponseData {
if c.Req.Method == http.MethodPost {
// TODO: Get preview data
}
baseData := getBaseData(c)
baseData.Title = "Create New Thread"
baseData.MathjaxEnabled = true
// TODO(ben): Set breadcrumbs
var res ResponseData
err := res.WriteTemplate("editor.html", editorData{
BaseData: baseData,
SubmitLabel: "Post New Thread",
PreviewLabel: "Preview",
}, c.Perf)
// err := res.WriteTemplate("forum_thread.html", forumThreadData{
// BaseData: baseData,
// Thread: templates.ThreadToTemplate(&thread),
// Posts: posts,
// CategoryUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, 1),
// ReplyUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, *thread.FirstID),
// Pagination: pagination,
// }, c.Perf)
if err != nil {
panic(err)
}
return res
}
func ForumNewThreadSubmit(c *RequestContext) ResponseData {
var res ResponseData
return res
}
func validateSubforums(lineageBuilder *models.CategoryLineageBuilder, project *models.Project, catPath string) (int, bool) {
if project.ForumID == nil {
return -1, false
}
subforumCatId := *project.ForumID
if len(catPath) == 0 {
return subforumCatId, true
}
catPath = strings.ToLower(catPath)
valid := false
catSlugs := strings.Split(catPath, "/")
lastSlug := catSlugs[len(catSlugs)-1]
if len(lastSlug) > 0 {
lastSlugCatId := lineageBuilder.FindIdBySlug(project.ID, lastSlug)
if lastSlugCatId != -1 {
subforumSlugs := lineageBuilder.GetSubforumLineageSlugs(lastSlugCatId)
allMatch := true
for i, subforum := range subforumSlugs {
if subforum != catSlugs[i] {
allMatch = false
break
}
}
valid = allMatch
}
if valid {
subforumCatId = lastSlugCatId
}
}
return subforumCatId, valid
}