Compare commits
7 Commits
86bcde4d49
...
141c279335
Author | SHA1 | Date |
---|---|---|
Asaf Gartner | 141c279335 | |
Asaf Gartner | b165bf7c23 | |
Asaf Gartner | 870a073e22 | |
Asaf Gartner | 86a7128f25 | |
Asaf Gartner | 2012328436 | |
Asaf Gartner | cb71abfdb3 | |
Asaf Gartner | 31f7bf5350 |
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -20,15 +20,17 @@
|
|||
<div class="details">
|
||||
<a class="user" href="{{ .Author.ProfileUrl }}">{{ .Author.Name }}</a> — {{ 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 →</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 →</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
|
|
@ -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, ¬ification)
|
||||
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, ¬ification)
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Panicked in MonitorTwitchSubscriptions")
|
||||
} else if done {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
))
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
||||
|
|
Loading…
Reference in New Issue