hmn/src/website/routes.go

384 lines
15 KiB
Go

package website
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"net/http/httputil"
"strconv"
"strings"
"time"
"git.handmade.network/hmn/hmn/src/buildcss"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/email"
"git.handmade.network/hmn/hmn/src/hmndata"
"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/utils"
"github.com/jackc/pgx/v5/pgxpool"
)
func NewWebsiteRoutes(conn *pgxpool.Pool) http.Handler {
router := &Router{}
routes := RouteBuilder{
Router: router,
Middlewares: []Middleware{
setDBConn(conn),
trackRequestPerf,
logContextErrorsMiddleware,
panicCatcherMiddleware,
},
}
anyProject := routes.WithMiddleware(
storeNoticesInCookieMiddleware,
loadCommonData,
)
hmnOnly := anyProject.WithMiddleware(
redirectToHMN,
)
routes.GET(hmnurl.RegexEsBuild, func(c *RequestContext) ResponseData {
if buildcss.ActiveServerPort != 0 {
var res ResponseData
proxy := httputil.ReverseProxy{
Director: func(r *http.Request) {
r.URL.Scheme = "http"
r.URL.Host = fmt.Sprintf("localhost:%d", buildcss.ActiveServerPort)
r.Host = "localhost"
},
}
logging.Debug().Msg("Redirecting esbuild SSE request to esbuild")
proxy.ServeHTTP(c.Res, c.Req)
res.hijacked = true
return res
}
return FourOhFour(c)
})
routes.GET(hmnurl.RegexPublic, func(c *RequestContext) ResponseData {
var res ResponseData
if buildcss.ActiveServerPort != 0 {
if strings.HasSuffix(c.Req.URL.Path, ".css") {
proxy := httputil.ReverseProxy{
Director: func(r *http.Request) {
r.URL.Scheme = "http"
r.URL.Host = fmt.Sprintf("localhost:%d", buildcss.ActiveServerPort)
r.Host = "localhost"
},
ModifyResponse: func(res *http.Response) error {
if res.StatusCode > 400 {
errStr, err := io.ReadAll(res.Body)
if err != nil {
return err
}
res.Body.Close()
logging.Error().Str("EsBuild error", string(errStr)).Msg("EsBuild is complaining")
res.Body = io.NopCloser(bytes.NewReader(errStr))
}
return nil
},
}
logging.Debug().Msg("Redirecting css request to esbuild")
proxy.ServeHTTP(c.Res, c.Req)
res.hijacked = true
return res
}
}
http.StripPrefix("/public/", http.FileServer(http.Dir("public"))).ServeHTTP(&res, c.Req)
addCORSHeaders(c, &res)
return res
})
routes.GET(hmnurl.RegexFishbowlFiles, FishbowlFiles)
// NOTE(asaf): HMN-only routes:
hmnOnly.GET(hmnurl.RegexManifesto, Manifesto)
hmnOnly.GET(hmnurl.RegexAbout, About)
// hmnOnly.GET(hmnurl.RegexFoundation, Foundation)
hmnOnly.GET(hmnurl.RegexCommunicationGuidelines, CommunicationGuidelines)
hmnOnly.GET(hmnurl.RegexContactPage, ContactPage)
hmnOnly.GET(hmnurl.RegexMonthlyUpdatePolicy, MonthlyUpdatePolicy)
hmnOnly.GET(hmnurl.RegexProjectSubmissionGuidelines, ProjectSubmissionGuidelines)
hmnOnly.GET(hmnurl.RegexConferences, Conferences)
hmnOnly.GET(hmnurl.RegexJamsIndex, JamsIndex)
hmnOnly.GET(hmnurl.RegexJamIndex, func(c *RequestContext) ResponseData {
return c.Redirect(hmnurl.BuildJamIndex2024_Visibility(), http.StatusFound)
})
hmnOnly.GET(hmnurl.RegexJamIndex2021, JamIndex2021)
hmnOnly.GET(hmnurl.RegexJamIndex2022, JamIndex2022)
hmnOnly.GET(hmnurl.RegexJamFeed2022, JamFeed2022)
hmnOnly.GET(hmnurl.RegexJamIndex2023_Visibility, JamIndex2023_Visibility)
hmnOnly.GET(hmnurl.RegexJamFeed2023_Visibility, JamFeed2023_Visibility)
hmnOnly.GET(hmnurl.RegexJamRecap2023_Visibility, JamRecap2023_Visibility)
hmnOnly.GET(hmnurl.RegexJamIndex2023, JamIndex2023)
hmnOnly.GET(hmnurl.RegexJamFeed2023, JamFeed2023)
hmnOnly.GET(hmnurl.RegexJamIndex2024_Learning, JamIndex2024_Learning)
hmnOnly.GET(hmnurl.RegexJamFeed2024_Learning, JamFeed2024_Learning)
hmnOnly.GET(hmnurl.RegexJamGuidelines2024_Learning, JamGuidelines2024_Learning)
hmnOnly.GET(hmnurl.RegexJamIndex2024_Visibility, JamIndex2024_Visibility)
hmnOnly.GET(hmnurl.RegexJamSaveTheDate, JamSaveTheDate)
hmnOnly.GET(hmnurl.RegexTimeMachine, TimeMachine)
hmnOnly.GET(hmnurl.RegexTimeMachineSubmissions, TimeMachineSubmissions)
hmnOnly.GET(hmnurl.RegexTimeMachineAtomFeed, TimeMachineAtomFeed)
hmnOnly.GET(hmnurl.RegexTimeMachineForm, needsAuth(TimeMachineForm))
hmnOnly.GET(hmnurl.RegexTimeMachineFormDone, needsAuth(TimeMachineFormDone))
hmnOnly.POST(hmnurl.RegexTimeMachineForm, needsAuth(csrfMiddleware(TimeMachineFormSubmit)))
hmnOnly.GET(hmnurl.RegexCalendarIndex, CalendarIndex)
hmnOnly.GET(hmnurl.RegexCalendarICal, CalendarICal)
hmnOnly.GET(hmnurl.RegexOldHome, Index)
hmnOnly.POST(hmnurl.RegexLoginAction, securityTimerMiddleware(time.Millisecond*100, Login))
hmnOnly.GET(hmnurl.RegexLogout, Logout)
hmnOnly.GET(hmnurl.RegexLoginPage, LoginPage)
hmnOnly.GET(hmnurl.RegexLoginWithDiscord, LoginWithDiscord)
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, adminsOnly(AdminApprovalQueue))
hmnOnly.POST(hmnurl.RegexAdminApprovalQueue, adminsOnly(csrfMiddleware(AdminApprovalQueueSubmit)))
hmnOnly.POST(hmnurl.RegexAdminSetUserOptions, adminsOnly(csrfMiddleware(UserProfileAdminSetOptions)))
hmnOnly.POST(hmnurl.RegexAdminNukeUser, adminsOnly(csrfMiddleware(UserProfileAdminNuke)))
hmnOnly.GET(hmnurl.RegexFeed, Feed) // TODO: Remove / rework this page
hmnOnly.GET(hmnurl.RegexAtomFeed, AtomFeed)
hmnOnly.GET(hmnurl.RegexSnippet, Snippet)
hmnOnly.GET(hmnurl.RegexProjectIndex, ProjectIndex)
hmnOnly.GET(hmnurl.RegexFollowingTest, needsAuth(FollowingTest))
hmnOnly.POST(hmnurl.RegexFollowUser, needsAuth(csrfMiddleware(FollowUser)))
hmnOnly.POST(hmnurl.RegexFollowProject, needsAuth(csrfMiddleware(FollowProject)))
hmnOnly.GET(hmnurl.RegexProjectNew, needsAuth(ProjectNew))
hmnOnly.POST(hmnurl.RegexProjectNew, needsAuth(csrfMiddleware(ProjectNewSubmit)))
hmnOnly.GET(hmnurl.RegexDiscordOAuthCallback, DiscordOAuthCallback)
hmnOnly.POST(hmnurl.RegexDiscordUnlink, needsAuth(csrfMiddleware(DiscordUnlink)))
hmnOnly.POST(hmnurl.RegexDiscordShowcaseBacklog, needsAuth(csrfMiddleware(DiscordShowcaseBacklog)))
hmnOnly.GET(hmnurl.RegexDiscordBotDebugPage, adminsOnly(DiscordBotDebugPage))
hmnOnly.POST(hmnurl.RegexTwitchEventSubCallback, TwitchEventSubCallback)
hmnOnly.GET(hmnurl.RegexTwitchDebugPage, adminsOnly(TwitchDebugPage))
hmnOnly.GET(hmnurl.RegexUserProfile, UserProfile)
hmnOnly.GET(hmnurl.RegexUserSettings, needsAuth(UserSettings))
hmnOnly.POST(hmnurl.RegexUserSettings, needsAuth(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.RegexFishbowlIndex, FishbowlIndex)
hmnOnly.GET(hmnurl.RegexFishbowl, Fishbowl)
hmnOnly.GET(hmnurl.RegexEducationIndex, EducationIndex)
hmnOnly.GET(hmnurl.RegexEducationGlossary, educationAuthorsOnly(EducationGlossary)) // TODO: Remove this gate
hmnOnly.GET(hmnurl.RegexEducationArticleNew, educationAuthorsOnly(EducationArticleNew))
hmnOnly.POST(hmnurl.RegexEducationArticleNew, educationAuthorsOnly(EducationArticleNewSubmit))
hmnOnly.GET(hmnurl.RegexEducationRerender, educationAuthorsOnly(EducationRerender))
hmnOnly.GET(hmnurl.RegexEducationArticle, EducationArticle) // Article stuff must be last so `/glossary` and others do not match as an article slug
hmnOnly.GET(hmnurl.RegexEducationArticleEdit, educationAuthorsOnly(EducationArticleEdit))
hmnOnly.POST(hmnurl.RegexEducationArticleEdit, educationAuthorsOnly(EducationArticleEditSubmit))
hmnOnly.GET(hmnurl.RegexEducationArticleDelete, educationAuthorsOnly(EducationArticleDelete))
hmnOnly.POST(hmnurl.RegexEducationArticleDelete, educationAuthorsOnly(csrfMiddleware(EducationArticleDeleteSubmit)))
hmnOnly.GET(hmnurl.RegexStyleTest, StyleTest)
hmnOnly.POST(hmnurl.RegexAPICheckUsername, csrfMiddleware(APICheckUsername))
hmnOnly.POST(hmnurl.RegexAPINewsletterSignup, APINewsletterSignup)
hmnOnly.GET(hmnurl.RegexLibraryAny, func(c *RequestContext) ResponseData {
return c.Redirect(hmnurl.BuildEducationIndex(), http.StatusFound)
})
hmnOnly.GET(hmnurl.RegexUnwind, func(c *RequestContext) ResponseData {
return c.Redirect("https://www.youtube.com/playlist?list=PL-IPpPzBYXBGsAd9-c2__x6LJG4Zszs0T", http.StatusFound)
})
// Project routes can appear either at the root (e.g. hero.handmade.network/edit)
// or on a personal project path (e.g. handmade.network/p/123/hero/edit). So, we
// have pulled all those routes into this function.
attachProjectRoutes := func(rb *RouteBuilder) {
rb.GET(hmnurl.RegexHomepage, func(c *RequestContext) ResponseData {
if c.CurrentProject.IsHMN() {
return Index(c)
} else {
return ProjectHomepage(c)
}
})
rb.GET(hmnurl.RegexProjectEdit, needsAuth(ProjectEdit))
rb.POST(hmnurl.RegexProjectEdit, needsAuth(csrfMiddleware(ProjectEditSubmit)))
// Middleware used for forum action routes - anything related to actually creating or editing forum content
needsForums := func(h Handler) Handler {
return func(c *RequestContext) ResponseData {
// 404 if the project has forums disabled
if !c.CurrentProject.HasForums() {
return FourOhFour(c)
}
// Require auth if forums are enabled
return needsAuth(h)(c)
}
}
rb.POST(hmnurl.RegexForumNewThreadSubmit, needsForums(csrfMiddleware(ForumNewThreadSubmit)))
rb.GET(hmnurl.RegexForumNewThread, needsForums(ForumNewThread))
rb.GET(hmnurl.RegexForumThread, ForumThread)
rb.GET(hmnurl.RegexForum, Forum)
rb.POST(hmnurl.RegexForumMarkRead, needsAuth(csrfMiddleware(ForumMarkRead))) // needs auth but doesn't need forums enabled
rb.GET(hmnurl.RegexForumPost, ForumPostRedirect)
rb.GET(hmnurl.RegexForumPostReply, needsForums(ForumPostReply))
rb.POST(hmnurl.RegexForumPostReply, needsForums(csrfMiddleware(ForumPostReplySubmit)))
rb.GET(hmnurl.RegexForumPostEdit, needsForums(ForumPostEdit))
rb.POST(hmnurl.RegexForumPostEdit, needsForums(csrfMiddleware(ForumPostEditSubmit)))
rb.GET(hmnurl.RegexForumPostDelete, needsForums(ForumPostDelete))
rb.POST(hmnurl.RegexForumPostDelete, needsForums(csrfMiddleware(ForumPostDeleteSubmit)))
rb.GET(hmnurl.RegexWikiArticle, WikiArticleRedirect)
// Middleware used for blog action routes - anything related to actually creating or editing blog content
needsBlogs := func(h Handler) Handler {
return func(c *RequestContext) ResponseData {
// 404 if the project has blogs disabled
if !c.CurrentProject.HasBlog() {
return FourOhFour(c)
}
// Require auth if blogs are enabled
return needsAuth(h)(c)
}
}
rb.GET(hmnurl.RegexBlog, BlogIndex)
rb.GET(hmnurl.RegexBlogNewThread, needsBlogs(BlogNewThread))
rb.POST(hmnurl.RegexBlogNewThread, needsBlogs(csrfMiddleware(BlogNewThreadSubmit)))
rb.GET(hmnurl.RegexBlogThread, BlogThread)
rb.GET(hmnurl.RegexBlogPost, BlogPostRedirectToThread)
rb.GET(hmnurl.RegexBlogPostReply, needsBlogs(BlogPostReply))
rb.POST(hmnurl.RegexBlogPostReply, needsBlogs(csrfMiddleware(BlogPostReplySubmit)))
rb.GET(hmnurl.RegexBlogPostEdit, needsBlogs(BlogPostEdit))
rb.POST(hmnurl.RegexBlogPostEdit, needsBlogs(csrfMiddleware(BlogPostEditSubmit)))
rb.GET(hmnurl.RegexBlogPostDelete, needsBlogs(BlogPostDelete))
rb.POST(hmnurl.RegexBlogPostDelete, needsBlogs(csrfMiddleware(BlogPostDeleteSubmit)))
rb.GET(hmnurl.RegexBlogsRedirect, func(c *RequestContext) ResponseData {
return c.Redirect(c.UrlContext.Url(
fmt.Sprintf("blog%s", c.PathParams["remainder"]), nil,
), http.StatusMovedPermanently)
})
rb.POST(hmnurl.RegexAssetUpload, AssetUpload)
}
officialProjectRoutes := anyProject.WithMiddleware(officialProjectMiddleware)
personalProjectRoutes := hmnOnly.Group(hmnurl.RegexPersonalProject, personalProjectMiddleware)
attachProjectRoutes(&officialProjectRoutes)
attachProjectRoutes(&personalProjectRoutes)
anyProject.POST(hmnurl.RegexSnippetSubmit, needsAuth(csrfMiddleware(SnippetEditSubmit)))
anyProject.GET(hmnurl.RegexEpisodeList, EpisodeList)
anyProject.GET(hmnurl.RegexEpisode, Episode)
anyProject.GET(hmnurl.RegexCineraIndex, CineraIndex)
anyProject.GET(hmnurl.RegexMarkdownWorkerJS, func(c *RequestContext) ResponseData {
var res ResponseData
res.MustWriteTemplate("markdown_worker.js", nil, c.Perf)
res.Header().Add("Content-Type", "application/javascript")
return res
})
// Other
anyProject.AnyMethod(hmnurl.RegexCatchAll, FourOhFour)
return router
}
func setDBConn(conn *pgxpool.Pool) Middleware {
return func(h Handler) Handler {
return func(c *RequestContext) ResponseData {
c.Conn = conn
return h(c)
}
}
}
func redirectToHMN(h Handler) Handler {
return func(c *RequestContext) ResponseData {
if !c.CurrentProject.IsHMN() {
return c.Redirect(hmnurl.Url(c.URL().Path, hmnurl.QFromURL(c.URL())), http.StatusMovedPermanently)
}
return h(c)
}
}
func officialProjectMiddleware(h Handler) Handler {
return func(c *RequestContext) ResponseData {
// 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 {
return c.Redirect(c.UrlContext.RewriteProjectUrl(c.URL()), http.StatusSeeOther)
}
return h(c)
}
}
func personalProjectMiddleware(h Handler) Handler {
return func(c *RequestContext) ResponseData {
hmnProject := c.CurrentProject
id := utils.Must1(strconv.Atoi(c.PathParams["projectid"]))
p, err := hmndata.FetchProject(c, c.Conn, c.CurrentUser, id, hmndata.ProjectsQuery{
Lifecycles: models.AllProjectLifecycles,
IncludeHidden: true,
})
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"))
}
}
c.CurrentProject = &p.Project
c.CurrentProject.Color1 = hmnProject.Color1
c.CurrentProject.Color2 = hmnProject.Color2
c.UrlContext = hmndata.UrlContextForProject(c.CurrentProject)
c.CurrentUserCanEditCurrentProject = CanEditProject(c.CurrentUser, p.Owners)
if !c.CurrentProject.Personal {
return c.Redirect(c.UrlContext.RewriteProjectUrl(c.URL()), http.StatusSeeOther)
}
if c.PathParams["projectslug"] != models.GeneratePersonalProjectSlug(c.CurrentProject.Name) {
return c.Redirect(c.UrlContext.RewriteProjectUrl(c.URL()), http.StatusSeeOther)
}
return h(c)
}
}