Compare commits

...

7 Commits

Author SHA1 Message Date
Asaf Gartner 141c279335 Merge branch 'master' of git.handmade.network:hmn/hmn 2022-06-16 00:34:48 +03:00
Asaf Gartner b165bf7c23 Added panic recovery to all of our background jobs
Fixes issue #32
2022-06-16 00:33:57 +03:00
Asaf Gartner 870a073e22 Added proper verification for forum urls
Fixes issue #12
2022-06-14 22:52:50 +03:00
Asaf Gartner 86a7128f25 Only show blog content preview in blog index when there are few posts
Partially resolves issue #30
2022-06-14 20:15:16 +03:00
Asaf Gartner 2012328436 Use hmndata helpers for project timeline on project homepage
Resolves issue #37
2022-06-14 19:14:38 +03:00
Asaf Gartner cb71abfdb3 Added createuser and usersetadmin commands
Resolves issue #38
2022-06-14 19:07:11 +03:00
Asaf Gartner 31f7bf5350 Treat fishbowl files like public files. Skip expensive middleware.
We don't really need to validate the project subdomain here.
Fetching the common data eats up a db connection, and we don't gain
anything from it.
2022-06-14 04:06:44 +03:00
13 changed files with 290 additions and 132 deletions

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"net"
"os"
"strconv"
"time"
@ -17,6 +18,7 @@ import (
"git.handmade.network/hmn/hmn/src/perf"
"git.handmade.network/hmn/hmn/src/templates"
"git.handmade.network/hmn/hmn/src/website"
"github.com/google/uuid"
"github.com/jackc/pgx/v4"
"github.com/spf13/cobra"
)
@ -99,6 +101,117 @@ func init() {
}
adminCommand.AddCommand(activateUserCommand)
createUserCommand := &cobra.Command{
Use: "createuser [username]",
Short: "Creates a new user and sets their status to 'Email confirmed'",
Run: func(cmd *cobra.Command, args []string) {
if len(args) < 1 {
fmt.Printf("You must provide a username.\n\n")
cmd.Usage()
os.Exit(1)
}
username := args[0]
password := "password"
ctx := context.Background()
conn := db.NewConn()
defer conn.Close(ctx)
userAlreadyExists := true
_, err := db.QueryOneScalar[int](ctx, conn,
`
SELECT id
FROM hmn_user
WHERE LOWER(username) = LOWER($1)
`,
username,
)
if err != nil {
if errors.Is(err, db.NotFound) {
userAlreadyExists = false
} else {
panic(err)
}
}
if userAlreadyExists {
fmt.Printf("%s already exists. Please pick a different username.\n\n", username)
os.Exit(1)
}
email := uuid.New().String() + "@example.com"
hashedPassword := auth.HashPassword(password)
var newUserId int
err = conn.QueryRow(ctx,
`
INSERT INTO hmn_user (username, email, password, date_joined, registration_ip, status)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
`,
username,
email,
hashedPassword.String(),
time.Now(),
net.ParseIP("127.0.0.1"),
models.UserStatusConfirmed,
).Scan(&newUserId)
if err != nil {
panic(err)
}
fmt.Printf("New user added!\nID: %d\nUsername: %s\nPassword: %s\n", newUserId, username, password)
fmt.Printf("You can change the user's status with the 'userstatus' command as follows:\n")
fmt.Printf("userstatus %s approved\n", username)
fmt.Printf("Or set the user as admin with the following command:\n")
fmt.Printf("usersetadmin %s true\n", username)
},
}
adminCommand.AddCommand(createUserCommand)
userSetAdminCommand := &cobra.Command{
Use: "usersetadmin [username] [true/false]",
Short: "Toggle the user's admin privileges",
Run: func(cmd *cobra.Command, args []string) {
if len(args) < 2 {
fmt.Printf("You must provide a username and 'true' or 'false'.\n\n")
cmd.Usage()
os.Exit(1)
}
username := args[0]
toggleStr := args[1]
makeAdmin := false
if toggleStr == "true" {
makeAdmin = true
}
ctx := context.Background()
conn := db.NewConn()
defer conn.Close(ctx)
res, err := conn.Exec(ctx,
`
UPDATE hmn_user
SET is_staff = $1
WHERE LOWER(username) = LOWER($2)
`,
makeAdmin,
username,
)
if err != nil {
panic(err)
}
if res.RowsAffected() == 0 {
fmt.Printf("User not found.\n\n")
} else {
fmt.Printf("Successfully set %s's is_staff to %v\n\n", username, makeAdmin)
}
},
}
adminCommand.AddCommand(userSetAdminCommand)
userStatusCommand := &cobra.Command{
Use: "userstatus [username] [status]",
Short: "Set a user's status manually",

View File

@ -18,6 +18,7 @@ import (
"git.handmade.network/hmn/hmn/src/logging"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/utils"
"github.com/jackc/pgx/v4/pgxpool"
"golang.org/x/crypto/argon2"
@ -244,22 +245,29 @@ func PeriodicallyDeleteInactiveUsers(ctx context.Context, conn *pgxpool.Pool) jo
for {
select {
case <-t.C:
n, err := DeleteInactiveUsers(ctx, conn)
if err == nil {
if n > 0 {
logging.Info().Int64("num deleted users", n).Msg("Deleted inactive users")
err := func() (err error) {
defer utils.RecoverPanicAsError(&err)
n, err := DeleteInactiveUsers(ctx, conn)
if err == nil {
if n > 0 {
logging.Info().Int64("num deleted users", n).Msg("Deleted inactive users")
}
} else {
logging.Error().Err(err).Msg("Failed to delete inactive users")
}
} else {
logging.Error().Err(err).Msg("Failed to delete inactive users")
}
n, err = DeleteExpiredPasswordResets(ctx, conn)
if err == nil {
if n > 0 {
logging.Info().Int64("num deleted password resets", n).Msg("Deleted expired password resets")
n, err = DeleteExpiredPasswordResets(ctx, conn)
if err == nil {
if n > 0 {
logging.Info().Int64("num deleted password resets", n).Msg("Deleted expired password resets")
}
} else {
logging.Error().Err(err).Msg("Failed to delete expired password resets")
}
} else {
logging.Error().Err(err).Msg("Failed to delete expired password resets")
return nil
}()
if err != nil {
logging.Error().Err(err).Msg("Panicked in PeriodicallyDeleteInactiveUsers")
}
case <-ctx.Done():
return

View File

@ -15,6 +15,7 @@ import (
"git.handmade.network/hmn/hmn/src/logging"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/utils"
"github.com/jackc/pgx/v4/pgxpool"
)
@ -142,13 +143,20 @@ func PeriodicallyDeleteExpiredSessions(ctx context.Context, conn *pgxpool.Pool)
for {
select {
case <-t.C:
n, err := DeleteExpiredSessions(ctx, conn)
if err == nil {
if n > 0 {
logging.Info().Int64("num deleted sessions", n).Msg("Deleted expired sessions")
err := func() (err error) {
defer utils.RecoverPanicAsError(&err)
n, err := DeleteExpiredSessions(ctx, conn)
if err == nil {
if n > 0 {
logging.Info().Int64("num deleted sessions", n).Msg("Deleted expired sessions")
}
} else {
logging.Error().Err(err).Msg("Failed to delete expired sessions")
}
} else {
logging.Error().Err(err).Msg("Failed to delete expired sessions")
return nil
}()
if err != nil {
logging.Error().Err(err).Msg("Panicked in PeriodicallyDeleteExpiredSessions")
}
case <-ctx.Done():
return

View File

@ -51,7 +51,8 @@ func RunDiscordBot(ctx context.Context, dbConn *pgxpool.Pool) jobs.Job {
default:
}
func() {
err := func() (retErr error) {
defer utils.RecoverPanicAsError(&retErr)
log.Info().Msg("Connecting to the Discord gateway")
bot := newBotInstance(dbConn)
err := bot.Run(ctx)
@ -84,7 +85,11 @@ func RunDiscordBot(ctx context.Context, dbConn *pgxpool.Pool) jobs.Job {
time.Sleep(delay)
boff.Reset()
return nil
}()
if err != nil {
log.Error().Err(err).Msg("Panicked in RunDiscordBot")
}
}
}()
return job

View File

@ -10,6 +10,7 @@ import (
"git.handmade.network/hmn/hmn/src/jobs"
"git.handmade.network/hmn/hmn/src/logging"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/utils"
"github.com/jackc/pgx/v4/pgxpool"
)
@ -52,16 +53,25 @@ func RunHistoryWatcher(ctx context.Context, dbConn *pgxpool.Pool) jobs.Job {
}
for {
select {
case <-ctx.Done():
done, err := func() (done bool, err error) {
defer utils.RecoverPanicAsError(&err)
select {
case <-ctx.Done():
return true, nil
case <-newUserTicker.C:
// Get content for messages when a user links their account (but do not create snippets)
fetchMissingContent(ctx, dbConn)
case <-backfillFirstRun:
runBackfill()
case <-backfillTicker.C:
runBackfill()
}
return false, nil
}()
if err != nil {
log.Error().Err(err).Msg("Panicked in RunHistoryWatcher")
} else if done {
return
case <-newUserTicker.C:
// Get content for messages when a user links their account (but do not create snippets)
fetchMissingContent(ctx, dbConn)
case <-backfillFirstRun:
runBackfill()
case <-backfillTicker.C:
runBackfill()
}
}
}()

View File

@ -67,7 +67,7 @@ func GetFullSubforumTree(ctx context.Context, conn *pgxpool.Pool) SubforumTree {
}
for _, cat := range subforums {
// NOTE(asaf): Doing this in a separate loop over rowsSlice to ensure that Children are in db order.
// NOTE(asaf): Doing this in a separate loop over subforums to ensure that Children are in db order.
node := sfTreeMap[cat.ID]
if node.Parent != nil {
node.Parent.Children = append(node.Parent.Children, node)

View File

@ -20,15 +20,17 @@
<div class="details">
<a class="user" href="{{ .Author.ProfileUrl }}">{{ .Author.Name }}</a> &mdash; {{ timehtml (relativedate .Date) .Date }}
</div>
<div class="overflow-hidden mh-5 mt2 relative">
<div>
{{ .Content }}
</div>
<div class="excerpt-fade absolute w-100 h4 bottom-0 z-999"></div>
</div>
<div class="mt2">
<a href="{{ .Url }}">Read More &rarr;</a>
</div>
{{ if $.ShowContent }}
<div class="overflow-hidden mh-5 mt2 relative">
<div>
{{ .Content }}
</div>
<div class="excerpt-fade absolute w-100 h4 bottom-0 z-999"></div>
</div>
<div class="mt2">
<a href="{{ .Url }}">Read More &rarr;</a>
</div>
{{ end }}
</div>
</div>
{{ end }}

View File

@ -14,6 +14,7 @@ import (
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/perf"
"git.handmade.network/hmn/hmn/src/utils"
"github.com/jackc/pgx/v4/pgxpool"
)
@ -45,12 +46,6 @@ func MonitorTwitchSubscriptions(ctx context.Context, dbConn *pgxpool.Pool) jobs.
}()
log.Info().Msg("Running twitch monitor...")
err := refreshAccessToken(ctx)
if err != nil {
log.Error().Err(err).Msg("Failed to fetch refresh token on start")
return
}
monitorTicker := time.NewTicker(2 * time.Hour)
firstRunChannel := make(chan struct{}, 1)
firstRunChannel <- struct{}{}
@ -58,53 +53,67 @@ func MonitorTwitchSubscriptions(ctx context.Context, dbConn *pgxpool.Pool) jobs.
timers := make([]*time.Timer, 0)
expiredTimers := make(chan *time.Timer, 10)
for {
select {
case <-ctx.Done():
for _, timer := range timers {
timer.Stop()
}
return
case expired := <-expiredTimers:
for idx, timer := range timers {
if timer == expired {
timers = append(timers[:idx], timers[idx+1:]...)
break
done, err := func() (done bool, retErr error) {
defer utils.RecoverPanicAsError(&retErr)
select {
case <-ctx.Done():
for _, timer := range timers {
timer.Stop()
}
}
case <-firstRunChannel:
syncWithTwitch(ctx, dbConn, true)
case <-monitorTicker.C:
syncWithTwitch(ctx, dbConn, true)
case <-linksChangedChannel:
// NOTE(asaf): Since we update links inside transactions for users/projects
// we won't see the updated list of links until the transaction is committed.
// Waiting 5 seconds is just a quick workaround for that. It's not
// convenient to only trigger this after the transaction is committed.
var timer *time.Timer
t := time.AfterFunc(5*time.Second, func() {
expiredTimers <- timer
syncWithTwitch(ctx, dbConn, false)
})
timer = t
timers = append(timers, t)
case notification := <-twitchNotificationChannel:
if notification.Type == notificationTypeRevocation {
syncWithTwitch(ctx, dbConn, false)
} else {
// NOTE(asaf): The twitch API (getStreamStatus) lags behind the notification and
// would return old data if we called it immediately, so we process
// the notification to the extent we can, and later do a full update. We can get the
// category from the notification, but not the tags (or the up-to-date title),
// so we can't really skip this.
return true, nil
case expired := <-expiredTimers:
for idx, timer := range timers {
if timer == expired {
timers = append(timers[:idx], timers[idx+1:]...)
break
}
}
case <-firstRunChannel:
err := refreshAccessToken(ctx)
if err != nil {
log.Error().Err(err).Msg("Failed to fetch refresh token on start")
return true, nil
}
syncWithTwitch(ctx, dbConn, true)
case <-monitorTicker.C:
syncWithTwitch(ctx, dbConn, true)
case <-linksChangedChannel:
// NOTE(asaf): Since we update links inside transactions for users/projects
// we won't see the updated list of links until the transaction is committed.
// Waiting 5 seconds is just a quick workaround for that. It's not
// convenient to only trigger this after the transaction is committed.
var timer *time.Timer
t := time.AfterFunc(3*time.Minute, func() {
t := time.AfterFunc(5*time.Second, func() {
expiredTimers <- timer
updateStreamStatus(ctx, dbConn, notification.Status.TwitchID, notification.Status.TwitchLogin)
syncWithTwitch(ctx, dbConn, false)
})
timer = t
timers = append(timers, t)
processEventSubNotification(ctx, dbConn, &notification)
case notification := <-twitchNotificationChannel:
if notification.Type == notificationTypeRevocation {
syncWithTwitch(ctx, dbConn, false)
} else {
// NOTE(asaf): The twitch API (getStreamStatus) lags behind the notification and
// would return old data if we called it immediately, so we process
// the notification to the extent we can, and later do a full update. We can get the
// category from the notification, but not the tags (or the up-to-date title),
// so we can't really skip this.
var timer *time.Timer
t := time.AfterFunc(3*time.Minute, func() {
expiredTimers <- timer
updateStreamStatus(ctx, dbConn, notification.Status.TwitchID, notification.Status.TwitchLogin)
})
timer = t
timers = append(timers, t)
processEventSubNotification(ctx, dbConn, &notification)
}
}
return false, nil
}()
if err != nil {
log.Error().Err(err).Msg("Panicked in MonitorTwitchSubscriptions")
} else if done {
return
}
}
}()

View File

@ -30,11 +30,12 @@ func BlogIndex(c *RequestContext) ResponseData {
Posts []blogIndexEntry
Pagination templates.Pagination
ShowContent bool
CanCreatePost bool
NewPostUrl string
}
const postsPerPage = 5
const postsPerPage = 20
numThreads, err := hmndata.CountThreads(c.Context(), c.Conn, c.CurrentUser, hmndata.ThreadsQuery{
ProjectIDs: []int{c.CurrentProject.ID},
@ -47,7 +48,7 @@ func BlogIndex(c *RequestContext) ResponseData {
numPages := utils.NumPages(numThreads, postsPerPage)
page, ok := ParsePageNumber(c, "page", numPages)
if !ok {
c.Redirect(c.UrlContext.BuildBlog(page), http.StatusSeeOther)
return c.Redirect(c.UrlContext.BuildBlog(page), http.StatusSeeOther)
}
threads, err := hmndata.FetchThreads(c.Context(), c.Conn, c.CurrentUser, hmndata.ThreadsQuery{
@ -105,6 +106,7 @@ func BlogIndex(c *RequestContext) ResponseData {
NextUrl: c.UrlContext.BuildBlog(utils.IntClamp(1, page+1, numPages)),
},
ShowContent: len(entries) <= 5,
CanCreatePost: canCreate,
NewPostUrl: c.UrlContext.BuildBlogNewThread(),
}, c.Perf)

View File

@ -103,7 +103,7 @@ func Forum(c *RequestContext) ResponseData {
numPages := utils.NumPages(numThreads, threadsPerPage)
page, ok := ParsePageNumber(c, "page", numPages)
if !ok {
c.Redirect(c.UrlContext.BuildForum(currentSubforumSlugs, page), http.StatusSeeOther)
return c.Redirect(c.UrlContext.BuildForum(currentSubforumSlugs, page), http.StatusSeeOther)
}
howManyThreadsToSkip := (page - 1) * threadsPerPage
@ -332,8 +332,6 @@ func ForumThread(c *RequestContext) ResponseData {
return FourOhFour(c)
}
currentSubforumSlugs := cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID)
threads, err := hmndata.FetchThreads(c.Context(), c.Conn, c.CurrentUser, hmndata.ThreadsQuery{
ProjectIDs: []int{c.CurrentProject.ID},
ThreadIDs: []int{cd.ThreadID},
@ -346,6 +344,12 @@ func ForumThread(c *RequestContext) ResponseData {
}
threadResult := threads[0]
thread := threadResult.Thread
currentSubforumSlugs := cd.LineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID)
if *thread.SubforumID != cd.SubforumID {
correctThreadUrl := c.UrlContext.BuildForumThread(currentSubforumSlugs, thread.ID, thread.Title, 1)
return c.Redirect(correctThreadUrl, http.StatusSeeOther)
}
numPosts, err := hmndata.CountPosts(c.Context(), c.Conn, c.CurrentUser, hmndata.PostsQuery{
ProjectIDs: []int{c.CurrentProject.ID},
@ -466,11 +470,11 @@ func ForumPostRedirect(c *RequestContext) ResponseData {
page := (postIdx / threadPostsPerPage) + 1
return c.Redirect(c.UrlContext.BuildForumThreadWithPostHash(
cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID),
cd.ThreadID,
cd.LineageBuilder.GetSubforumLineageSlugs(*post.Thread.SubforumID),
post.Thread.ID,
post.Thread.Title,
page,
cd.PostID,
post.Post.ID,
), http.StatusSeeOther)
}
@ -567,9 +571,14 @@ func ForumPostReply(c *RequestContext) ResponseData {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch post for reply"))
}
if *post.Thread.SubforumID != cd.SubforumID {
correctUrl := c.UrlContext.BuildForumPostReply(cd.LineageBuilder.GetSubforumLineageSlugs(*post.Thread.SubforumID), post.Thread.ID, post.Post.ID)
return c.Redirect(correctUrl, http.StatusSeeOther)
}
baseData := getBaseData(
c,
fmt.Sprintf("Replying to post | %s", cd.SubforumTree[cd.SubforumID].Name),
fmt.Sprintf("Replying to post | %s", cd.SubforumTree[*post.Thread.SubforumID].Name),
ForumThreadBreadcrumbs(c.UrlContext, cd.LineageBuilder, &post.Thread),
)
@ -577,7 +586,7 @@ func ForumPostReply(c *RequestContext) ResponseData {
replyPost.AddContentVersion(post.CurrentVersion, post.Editor)
editData := getEditorDataForNew(c.UrlContext, c.CurrentUser, baseData, &replyPost)
editData.SubmitUrl = c.UrlContext.BuildForumPostReply(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID)
editData.SubmitUrl = c.UrlContext.BuildForumPostReply(cd.LineageBuilder.GetSubforumLineageSlugs(*post.Thread.SubforumID), post.Thread.ID, post.Post.ID)
editData.SubmitLabel = "Submit Reply"
var res ResponseData
@ -616,18 +625,18 @@ func ForumPostReplySubmit(c *RequestContext) ResponseData {
// Replies to the OP should not be considered replies
var replyPostId *int
if cd.PostID != post.Thread.FirstID {
replyPostId = &cd.PostID
if post.Post.ID != post.Thread.FirstID {
replyPostId = &post.Post.ID
}
newPostId, _ := hmndata.CreateNewPost(c.Context(), tx, c.CurrentProject.ID, cd.ThreadID, models.ThreadTypeForumPost, c.CurrentUser.ID, replyPostId, unparsed, c.Req.Host)
newPostId, _ := hmndata.CreateNewPost(c.Context(), tx, c.CurrentProject.ID, post.Thread.ID, models.ThreadTypeForumPost, c.CurrentUser.ID, replyPostId, unparsed, c.Req.Host)
err = tx.Commit(c.Context())
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to reply to forum post"))
}
newPostUrl := c.UrlContext.BuildForumPost(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, newPostId)
newPostUrl := c.UrlContext.BuildForumPost(cd.LineageBuilder.GetSubforumLineageSlugs(*post.Thread.SubforumID), post.Thread.ID, newPostId)
return c.Redirect(newPostUrl, http.StatusSeeOther)
}
@ -651,16 +660,21 @@ func ForumPostEdit(c *RequestContext) ResponseData {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch post for editing"))
}
if *post.Thread.SubforumID != cd.SubforumID {
correctUrl := c.UrlContext.BuildForumPostEdit(cd.LineageBuilder.GetSubforumLineageSlugs(*post.Thread.SubforumID), post.Thread.ID, post.Post.ID)
return c.Redirect(correctUrl, http.StatusSeeOther)
}
title := ""
if post.Thread.FirstID == post.Post.ID {
title = fmt.Sprintf("Editing \"%s\" | %s", post.Thread.Title, cd.SubforumTree[cd.SubforumID].Name)
title = fmt.Sprintf("Editing \"%s\" | %s", post.Thread.Title, cd.SubforumTree[*post.Thread.SubforumID].Name)
} else {
title = fmt.Sprintf("Editing Post | %s", cd.SubforumTree[cd.SubforumID].Name)
title = fmt.Sprintf("Editing Post | %s", cd.SubforumTree[*post.Thread.SubforumID].Name)
}
baseData := getBaseData(c, title, ForumThreadBreadcrumbs(c.UrlContext, cd.LineageBuilder, &post.Thread))
editData := getEditorDataForEdit(c.UrlContext, c.CurrentUser, baseData, post)
editData.SubmitUrl = c.UrlContext.BuildForumPostEdit(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID)
editData.SubmitUrl = c.UrlContext.BuildForumPostEdit(cd.LineageBuilder.GetSubforumLineageSlugs(*post.Thread.SubforumID), post.Thread.ID, post.Post.ID)
editData.SubmitLabel = "Submit Edited Post"
var res ResponseData
@ -705,7 +719,7 @@ func ForumPostEditSubmit(c *RequestContext) ResponseData {
return RejectRequest(c, "You must provide a body for your post.")
}
hmndata.CreatePostVersion(c.Context(), tx, cd.PostID, unparsed, c.Req.Host, editReason, &c.CurrentUser.ID)
hmndata.CreatePostVersion(c.Context(), tx, post.Post.ID, unparsed, c.Req.Host, editReason, &c.CurrentUser.ID)
if title != "" {
_, err := tx.Exec(c.Context(),
@ -725,7 +739,7 @@ func ForumPostEditSubmit(c *RequestContext) ResponseData {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to edit forum post"))
}
postUrl := c.UrlContext.BuildForumPost(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID)
postUrl := c.UrlContext.BuildForumPost(cd.LineageBuilder.GetSubforumLineageSlugs(*post.Thread.SubforumID), post.Thread.ID, post.Post.ID)
return c.Redirect(postUrl, http.StatusSeeOther)
}
@ -749,9 +763,14 @@ func ForumPostDelete(c *RequestContext) ResponseData {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch post for delete"))
}
if *post.Thread.SubforumID != cd.SubforumID {
correctUrl := c.UrlContext.BuildForumPostDelete(cd.LineageBuilder.GetSubforumLineageSlugs(*post.Thread.SubforumID), post.Thread.ID, post.Post.ID)
return c.Redirect(correctUrl, http.StatusSeeOther)
}
baseData := getBaseData(
c,
fmt.Sprintf("Deleting post in \"%s\" | %s", post.Thread.Title, cd.SubforumTree[cd.SubforumID].Name),
fmt.Sprintf("Deleting post in \"%s\" | %s", post.Thread.Title, cd.SubforumTree[*post.Thread.SubforumID].Name),
ForumThreadBreadcrumbs(c.UrlContext, cd.LineageBuilder, &post.Thread),
)
@ -767,7 +786,7 @@ func ForumPostDelete(c *RequestContext) ResponseData {
var res ResponseData
res.MustWriteTemplate("forum_post_delete.html", forumPostDeleteData{
BaseData: baseData,
SubmitUrl: c.UrlContext.BuildForumPostDelete(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID),
SubmitUrl: c.UrlContext.BuildForumPostDelete(cd.LineageBuilder.GetSubforumLineageSlugs(*post.Thread.SubforumID), post.Thread.ID, post.Post.ID),
Post: templatePost,
}, c.Perf)
return res

View File

@ -19,6 +19,7 @@ func ParsePageNumber(
paramName string,
numPages int,
) (page int, ok bool) {
page = 1
if pageString, hasPage := c.PathParams[paramName]; hasPage && pageString != "" {
if pageParsed, err := strconv.Atoi(pageString); err == nil {
page = pageParsed

View File

@ -226,30 +226,11 @@ func ProjectHomepage(c *RequestContext) ResponseData {
c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Fetching project timeline")
type postQuery struct {
Post models.Post `db:"post"`
Thread models.Thread `db:"thread"`
Author models.User `db:"author"`
}
posts, err := db.Query[postQuery](c.Context(), c.Conn,
`
SELECT $columns
FROM
post
INNER JOIN thread ON thread.id = post.thread_id
INNER JOIN hmn_user AS author ON author.id = post.author_id
LEFT JOIN asset AS author_avatar ON author_avatar.id = author.avatar_asset_id
WHERE
post.project_id = $1
ORDER BY post.postdate DESC
LIMIT $2
`,
c.CurrentProject.ID,
maxRecentActivity,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project posts"))
}
posts, err := hmndata.FetchPosts(c.Context(), c.Conn, c.CurrentUser, hmndata.PostsQuery{
ProjectIDs: []int{c.CurrentProject.ID},
Limit: maxRecentActivity,
SortDescending: true,
})
c.Perf.EndBlock()
var templateData ProjectHomepageData
@ -326,7 +307,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
lineageBuilder,
&post.Post,
&post.Thread,
&post.Author,
post.Author,
c.Theme,
))
}

View File

@ -156,6 +156,7 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool) ht
AddCORSHeaders(c, &res)
return res
})
routes.GET(hmnurl.RegexFishbowlFiles, FishbowlFiles)
// NOTE(asaf): HMN-only routes:
hmnOnly.GET(hmnurl.RegexManifesto, Manifesto)
@ -224,7 +225,6 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool) ht
hmnOnly.GET(hmnurl.RegexFishbowlIndex, FishbowlIndex)
hmnOnly.GET(hmnurl.RegexFishbowl, Fishbowl)
hmnOnly.GET(hmnurl.RegexFishbowlFiles, FishbowlFiles)
hmnOnly.POST(hmnurl.RegexAPICheckUsername, csrfMiddleware(APICheckUsername))