648 lines
21 KiB
Go
648 lines
21 KiB
Go
package website
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"math/rand"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.handmade.network/hmn/hmn/src/auth"
|
|
"git.handmade.network/hmn/hmn/src/config"
|
|
"git.handmade.network/hmn/hmn/src/db"
|
|
"git.handmade.network/hmn/hmn/src/email"
|
|
"git.handmade.network/hmn/hmn/src/hmnurl"
|
|
"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/perf"
|
|
"git.handmade.network/hmn/hmn/src/templates"
|
|
"git.handmade.network/hmn/hmn/src/utils"
|
|
"github.com/jackc/pgx/v4/pgxpool"
|
|
"github.com/teacat/noire"
|
|
)
|
|
|
|
func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, perfCollector *perf.PerfCollector) http.Handler {
|
|
router := &Router{}
|
|
routes := RouteBuilder{
|
|
Router: router,
|
|
Middleware: func(h Handler) Handler {
|
|
return func(c *RequestContext) (res ResponseData) {
|
|
c.Conn = conn
|
|
|
|
logPerf := TrackRequestPerf(c, perfCollector)
|
|
defer logPerf()
|
|
|
|
defer LogContextErrorsFromResponse(c, &res)
|
|
defer MiddlewarePanicCatcher(c, &res)
|
|
|
|
return h(c)
|
|
}
|
|
},
|
|
}
|
|
|
|
anyProject := routes
|
|
anyProject.Middleware = func(h Handler) Handler {
|
|
return func(c *RequestContext) (res ResponseData) {
|
|
c.Conn = conn
|
|
|
|
logPerf := TrackRequestPerf(c, perfCollector)
|
|
defer logPerf()
|
|
|
|
defer LogContextErrorsFromResponse(c, &res)
|
|
defer MiddlewarePanicCatcher(c, &res)
|
|
|
|
defer storeNoticesInCookie(c, &res)
|
|
|
|
ok, errRes := LoadCommonWebsiteData(c)
|
|
if !ok {
|
|
return errRes
|
|
}
|
|
|
|
return h(c)
|
|
}
|
|
}
|
|
|
|
hmnOnly := routes
|
|
hmnOnly.Middleware = func(h Handler) Handler {
|
|
return func(c *RequestContext) (res ResponseData) {
|
|
c.Conn = conn
|
|
|
|
logPerf := TrackRequestPerf(c, perfCollector)
|
|
defer logPerf()
|
|
|
|
defer LogContextErrorsFromResponse(c, &res)
|
|
defer MiddlewarePanicCatcher(c, &res)
|
|
|
|
defer storeNoticesInCookie(c, &res)
|
|
|
|
ok, errRes := LoadCommonWebsiteData(c)
|
|
if !ok {
|
|
return errRes
|
|
}
|
|
|
|
if !c.CurrentProject.IsHMN() {
|
|
return c.Redirect(hmnurl.Url(c.URL().Path, hmnurl.QFromURL(c.URL())), http.StatusMovedPermanently)
|
|
}
|
|
|
|
return h(c)
|
|
}
|
|
}
|
|
|
|
authMiddleware := func(h Handler) Handler {
|
|
return func(c *RequestContext) (res ResponseData) {
|
|
if c.CurrentUser == nil {
|
|
return c.Redirect(hmnurl.BuildLoginPage(c.FullUrl()), http.StatusSeeOther)
|
|
}
|
|
|
|
return h(c)
|
|
}
|
|
}
|
|
|
|
adminMiddleware := func(h Handler) Handler {
|
|
return func(c *RequestContext) (res ResponseData) {
|
|
if c.CurrentUser == nil || !c.CurrentUser.IsStaff {
|
|
return FourOhFour(c)
|
|
}
|
|
|
|
return h(c)
|
|
}
|
|
}
|
|
|
|
csrfMiddleware := func(h Handler) Handler {
|
|
// CSRF mitigation actions per the OWASP cheat sheet:
|
|
// https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
|
|
return func(c *RequestContext) ResponseData {
|
|
c.Req.ParseMultipartForm(100 * 1024 * 1024)
|
|
csrfToken := c.Req.Form.Get(auth.CSRFFieldName)
|
|
if csrfToken != c.CurrentSession.CSRFToken {
|
|
c.Logger.Warn().Str("userId", c.CurrentUser.Username).Msg("user failed CSRF validation - potential attack?")
|
|
|
|
res := c.Redirect("/", http.StatusSeeOther)
|
|
logoutUser(c, &res)
|
|
|
|
return res
|
|
}
|
|
|
|
return h(c)
|
|
}
|
|
}
|
|
|
|
securityTimerMiddleware := func(duration time.Duration, h Handler) Handler {
|
|
// NOTE(asaf): Will make sure that the request takes at least `delayMs` to finish. Adds a 10% random duration.
|
|
return func(c *RequestContext) ResponseData {
|
|
additionalDuration := time.Duration(rand.Int63n(utils.Int64Max(1, int64(duration)/10)))
|
|
timer := time.NewTimer(duration + additionalDuration)
|
|
res := h(c)
|
|
select {
|
|
case <-longRequestContext.Done():
|
|
case <-c.Context().Done():
|
|
case <-timer.C:
|
|
}
|
|
return res
|
|
}
|
|
}
|
|
|
|
routes.GET(hmnurl.RegexPublic, func(c *RequestContext) ResponseData {
|
|
var res ResponseData
|
|
http.StripPrefix("/public/", http.FileServer(http.Dir("public"))).ServeHTTP(&res, c.Req)
|
|
AddCORSHeaders(c, &res)
|
|
return res
|
|
})
|
|
|
|
// NOTE(asaf): HMN-only routes:
|
|
hmnOnly.GET(hmnurl.RegexManifesto, Manifesto)
|
|
hmnOnly.GET(hmnurl.RegexAbout, About)
|
|
hmnOnly.GET(hmnurl.RegexCodeOfConduct, CodeOfConduct)
|
|
hmnOnly.GET(hmnurl.RegexCommunicationGuidelines, CommunicationGuidelines)
|
|
hmnOnly.GET(hmnurl.RegexContactPage, ContactPage)
|
|
hmnOnly.GET(hmnurl.RegexMonthlyUpdatePolicy, MonthlyUpdatePolicy)
|
|
hmnOnly.GET(hmnurl.RegexProjectSubmissionGuidelines, ProjectSubmissionGuidelines)
|
|
hmnOnly.GET(hmnurl.RegexWhenIsIt, WhenIsIt)
|
|
hmnOnly.GET(hmnurl.RegexJamIndex, JamIndex)
|
|
|
|
hmnOnly.GET(hmnurl.RegexOldHome, Index)
|
|
|
|
hmnOnly.POST(hmnurl.RegexLoginAction, securityTimerMiddleware(time.Millisecond*100, Login))
|
|
hmnOnly.GET(hmnurl.RegexLogoutAction, Logout)
|
|
hmnOnly.GET(hmnurl.RegexLoginPage, LoginPage)
|
|
|
|
hmnOnly.GET(hmnurl.RegexRegister, RegisterNewUser)
|
|
hmnOnly.POST(hmnurl.RegexRegister, securityTimerMiddleware(email.ExpectedEmailSendDuration, RegisterNewUserSubmit))
|
|
hmnOnly.GET(hmnurl.RegexRegistrationSuccess, RegisterNewUserSuccess)
|
|
hmnOnly.GET(hmnurl.RegexEmailConfirmation, EmailConfirmation)
|
|
hmnOnly.POST(hmnurl.RegexEmailConfirmation, EmailConfirmationSubmit)
|
|
|
|
hmnOnly.GET(hmnurl.RegexRequestPasswordReset, RequestPasswordReset)
|
|
hmnOnly.POST(hmnurl.RegexRequestPasswordReset, securityTimerMiddleware(email.ExpectedEmailSendDuration, RequestPasswordResetSubmit))
|
|
hmnOnly.GET(hmnurl.RegexPasswordResetSent, PasswordResetSent)
|
|
hmnOnly.GET(hmnurl.RegexOldDoPasswordReset, DoPasswordReset)
|
|
hmnOnly.GET(hmnurl.RegexDoPasswordReset, DoPasswordReset)
|
|
hmnOnly.POST(hmnurl.RegexDoPasswordReset, DoPasswordResetSubmit)
|
|
|
|
hmnOnly.GET(hmnurl.RegexAdminAtomFeed, AdminAtomFeed)
|
|
hmnOnly.GET(hmnurl.RegexAdminApprovalQueue, adminMiddleware(AdminApprovalQueue))
|
|
hmnOnly.POST(hmnurl.RegexAdminApprovalQueue, adminMiddleware(csrfMiddleware(AdminApprovalQueueSubmit)))
|
|
|
|
hmnOnly.GET(hmnurl.RegexFeed, Feed)
|
|
hmnOnly.GET(hmnurl.RegexAtomFeed, AtomFeed)
|
|
hmnOnly.GET(hmnurl.RegexShowcase, Showcase)
|
|
hmnOnly.GET(hmnurl.RegexSnippet, Snippet)
|
|
hmnOnly.GET(hmnurl.RegexProjectIndex, ProjectIndex)
|
|
|
|
hmnOnly.GET(hmnurl.RegexDiscordOAuthCallback, authMiddleware(DiscordOAuthCallback))
|
|
hmnOnly.POST(hmnurl.RegexDiscordUnlink, authMiddleware(csrfMiddleware(DiscordUnlink)))
|
|
hmnOnly.POST(hmnurl.RegexDiscordShowcaseBacklog, authMiddleware(csrfMiddleware(DiscordShowcaseBacklog)))
|
|
|
|
hmnOnly.GET(hmnurl.RegexUserProfile, UserProfile)
|
|
hmnOnly.GET(hmnurl.RegexUserSettings, authMiddleware(UserSettings))
|
|
hmnOnly.POST(hmnurl.RegexUserSettings, authMiddleware(csrfMiddleware(UserSettingsSave)))
|
|
|
|
hmnOnly.GET(hmnurl.RegexPodcast, PodcastIndex)
|
|
hmnOnly.GET(hmnurl.RegexPodcastEdit, PodcastEdit)
|
|
hmnOnly.POST(hmnurl.RegexPodcastEdit, PodcastEditSubmit)
|
|
hmnOnly.GET(hmnurl.RegexPodcastEpisodeNew, PodcastEpisodeNew)
|
|
hmnOnly.POST(hmnurl.RegexPodcastEpisodeNew, PodcastEpisodeSubmit)
|
|
hmnOnly.GET(hmnurl.RegexPodcastEpisodeEdit, PodcastEpisodeEdit)
|
|
hmnOnly.POST(hmnurl.RegexPodcastEpisodeEdit, PodcastEpisodeSubmit)
|
|
hmnOnly.GET(hmnurl.RegexPodcastEpisode, PodcastEpisode)
|
|
hmnOnly.GET(hmnurl.RegexPodcastRSS, PodcastRSS)
|
|
|
|
hmnOnly.GET(hmnurl.RegexLibraryAny, LibraryNotPortedYet)
|
|
|
|
// NOTE(asaf): Any-project routes:
|
|
attachProjectRoutes := func(rb *RouteBuilder) {
|
|
rb.GET(hmnurl.RegexHomepage, func(c *RequestContext) ResponseData {
|
|
if c.CurrentProject.IsHMN() {
|
|
return Index(c)
|
|
} else {
|
|
return ProjectHomepage(c)
|
|
}
|
|
})
|
|
|
|
rb.POST(hmnurl.RegexForumNewThreadSubmit, authMiddleware(csrfMiddleware(ForumNewThreadSubmit)))
|
|
rb.GET(hmnurl.RegexForumNewThread, authMiddleware(ForumNewThread))
|
|
rb.GET(hmnurl.RegexForumThread, ForumThread)
|
|
rb.GET(hmnurl.RegexForum, Forum)
|
|
rb.POST(hmnurl.RegexForumMarkRead, authMiddleware(csrfMiddleware(ForumMarkRead)))
|
|
rb.GET(hmnurl.RegexForumPost, ForumPostRedirect)
|
|
rb.GET(hmnurl.RegexForumPostReply, authMiddleware(ForumPostReply))
|
|
rb.POST(hmnurl.RegexForumPostReply, authMiddleware(csrfMiddleware(ForumPostReplySubmit)))
|
|
rb.GET(hmnurl.RegexForumPostEdit, authMiddleware(ForumPostEdit))
|
|
rb.POST(hmnurl.RegexForumPostEdit, authMiddleware(csrfMiddleware(ForumPostEditSubmit)))
|
|
rb.GET(hmnurl.RegexForumPostDelete, authMiddleware(ForumPostDelete))
|
|
rb.POST(hmnurl.RegexForumPostDelete, authMiddleware(csrfMiddleware(ForumPostDeleteSubmit)))
|
|
rb.GET(hmnurl.RegexWikiArticle, WikiArticleRedirect)
|
|
|
|
rb.GET(hmnurl.RegexBlog, BlogIndex)
|
|
rb.GET(hmnurl.RegexBlogNewThread, authMiddleware(BlogNewThread))
|
|
rb.POST(hmnurl.RegexBlogNewThread, authMiddleware(csrfMiddleware(BlogNewThreadSubmit)))
|
|
rb.GET(hmnurl.RegexBlogThread, BlogThread)
|
|
rb.GET(hmnurl.RegexBlogPost, BlogPostRedirectToThread)
|
|
rb.GET(hmnurl.RegexBlogPostReply, authMiddleware(BlogPostReply))
|
|
rb.POST(hmnurl.RegexBlogPostReply, authMiddleware(csrfMiddleware(BlogPostReplySubmit)))
|
|
rb.GET(hmnurl.RegexBlogPostEdit, authMiddleware(BlogPostEdit))
|
|
rb.POST(hmnurl.RegexBlogPostEdit, authMiddleware(csrfMiddleware(BlogPostEditSubmit)))
|
|
rb.GET(hmnurl.RegexBlogPostDelete, authMiddleware(BlogPostDelete))
|
|
rb.POST(hmnurl.RegexBlogPostDelete, authMiddleware(csrfMiddleware(BlogPostDeleteSubmit)))
|
|
rb.GET(hmnurl.RegexBlogsRedirect, func(c *RequestContext) ResponseData {
|
|
return c.Redirect(hmnurl.ProjectUrl(
|
|
fmt.Sprintf("blog%s", c.PathParams["remainder"]), nil,
|
|
c.CurrentProject.Slug,
|
|
), http.StatusMovedPermanently)
|
|
})
|
|
}
|
|
hmnOnly.Group(hmnurl.RegexPersonalProject, func(rb *RouteBuilder) {
|
|
// TODO(ben): Perhaps someday we can make this middleware modification feel better? It seems
|
|
// pretty common to run the outermost middleware first before doing other stuff, but having
|
|
// to nest functions this way feels real bad.
|
|
rb.Middleware = func(h Handler) Handler {
|
|
return hmnOnly.Middleware(func(c *RequestContext) ResponseData {
|
|
// At this point we are definitely on the plain old HMN subdomain.
|
|
|
|
// Fetch personal project and do whatever
|
|
id, err := strconv.Atoi(c.PathParams["projectid"])
|
|
if err != nil {
|
|
panic(oops.New(err, "project id was not numeric (bad regex in routing)"))
|
|
}
|
|
p, err := FetchProject(c.Context(), c.Conn, c.CurrentUser, id, ProjectsQuery{})
|
|
if err != nil {
|
|
if errors.Is(err, db.NotFound) {
|
|
return FourOhFour(c)
|
|
} else {
|
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch personal project"))
|
|
}
|
|
}
|
|
|
|
if !p.Project.Personal {
|
|
// TODO: Redirect to the same page on the other prefix
|
|
return c.Redirect(hmnurl.BuildOfficialProjectHomepage(p.Project.Slug), http.StatusSeeOther)
|
|
}
|
|
|
|
if c.PathParams["slug"] != models.GeneratePersonalProjectSlug(p.Project.Name) {
|
|
// TODO: Redirect to the same page on the other path
|
|
return c.Redirect(hmnurl.BuildPersonalProject(p.Project.ID, models.GeneratePersonalProjectSlug(p.Project.Name)), http.StatusSeeOther)
|
|
}
|
|
|
|
c.CurrentProject = &p.Project
|
|
|
|
return h(c)
|
|
})
|
|
}
|
|
attachProjectRoutes(rb)
|
|
})
|
|
anyProject.Group(hmnurl.RegexHomepage, func(rb *RouteBuilder) {
|
|
rb.Middleware = func(h Handler) Handler {
|
|
return anyProject.Middleware(func(c *RequestContext) ResponseData {
|
|
// We could be on any project's subdomain.
|
|
|
|
// Check if the current project (matched by subdomain) is actually no longer official
|
|
// and therefore needs to be redirected to the personal project version of the route.
|
|
if c.CurrentProject.Personal {
|
|
// TODO: Redirect to the same page on the other prefix
|
|
return c.Redirect(hmnurl.BuildPersonalProject(c.CurrentProject.ID, c.CurrentProject.Slug), http.StatusSeeOther)
|
|
}
|
|
|
|
return h(c)
|
|
})
|
|
}
|
|
attachProjectRoutes(rb)
|
|
})
|
|
|
|
anyProject.POST(hmnurl.RegexAssetUpload, AssetUpload)
|
|
|
|
anyProject.GET(hmnurl.RegexEpisodeList, EpisodeList)
|
|
anyProject.GET(hmnurl.RegexEpisode, Episode)
|
|
anyProject.GET(hmnurl.RegexCineraIndex, CineraIndex)
|
|
|
|
anyProject.GET(hmnurl.RegexProjectCSS, ProjectCSS)
|
|
anyProject.GET(hmnurl.RegexEditorPreviewsJS, func(c *RequestContext) ResponseData {
|
|
var res ResponseData
|
|
res.MustWriteTemplate("editorpreviews.js", nil, c.Perf)
|
|
res.Header().Add("Content-Type", "application/javascript")
|
|
return res
|
|
})
|
|
|
|
// Other
|
|
anyProject.AnyMethod(hmnurl.RegexCatchAll, FourOhFour)
|
|
|
|
return router
|
|
}
|
|
|
|
func ProjectCSS(c *RequestContext) ResponseData {
|
|
color := c.URL().Query().Get("color")
|
|
if color == "" {
|
|
return c.ErrorResponse(http.StatusBadRequest, NewSafeError(nil, "You must provide a 'color' parameter.\n"))
|
|
}
|
|
|
|
baseData := getBaseData(c, "", nil)
|
|
|
|
bgColor := noire.NewHex(color)
|
|
h, s, l := bgColor.HSL()
|
|
if baseData.Theme == "dark" {
|
|
l = 15
|
|
} else {
|
|
l = 95
|
|
}
|
|
if s > 20 {
|
|
s = 20
|
|
}
|
|
bgColor = noire.NewHSL(h, s, l)
|
|
|
|
templateData := struct {
|
|
templates.BaseData
|
|
Color string
|
|
PostBgColor string
|
|
}{
|
|
BaseData: baseData,
|
|
Color: color,
|
|
PostBgColor: bgColor.HTML(),
|
|
}
|
|
|
|
var res ResponseData
|
|
res.Header().Add("Content-Type", "text/css")
|
|
err := res.WriteTemplate("project.css", templateData, c.Perf)
|
|
if err != nil {
|
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to generate project CSS"))
|
|
}
|
|
|
|
return res
|
|
}
|
|
|
|
func FourOhFour(c *RequestContext) ResponseData {
|
|
var res ResponseData
|
|
res.StatusCode = http.StatusNotFound
|
|
|
|
if c.Req.Header["Accept"] != nil && strings.Contains(c.Req.Header["Accept"][0], "text/html") {
|
|
templateData := struct {
|
|
templates.BaseData
|
|
Wanted string
|
|
}{
|
|
BaseData: getBaseData(c, "Page not found", nil),
|
|
Wanted: c.FullUrl(),
|
|
}
|
|
res.MustWriteTemplate("404.html", templateData, c.Perf)
|
|
} else {
|
|
res.Write([]byte("Not Found"))
|
|
}
|
|
return res
|
|
}
|
|
|
|
type RejectData struct {
|
|
templates.BaseData
|
|
RejectReason string
|
|
}
|
|
|
|
func RejectRequest(c *RequestContext, reason string) ResponseData {
|
|
var res ResponseData
|
|
err := res.WriteTemplate("reject.html", RejectData{
|
|
BaseData: getBaseData(c, "Rejected", nil),
|
|
RejectReason: reason,
|
|
}, c.Perf)
|
|
if err != nil {
|
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to render reject template"))
|
|
}
|
|
return res
|
|
}
|
|
|
|
func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) {
|
|
c.Perf.StartBlock("MIDDLEWARE", "Load common website data")
|
|
defer c.Perf.EndBlock()
|
|
|
|
// get user
|
|
{
|
|
sessionCookie, err := c.Req.Cookie(auth.SessionCookieName)
|
|
if err == nil {
|
|
user, session, err := getCurrentUserAndSession(c, sessionCookie.Value)
|
|
if err != nil {
|
|
return false, c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get current user"))
|
|
}
|
|
|
|
c.CurrentUser = user
|
|
c.CurrentSession = session
|
|
}
|
|
// http.ErrNoCookie is the only error Cookie ever returns, so no further handling to do here.
|
|
}
|
|
|
|
// get official project
|
|
{
|
|
hostPrefix := strings.TrimSuffix(c.Req.Host, hmnurl.GetBaseHost())
|
|
slug := strings.TrimRight(hostPrefix, ".")
|
|
|
|
dbProject, err := FetchProjectBySlug(c.Context(), c.Conn, c.CurrentUser, slug, ProjectsQuery{})
|
|
if err == nil {
|
|
c.CurrentProject = &dbProject.Project
|
|
} else {
|
|
if errors.Is(err, db.NotFound) {
|
|
// do nothing, this is fine
|
|
} else {
|
|
return false, c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch current project"))
|
|
}
|
|
}
|
|
|
|
if c.CurrentProject == nil {
|
|
dbProject, err := FetchProject(c.Context(), c.Conn, c.CurrentUser, models.HMNProjectID, ProjectsQuery{
|
|
IncludeHidden: true,
|
|
})
|
|
if err != nil {
|
|
panic(oops.New(err, "failed to fetch HMN project"))
|
|
}
|
|
c.CurrentProject = &dbProject.Project
|
|
}
|
|
|
|
if c.CurrentProject == nil {
|
|
panic("failed to load project data")
|
|
}
|
|
}
|
|
|
|
theme := "light"
|
|
if c.CurrentUser != nil && c.CurrentUser.DarkTheme {
|
|
theme = "dark"
|
|
}
|
|
|
|
c.Theme = theme
|
|
|
|
return true, ResponseData{}
|
|
}
|
|
|
|
func AddCORSHeaders(c *RequestContext, res *ResponseData) {
|
|
parsed, err := url.Parse(config.Config.BaseUrl)
|
|
if err != nil {
|
|
c.Logger.Error().Str("Config.BaseUrl", config.Config.BaseUrl).Msg("Config.BaseUrl cannot be parsed. Skipping CORS headers")
|
|
return
|
|
}
|
|
origin := ""
|
|
origins, found := c.Req.Header["Origin"]
|
|
if found {
|
|
origin = origins[0]
|
|
}
|
|
if strings.HasSuffix(origin, parsed.Host) {
|
|
res.Header().Add("Access-Control-Allow-Origin", origin)
|
|
res.Header().Add("Vary", "Origin")
|
|
}
|
|
}
|
|
|
|
// Given a session id, fetches user data from the database. Will return nil if
|
|
// the user cannot be found, and will only return an error if it's serious.
|
|
func getCurrentUserAndSession(c *RequestContext, sessionId string) (*models.User, *models.Session, error) {
|
|
session, err := auth.GetSession(c.Context(), c.Conn, sessionId)
|
|
if err != nil {
|
|
if errors.Is(err, auth.ErrNoSession) {
|
|
return nil, nil, nil
|
|
} else {
|
|
return nil, nil, oops.New(err, "failed to get current session")
|
|
}
|
|
}
|
|
|
|
userRow, err := db.QueryOne(c.Context(), c.Conn, models.User{}, "SELECT $columns FROM auth_user WHERE username = $1", session.Username)
|
|
if err != nil {
|
|
if errors.Is(err, db.NotFound) {
|
|
logging.Debug().Str("username", session.Username).Msg("returning no current user for this request because the user for the session couldn't be found")
|
|
return nil, nil, nil // user was deleted or something
|
|
} else {
|
|
return nil, nil, oops.New(err, "failed to get user for session")
|
|
}
|
|
}
|
|
user := userRow.(*models.User)
|
|
|
|
return user, session, nil
|
|
}
|
|
|
|
const PerfContextKey = "HMNPerf"
|
|
|
|
func TrackRequestPerf(c *RequestContext, perfCollector *perf.PerfCollector) (after func()) {
|
|
c.Perf = perf.MakeNewRequestPerf(c.Route, c.Req.Method, c.Req.URL.Path)
|
|
c.ctx = context.WithValue(c.Context(), PerfContextKey, c.Perf)
|
|
|
|
return func() {
|
|
c.Perf.EndRequest()
|
|
log := logging.Info()
|
|
blockStack := make([]time.Time, 0)
|
|
for i, block := range c.Perf.Blocks {
|
|
for len(blockStack) > 0 && block.End.After(blockStack[len(blockStack)-1]) {
|
|
blockStack = blockStack[:len(blockStack)-1]
|
|
}
|
|
log.Str(fmt.Sprintf("[%4.d] At %9.2fms", i, c.Perf.MsFromStart(&block)), fmt.Sprintf("%*.s[%s] %s (%.4fms)", len(blockStack)*2, "", block.Category, block.Description, block.DurationMs()))
|
|
blockStack = append(blockStack, block.End)
|
|
}
|
|
log.Msg(fmt.Sprintf("Served [%s] %s in %.4fms", c.Perf.Method, c.Perf.Path, float64(c.Perf.End.Sub(c.Perf.Start).Nanoseconds())/1000/1000))
|
|
// perfCollector.SubmitRun(c.Perf) // TODO(asaf): Implement a use for this
|
|
}
|
|
}
|
|
|
|
func ExtractPerf(ctx context.Context) *perf.RequestPerf {
|
|
iperf := ctx.Value(PerfContextKey)
|
|
if iperf == nil {
|
|
return nil
|
|
}
|
|
return iperf.(*perf.RequestPerf)
|
|
}
|
|
|
|
func LogContextErrors(c *RequestContext, errs ...error) {
|
|
for _, err := range errs {
|
|
c.Logger.Error().Timestamp().Stack().Str("Requested", c.FullUrl()).Err(err).Msg("error occurred during request")
|
|
}
|
|
}
|
|
|
|
func LogContextErrorsFromResponse(c *RequestContext, res *ResponseData) {
|
|
LogContextErrors(c, res.Errors...)
|
|
}
|
|
|
|
func MiddlewarePanicCatcher(c *RequestContext, res *ResponseData) {
|
|
if recovered := recover(); recovered != nil {
|
|
maybeError, ok := recovered.(*error)
|
|
var err error
|
|
if ok {
|
|
err = *maybeError
|
|
} else {
|
|
err = oops.New(nil, fmt.Sprintf("Recovered from panic with value: %v", recovered))
|
|
}
|
|
*res = c.ErrorResponse(http.StatusInternalServerError, err)
|
|
}
|
|
}
|
|
|
|
const NoticesCookieName = "hmn_notices"
|
|
|
|
func getNoticesFromCookie(c *RequestContext) []templates.Notice {
|
|
cookie, err := c.Req.Cookie(NoticesCookieName)
|
|
if err != nil {
|
|
if !errors.Is(err, http.ErrNoCookie) {
|
|
c.Logger.Warn().Err(err).Msg("failed to get notices cookie")
|
|
}
|
|
return nil
|
|
}
|
|
return deserializeNoticesFromCookie(cookie.Value)
|
|
}
|
|
|
|
func storeNoticesInCookie(c *RequestContext, res *ResponseData) {
|
|
serialized := serializeNoticesForCookie(c, res.FutureNotices)
|
|
if serialized != "" {
|
|
noticesCookie := http.Cookie{
|
|
Name: NoticesCookieName,
|
|
Value: serialized,
|
|
Path: "/",
|
|
Domain: config.Config.Auth.CookieDomain,
|
|
Expires: time.Now().Add(time.Minute * 5),
|
|
Secure: config.Config.Auth.CookieSecure,
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
}
|
|
res.SetCookie(¬icesCookie)
|
|
} else if !(res.StatusCode >= 300 && res.StatusCode < 400) {
|
|
// NOTE(asaf): Don't clear on redirect
|
|
noticesCookie := http.Cookie{
|
|
Name: NoticesCookieName,
|
|
Path: "/",
|
|
Domain: config.Config.Auth.CookieDomain,
|
|
MaxAge: -1,
|
|
}
|
|
res.SetCookie(¬icesCookie)
|
|
}
|
|
}
|
|
|
|
func serializeNoticesForCookie(c *RequestContext, notices []templates.Notice) string {
|
|
var builder strings.Builder
|
|
maxSize := 1024 // NOTE(asaf): Make sure we don't use too much space for notices.
|
|
size := 0
|
|
for i, notice := range notices {
|
|
sizeIncrease := len(notice.Class) + len(string(notice.Content)) + 1
|
|
if i != 0 {
|
|
sizeIncrease += 1
|
|
}
|
|
if size+sizeIncrease > maxSize {
|
|
c.Logger.Warn().Interface("Notices", notices).Msg("Notices too big for cookie")
|
|
break
|
|
}
|
|
|
|
if i != 0 {
|
|
builder.WriteString("\t")
|
|
}
|
|
builder.WriteString(notice.Class)
|
|
builder.WriteString("|")
|
|
builder.WriteString(string(notice.Content))
|
|
|
|
size += sizeIncrease
|
|
}
|
|
return builder.String()
|
|
}
|
|
|
|
func deserializeNoticesFromCookie(cookieVal string) []templates.Notice {
|
|
var result []templates.Notice
|
|
notices := strings.Split(cookieVal, "\t")
|
|
for _, notice := range notices {
|
|
parts := strings.SplitN(notice, "|", 2)
|
|
if len(parts) == 2 {
|
|
result = append(result, templates.Notice{
|
|
Class: parts[0],
|
|
Content: template.HTML(parts[1]),
|
|
})
|
|
}
|
|
}
|
|
return result
|
|
}
|