302 lines
12 KiB
Go
302 lines
12 KiB
Go
package website
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"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/models"
|
|
"git.handmade.network/hmn/hmn/src/oops"
|
|
"git.handmade.network/hmn/hmn/src/utils"
|
|
"github.com/jackc/pgx/v4/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.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
|
|
})
|
|
routes.GET(hmnurl.RegexFishbowlFiles, FishbowlFiles)
|
|
|
|
// NOTE(asaf): HMN-only routes:
|
|
hmnOnly.GET(hmnurl.RegexManifesto, Manifesto)
|
|
hmnOnly.GET(hmnurl.RegexAbout, About)
|
|
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.RegexWhenIsIt, WhenIsIt)
|
|
hmnOnly.GET(hmnurl.RegexJamIndex, JamIndex2022)
|
|
hmnOnly.GET(hmnurl.RegexJamIndex2021, JamIndex2021)
|
|
hmnOnly.GET(hmnurl.RegexJamIndex2022, JamIndex2022)
|
|
hmnOnly.GET(hmnurl.RegexJamFeed2022, JamFeed2022)
|
|
|
|
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, 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)
|
|
hmnOnly.GET(hmnurl.RegexAtomFeed, AtomFeed)
|
|
hmnOnly.GET(hmnurl.RegexShowcase, Showcase)
|
|
hmnOnly.GET(hmnurl.RegexSnippet, Snippet)
|
|
hmnOnly.GET(hmnurl.RegexProjectIndex, ProjectIndex)
|
|
|
|
hmnOnly.GET(hmnurl.RegexProjectNew, needsAuth(ProjectNew))
|
|
hmnOnly.POST(hmnurl.RegexProjectNew, needsAuth(csrfMiddleware(ProjectNewSubmit)))
|
|
|
|
hmnOnly.GET(hmnurl.RegexDiscordOAuthCallback, needsAuth(DiscordOAuthCallback))
|
|
hmnOnly.POST(hmnurl.RegexDiscordUnlink, needsAuth(csrfMiddleware(DiscordUnlink)))
|
|
hmnOnly.POST(hmnurl.RegexDiscordShowcaseBacklog, needsAuth(csrfMiddleware(DiscordShowcaseBacklog)))
|
|
|
|
hmnOnly.POST(hmnurl.RegexTwitchEventSubCallback, TwitchEventSubCallback)
|
|
hmnOnly.GET(hmnurl.RegexTwitchDebugPage, 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)
|
|
|
|
educationPrerelease := hmnOnly.WithMiddleware(educationBetaTestersOnly)
|
|
{
|
|
educationPrerelease.GET(hmnurl.RegexEducationIndex, EducationIndex)
|
|
educationPrerelease.GET(hmnurl.RegexEducationGlossary, EducationGlossary)
|
|
educationPrerelease.GET(hmnurl.RegexEducationArticleNew, educationAuthorsOnly(EducationArticleNew))
|
|
educationPrerelease.POST(hmnurl.RegexEducationArticleNew, educationAuthorsOnly(EducationArticleNewSubmit))
|
|
educationPrerelease.GET(hmnurl.RegexEducationRerender, educationAuthorsOnly(EducationRerender))
|
|
educationPrerelease.GET(hmnurl.RegexEducationArticle, EducationArticle) // Article stuff must be last so `/glossary` and others do not match as an article slug
|
|
educationPrerelease.GET(hmnurl.RegexEducationArticleEdit, educationAuthorsOnly(EducationArticleEdit))
|
|
educationPrerelease.POST(hmnurl.RegexEducationArticleEdit, educationAuthorsOnly(EducationArticleEditSubmit))
|
|
educationPrerelease.GET(hmnurl.RegexEducationArticleDelete, educationAuthorsOnly(EducationArticleDelete))
|
|
educationPrerelease.POST(hmnurl.RegexEducationArticleDelete, educationAuthorsOnly(csrfMiddleware(EducationArticleDeleteSubmit)))
|
|
}
|
|
|
|
hmnOnly.POST(hmnurl.RegexAPICheckUsername, csrfMiddleware(APICheckUsername))
|
|
|
|
hmnOnly.GET(hmnurl.RegexLibraryAny, LibraryNotPortedYet)
|
|
// hmnOnly.GET(hmnurl.RegexLibraryAny, func(c *RequestContext) ResponseData {
|
|
// return c.Redirect(hmnurl.BuildEducationIndex(), 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.RegexProjectCSS, ProjectCSS)
|
|
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)
|
|
}
|
|
}
|