From ff901e4fb86a19968ca1f74ae2ad6e7a8eabfbb7 Mon Sep 17 00:00:00 2001 From: Ben Visness Date: Tue, 9 Nov 2021 11:14:38 -0800 Subject: [PATCH] Add route grouping stuff for projects (needs thorough testing) --- local/resetdb.sh | 7 +- src/hmnurl/urls.go | 10 +- src/templates/mapping.go | 2 +- src/website/base_data.go | 4 +- src/website/blogs.go | 2 +- src/website/breadcrumb_helper.go | 2 +- src/website/episode_guide.go | 4 +- src/website/post_helper.go | 8 +- src/website/project_helper.go | 12 ++- src/website/projects.go | 70 ++++---------- src/website/requesthandling.go | 72 ++++++++++----- src/website/routes.go | 152 ++++++++++++++++++++++--------- 12 files changed, 204 insertions(+), 141 deletions(-) diff --git a/local/resetdb.sh b/local/resetdb.sh index 1cc0399..ba5af70 100755 --- a/local/resetdb.sh +++ b/local/resetdb.sh @@ -9,8 +9,8 @@ set -euxo pipefail # TODO(opensource): We should adapt Asaf's seedfile command and then delete this. THIS_PATH=$(pwd) -BETA_PATH='/mnt/c/Users/bvisn/Developer/handmade/handmade-beta' -# BETA_PATH='/Users/benvisness/Developer/handmade/handmade-beta' +#BETA_PATH='/mnt/c/Users/bvisn/Developer/handmade/handmade-beta' +BETA_PATH='/Users/benvisness/Developer/handmade/handmade-beta' pushd $BETA_PATH docker-compose down -v @@ -19,4 +19,5 @@ pushd $BETA_PATH docker-compose exec postgres bash -c "psql -U postgres -c \"CREATE ROLE hmn CREATEDB LOGIN PASSWORD 'password';\"" popd -go run src/main.go seedfile local/backups/hmn_pg_dump_live_2021-09-06 +#go run src/main.go seedfile local/backups/hmn_pg_dump_live_2021-09-06 +go run src/main.go seedfile local/backups/hmn_pg_dump_live_2021-10-23 diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go index b98dbf1..3975b3c 100644 --- a/src/hmnurl/urls.go +++ b/src/hmnurl/urls.go @@ -97,8 +97,6 @@ func BuildRegistrationSuccess() string { return Url("/registered_successfully", nil) } -// TODO(asaf): Delete the old version a bit after launch -var RegexOldEmailConfirmation = regexp.MustCompile(`^/_register/confirm/(?P[\w\ \.\,\-@\+\_]+)/(?P[\d\w]+)/(?P.+)[\/]?$`) var RegexEmailConfirmation = regexp.MustCompile("^/email_confirmation/(?P[^/]+)/(?P[^/]+)$") func BuildEmailConfirmation(username, token string) string { @@ -295,14 +293,14 @@ func BuildProjectNew() string { return Url("/projects/new", nil) } -var RegexPersonalProjectHomepage = regexp.MustCompile("^/p/(?P[0-9]+)(/(?P[^/]*))?") +var RegexPersonalProject = regexp.MustCompile("^/p/(?P[0-9]+)(/(?P[a-zA-Z0-9-]+))?") -func BuildPersonalProjectHomepage(id int, slug string) string { +func BuildPersonalProject(id int, slug string) string { defer CatchPanic() return Url(fmt.Sprintf("/p/%d/%s", id, slug), nil) } -var RegexProjectEdit = regexp.MustCompile("^/p/(?P.+)/edit$") +var RegexProjectEdit = regexp.MustCompile("^/edit$") func BuildProjectEdit(slug string, section string) string { defer CatchPanic() @@ -730,7 +728,7 @@ func BuildForumMarkRead(projectSlug string, subforumId int) string { return ProjectUrl(builder.String(), nil, projectSlug) } -var RegexCatchAll = regexp.MustCompile("") +var RegexCatchAll = regexp.MustCompile("^") /* * Helper functions diff --git a/src/templates/mapping.go b/src/templates/mapping.go index 39b7473..e6d18d4 100644 --- a/src/templates/mapping.go +++ b/src/templates/mapping.go @@ -62,7 +62,7 @@ var LifecycleBadgeStrings = map[models.ProjectLifecycle]string{ func ProjectUrl(p *models.Project) string { var url string if p.Personal { - url = hmnurl.BuildPersonalProjectHomepage(p.ID, models.GeneratePersonalProjectSlug(p.Name)) + url = hmnurl.BuildPersonalProject(p.ID, models.GeneratePersonalProjectSlug(p.Name)) } else { url = hmnurl.BuildOfficialProjectHomepage(p.Slug) } diff --git a/src/website/base_data.go b/src/website/base_data.go index 6d37b28..794f20c 100644 --- a/src/website/base_data.go +++ b/src/website/base_data.go @@ -26,7 +26,7 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc notices := getNoticesFromCookie(c) if len(breadcrumbs) > 0 { - projectUrl := hmnurl.BuildProjectHomepage(c.CurrentProject.Slug) + projectUrl := UrlForProject(c.CurrentProject) if breadcrumbs[0].Url != projectUrl { rootBreadcrumb := templates.Breadcrumb{ Name: c.CurrentProject.Name, @@ -42,7 +42,7 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc Breadcrumbs: breadcrumbs, CurrentUrl: c.FullUrl(), - CurrentProjectUrl: hmnurl.BuildProjectHomepage(c.CurrentProject.Slug), + CurrentProjectUrl: UrlForProject(c.CurrentProject), LoginPageUrl: hmnurl.BuildLoginPage(c.FullUrl()), ProjectCSSUrl: hmnurl.BuildProjectCSS(c.CurrentProject.Color1), diff --git a/src/website/blogs.go b/src/website/blogs.go index 9e31868..df76def 100644 --- a/src/website/blogs.go +++ b/src/website/blogs.go @@ -517,7 +517,7 @@ func BlogPostDeleteSubmit(c *RequestContext) ResponseData { } if threadDeleted { - projectUrl := hmnurl.BuildProjectHomepage(c.CurrentProject.Slug) + projectUrl := UrlForProject(c.CurrentProject) return c.Redirect(projectUrl, http.StatusSeeOther) } else { thread, err := FetchThread(c.Context(), c.Conn, c.CurrentUser, cd.ThreadID, ThreadsQuery{ diff --git a/src/website/breadcrumb_helper.go b/src/website/breadcrumb_helper.go index 0f69794..b40ace7 100644 --- a/src/website/breadcrumb_helper.go +++ b/src/website/breadcrumb_helper.go @@ -9,7 +9,7 @@ import ( func ProjectBreadcrumb(project *models.Project) templates.Breadcrumb { return templates.Breadcrumb{ Name: project.Name, - Url: hmnurl.BuildProjectHomepage(project.Slug), + Url: UrlForProject(project), } } diff --git a/src/website/episode_guide.go b/src/website/episode_guide.go index 6be4069..76cba49 100644 --- a/src/website/episode_guide.go +++ b/src/website/episode_guide.go @@ -53,7 +53,7 @@ func EpisodeList(c *RequestContext) ResponseData { defaultTopic, hasEpisodeGuide := config.Config.EpisodeGuide.Projects[slug] if !hasEpisodeGuide { - return c.Redirect(hmnurl.BuildProjectHomepage(slug), http.StatusSeeOther) + return c.Redirect(UrlForProject(c.CurrentProject), http.StatusSeeOther) } if topic == "" { @@ -114,7 +114,7 @@ func Episode(c *RequestContext) ResponseData { _, hasEpisodeGuide := config.Config.EpisodeGuide.Projects[slug] if !hasEpisodeGuide { - return c.Redirect(hmnurl.BuildProjectHomepage(slug), http.StatusSeeOther) + return c.Redirect(UrlForProject(c.CurrentProject), http.StatusSeeOther) } _, foundTopic := topicsForProject(slug, topic) diff --git a/src/website/post_helper.go b/src/website/post_helper.go index 8c8fb76..c60c148 100644 --- a/src/website/post_helper.go +++ b/src/website/post_helper.go @@ -15,7 +15,7 @@ func UrlForGenericThread(thread *models.Thread, lineageBuilder *models.SubforumL return hmnurl.BuildForumThread(projectSlug, lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), thread.ID, thread.Title, 1) } - return hmnurl.BuildProjectHomepage(projectSlug) + return hmnurl.BuildOfficialProjectHomepage(projectSlug) // TODO: both official and personal projects } func UrlForGenericPost(thread *models.Thread, post *models.Post, lineageBuilder *models.SubforumLineageBuilder, projectSlug string) string { @@ -26,7 +26,7 @@ func UrlForGenericPost(thread *models.Thread, post *models.Post, lineageBuilder return hmnurl.BuildForumPost(projectSlug, lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), post.ThreadID, post.ID) } - return hmnurl.BuildProjectHomepage(projectSlug) + return hmnurl.BuildOfficialProjectHomepage(projectSlug) // TODO: both official and personal projects } var PostTypeMap = map[models.ThreadType][]templates.PostType{ @@ -55,7 +55,7 @@ func GenericThreadBreadcrumbs(lineageBuilder *models.SubforumLineageBuilder, pro result = []templates.Breadcrumb{ { Name: project.Name, - Url: hmnurl.BuildProjectHomepage(project.Slug), + Url: UrlForProject(project), }, { Name: ThreadTypeDisplayNames[thread.Type], @@ -73,7 +73,7 @@ func BuildProjectRootResourceUrl(projectSlug string, kind models.ThreadType) str case models.ThreadTypeForumPost: return hmnurl.BuildForum(projectSlug, nil, 1) } - return hmnurl.BuildProjectHomepage(projectSlug) + return hmnurl.BuildOfficialProjectHomepage(projectSlug) // TODO: both official and personal projects } func MakePostListItem( diff --git a/src/website/project_helper.go b/src/website/project_helper.go index c022f7c..de6e758 100644 --- a/src/website/project_helper.go +++ b/src/website/project_helper.go @@ -18,8 +18,9 @@ const ( type ProjectsQuery struct { // Available on all project queries - Lifecycles []models.ProjectLifecycle // if empty, defaults to models.VisibleProjectLifecycles - Types ProjectTypeQuery // bitfield + Lifecycles []models.ProjectLifecycle // if empty, defaults to models.VisibleProjectLifecycles + Types ProjectTypeQuery // bitfield + IncludeHidden bool // Ignored when using FetchProject ProjectIDs []int // if empty, all projects @@ -62,8 +63,11 @@ func FetchProjects( FROM handmade_project AS project WHERE - NOT hidden + TRUE `) + if !q.IncludeHidden { + qb.Add(`AND NOT hidden`) + } if len(q.ProjectIDs) > 0 { qb.Add(`AND project.id = ANY ($?)`, q.ProjectIDs) } @@ -386,7 +390,7 @@ func FetchProjectOwners( func UrlForProject(p *models.Project) string { if p.Personal { - return hmnurl.BuildPersonalProjectHomepage(p.ID, models.GeneratePersonalProjectSlug(p.Name)) + return hmnurl.BuildPersonalProject(p.ID, models.GeneratePersonalProjectSlug(p.Name)) } else { return hmnurl.BuildOfficialProjectHomepage(p.Slug) } diff --git a/src/website/projects.go b/src/website/projects.go index 345d359..54e6da9 100644 --- a/src/website/projects.go +++ b/src/website/projects.go @@ -1,12 +1,10 @@ package website import ( - "errors" "fmt" "math" "math/rand" "net/http" - "strconv" "time" "git.handmade.network/hmn/hmn/src/db" @@ -155,49 +153,15 @@ type ProjectHomepageData struct { func ProjectHomepage(c *RequestContext) ResponseData { maxRecentActivity := 15 - var project *models.Project - if c.CurrentProject.IsHMN() { - // Viewing a personal project - idStr := c.PathParams["id"] - slug := c.PathParams["slug"] - - id, err := strconv.Atoi(idStr) - if err != nil { - panic(oops.New(err, "id was not numeric (bad regex in routing)")) - } - - if id == models.HMNProjectID { - return c.Redirect(hmnurl.BuildHomepage(), http.StatusPermanentRedirect) - } - - 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 project by slug")) - } - } - - correctSlug := models.GeneratePersonalProjectSlug(p.Project.Name) - if slug != correctSlug { - return c.Redirect(hmnurl.BuildPersonalProjectHomepage(id, correctSlug), http.StatusPermanentRedirect) - } - - project = &p.Project - } else { - project = c.CurrentProject - } - - if project == nil { + if c.CurrentProject == nil { return FourOhFour(c) } // There are no further permission checks to do, because permissions are // checked whatever way we fetch the project. - owners, err := FetchProjectOwners(c.Context(), c.Conn, project.ID) + owners, err := FetchProjectOwners(c.Context(), c.Conn, c.CurrentProject.ID) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, err) } @@ -215,7 +179,7 @@ func ProjectHomepage(c *RequestContext) ResponseData { WHERE handmade_project_screenshots.project_id = $1 `, - project.ID, + c.CurrentProject.ID, ) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch screenshots for project")) @@ -235,7 +199,7 @@ func ProjectHomepage(c *RequestContext) ResponseData { link.project_id = $1 ORDER BY link.ordering ASC `, - project.ID, + c.CurrentProject.ID, ) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project links")) @@ -265,7 +229,7 @@ func ProjectHomepage(c *RequestContext) ResponseData { ORDER BY post.postdate DESC LIMIT $2 `, - project.ID, + c.CurrentProject.ID, maxRecentActivity, ) if err != nil { @@ -275,36 +239,36 @@ func ProjectHomepage(c *RequestContext) ResponseData { var projectHomepageData ProjectHomepageData - projectHomepageData.BaseData = getBaseData(c, project.Name, nil) - if canEdit { - // TODO: Move to project-specific navigation - // projectHomepageData.BaseData.Header.EditURL = hmnurl.BuildProjectEdit(project.Slug, "") - } + projectHomepageData.BaseData = getBaseData(c, c.CurrentProject.Name, nil) + //if canEdit { + // // TODO: Move to project-specific navigation + // // projectHomepageData.BaseData.Header.EditURL = hmnurl.BuildProjectEdit(project.Slug, "") + //} projectHomepageData.BaseData.OpenGraphItems = append(projectHomepageData.BaseData.OpenGraphItems, templates.OpenGraphItem{ Property: "og:description", - Value: project.Blurb, + Value: c.CurrentProject.Blurb, }) - projectHomepageData.Project = templates.ProjectToTemplate(project, c.Theme) + projectHomepageData.Project = templates.ProjectToTemplate(c.CurrentProject, c.Theme) for _, owner := range owners { projectHomepageData.Owners = append(projectHomepageData.Owners, templates.UserToTemplate(owner, c.Theme)) } - if project.Hidden { + if c.CurrentProject.Hidden { projectHomepageData.BaseData.AddImmediateNotice( "hidden", "NOTICE: This project is hidden. It is currently visible only to owners and site admins.", ) } - if project.Lifecycle != models.ProjectLifecycleActive { - switch project.Lifecycle { + if c.CurrentProject.Lifecycle != models.ProjectLifecycleActive { + switch c.CurrentProject.Lifecycle { case models.ProjectLifecycleUnapproved: projectHomepageData.BaseData.AddImmediateNotice( "unapproved", fmt.Sprintf( "NOTICE: This project has not yet been submitted for approval. It is only visible to owners. Please submit it for approval when the project content is ready for review.", - hmnurl.BuildProjectEdit(project.Slug, "submit"), + hmnurl.BuildProjectEdit(c.CurrentProject.Slug, "submit"), ), ) case models.ProjectLifecycleApprovalRequired: @@ -348,7 +312,7 @@ func ProjectHomepage(c *RequestContext) ResponseData { lineageBuilder, &post.(*postQuery).Post, &post.(*postQuery).Thread, - project, + c.CurrentProject, &post.(*postQuery).Author, c.Theme, )) diff --git a/src/website/requesthandling.go b/src/website/requesthandling.go index 6ca84ec..0a6eee5 100644 --- a/src/website/requesthandling.go +++ b/src/website/requesthandling.go @@ -30,12 +30,13 @@ type Router struct { type Route struct { Method string - Regex *regexp.Regexp + Regexes []*regexp.Regexp Handler Handler } type RouteBuilder struct { Router *Router + Prefixes []*regexp.Regexp Middleware Middleware } @@ -44,11 +45,17 @@ type Handler func(c *RequestContext) ResponseData type Middleware func(h Handler) Handler func (rb *RouteBuilder) Handle(methods []string, regex *regexp.Regexp, h Handler) { + // Ensure that this regex matches the start of the string + regexStr := regex.String() + if len(regexStr) == 0 || regexStr[0] != '^' { + panic("All routing regexes must begin with '^'") + } + h = rb.Middleware(h) for _, method := range methods { rb.Router.Routes = append(rb.Router.Routes, Route{ Method: method, - Regex: regex, + Regexes: append(rb.Prefixes, regex), Handler: h, }) } @@ -66,33 +73,36 @@ func (rb *RouteBuilder) POST(regex *regexp.Regexp, h Handler) { rb.Handle([]string{http.MethodPost}, regex, h) } +func (rb *RouteBuilder) Group(regex *regexp.Regexp, addRoutes func(rb *RouteBuilder)) { + newRb := *rb + newRb.Prefixes = append(newRb.Prefixes, regex) + addRoutes(&newRb) +} + func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) { - path := req.URL.Path +nextroute: for _, route := range r.Routes { if route.Method != "" && req.Method != route.Method { continue } - path = strings.TrimSuffix(path, "/") - if path == "" { - path = "/" + currentPath := strings.TrimSuffix(req.URL.Path, "/") + if currentPath == "" { + currentPath = "/" } - match := route.Regex.FindStringSubmatch(path) - if match == nil { - continue - } + var params map[string]string + for _, regex := range route.Regexes { - c := &RequestContext{ - Route: route.Regex.String(), - Logger: logging.GlobalLogger(), - Req: req, - Res: rw, - } + match := regex.FindStringSubmatch(currentPath) + if len(match) == 0 { + continue nextroute + } - if len(match) > 0 { - params := map[string]string{} - subexpNames := route.Regex.SubexpNames() + if params == nil { + params = map[string]string{} + } + subexpNames := regex.SubexpNames() for i, paramValue := range match { paramName := subexpNames[i] if paramName == "" { @@ -100,15 +110,35 @@ func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) { } params[paramName] = paramValue } - c.PathParams = params + + // Make sure that we never consume trailing slashes even if the route regex matches them + toConsume := strings.TrimSuffix(match[0], "/") + currentPath = currentPath[len(toConsume):] + if currentPath == "" { + currentPath = "/" + } } + var routeStrings []string + for _, regex := range route.Regexes { + routeStrings = append(routeStrings, regex.String()) + } + + c := &RequestContext{ + Route: fmt.Sprintf("%v", routeStrings), + Logger: logging.GlobalLogger(), + Req: req, + Res: rw, + PathParams: params, + } + c.PathParams = params + doRequest(rw, c, route.Handler) return } - panic(fmt.Sprintf("Path '%s' did not match any routes! Make sure to register a wildcard route to act as a 404.", path)) + panic(fmt.Sprintf("Path '%s' did not match any routes! Make sure to register a wildcard route to act as a 404.", req.URL)) } type RequestContext struct { diff --git a/src/website/routes.go b/src/website/routes.go index b3df8a5..042c4a8 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -8,6 +8,7 @@ import ( "math/rand" "net/http" "net/url" + "strconv" "strings" "time" @@ -154,14 +155,6 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe return res }) - anyProject.GET(hmnurl.RegexHomepage, func(c *RequestContext) ResponseData { - if c.CurrentProject.IsHMN() { - return Index(c) - } else { - return ProjectHomepage(c) - } - }) - // NOTE(asaf): HMN-only routes: hmnOnly.GET(hmnurl.RegexManifesto, Manifesto) hmnOnly.GET(hmnurl.RegexAbout, About) @@ -175,14 +168,13 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe hmnOnly.GET(hmnurl.RegexOldHome, Index) - hmnOnly.POST(hmnurl.RegexLoginAction, securityTimerMiddleware(time.Millisecond*100, Login)) // TODO(asaf): Adjust this after launch + 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.RegexOldEmailConfirmation, EmailConfirmation) // TODO(asaf): Delete this a bit after launch hmnOnly.GET(hmnurl.RegexEmailConfirmation, EmailConfirmation) hmnOnly.POST(hmnurl.RegexEmailConfirmation, EmailConfirmationSubmit) @@ -202,7 +194,6 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe hmnOnly.GET(hmnurl.RegexShowcase, Showcase) hmnOnly.GET(hmnurl.RegexSnippet, Snippet) hmnOnly.GET(hmnurl.RegexProjectIndex, ProjectIndex) - hmnOnly.GET(hmnurl.RegexPersonalProjectHomepage, ProjectHomepage) hmnOnly.GET(hmnurl.RegexDiscordOAuthCallback, authMiddleware(DiscordOAuthCallback)) hmnOnly.POST(hmnurl.RegexDiscordUnlink, authMiddleware(csrfMiddleware(DiscordUnlink))) @@ -225,36 +216,97 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe hmnOnly.GET(hmnurl.RegexLibraryAny, LibraryNotPortedYet) // NOTE(asaf): Any-project routes: - anyProject.GET(hmnurl.RegexForumNewThread, authMiddleware(ForumNewThread)) - anyProject.POST(hmnurl.RegexForumNewThreadSubmit, authMiddleware(csrfMiddleware(ForumNewThreadSubmit))) - anyProject.GET(hmnurl.RegexForumThread, ForumThread) - anyProject.GET(hmnurl.RegexForum, Forum) - anyProject.POST(hmnurl.RegexForumMarkRead, authMiddleware(csrfMiddleware(ForumMarkRead))) - anyProject.GET(hmnurl.RegexForumPost, ForumPostRedirect) - anyProject.GET(hmnurl.RegexForumPostReply, authMiddleware(ForumPostReply)) - anyProject.POST(hmnurl.RegexForumPostReply, authMiddleware(csrfMiddleware(ForumPostReplySubmit))) - anyProject.GET(hmnurl.RegexForumPostEdit, authMiddleware(ForumPostEdit)) - anyProject.POST(hmnurl.RegexForumPostEdit, authMiddleware(csrfMiddleware(ForumPostEditSubmit))) - anyProject.GET(hmnurl.RegexForumPostDelete, authMiddleware(ForumPostDelete)) - anyProject.POST(hmnurl.RegexForumPostDelete, authMiddleware(csrfMiddleware(ForumPostDeleteSubmit))) - anyProject.GET(hmnurl.RegexWikiArticle, WikiArticleRedirect) + attachProjectRoutes := func(rb *RouteBuilder) { + rb.GET(hmnurl.RegexHomepage, func(c *RequestContext) ResponseData { + if c.CurrentProject.IsHMN() { + return Index(c) + } else { + return ProjectHomepage(c) + } + }) - anyProject.GET(hmnurl.RegexBlog, BlogIndex) - anyProject.GET(hmnurl.RegexBlogNewThread, authMiddleware(BlogNewThread)) - anyProject.POST(hmnurl.RegexBlogNewThread, authMiddleware(csrfMiddleware(BlogNewThreadSubmit))) - anyProject.GET(hmnurl.RegexBlogThread, BlogThread) - anyProject.GET(hmnurl.RegexBlogPost, BlogPostRedirectToThread) - anyProject.GET(hmnurl.RegexBlogPostReply, authMiddleware(BlogPostReply)) - anyProject.POST(hmnurl.RegexBlogPostReply, authMiddleware(csrfMiddleware(BlogPostReplySubmit))) - anyProject.GET(hmnurl.RegexBlogPostEdit, authMiddleware(BlogPostEdit)) - anyProject.POST(hmnurl.RegexBlogPostEdit, authMiddleware(csrfMiddleware(BlogPostEditSubmit))) - anyProject.GET(hmnurl.RegexBlogPostDelete, authMiddleware(BlogPostDelete)) - anyProject.POST(hmnurl.RegexBlogPostDelete, authMiddleware(csrfMiddleware(BlogPostDeleteSubmit))) - anyProject.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) + 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) + } + + 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) @@ -378,15 +430,29 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) { slug := strings.TrimRight(hostPrefix, ".") dbProject, err := FetchProjectBySlug(c.Context(), c.Conn, c.CurrentUser, slug, ProjectsQuery{}) - if err != nil { + if err == nil { + c.CurrentProject = &dbProject.Project + } else { if errors.Is(err, db.NotFound) { - return false, c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther) + // do nothing, this is fine } else { return false, c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch current project")) } } - c.CurrentProject = &dbProject.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"