diff --git a/src/hmnurl/hmnurl.go b/src/hmnurl/hmnurl.go index 6f2738e..8cc0886 100644 --- a/src/hmnurl/hmnurl.go +++ b/src/hmnurl/hmnurl.go @@ -18,7 +18,11 @@ type Q struct { var baseUrlParsed url.URL func init() { - parsed, err := url.Parse(config.Config.BaseUrl) + SetGlobalBaseUrl(config.Config.BaseUrl) +} + +func SetGlobalBaseUrl(fullBaseUrl string) { + parsed, err := url.Parse(fullBaseUrl) if err != nil { panic(oops.New(err, "could not parse base URL")) } diff --git a/src/hmnurl/hmnurl_test.go b/src/hmnurl/hmnurl_test.go index 959c016..69777cd 100644 --- a/src/hmnurl/hmnurl_test.go +++ b/src/hmnurl/hmnurl_test.go @@ -1,6 +1,8 @@ package hmnurl import ( + "net/url" + "regexp" "testing" "git.handmade.network/hmn/hmn/src/config" @@ -8,10 +10,10 @@ import ( ) func TestUrl(t *testing.T) { - defer func(original string) { - config.Config.BaseUrl = original - }(config.Config.BaseUrl) - config.Config.BaseUrl = "http://handmade.test" + defer func() { + SetGlobalBaseUrl(config.Config.BaseUrl) + }() + SetGlobalBaseUrl("http://handmade.test") t.Run("no query", func(t *testing.T) { result := Url("/test/foo", nil) @@ -22,3 +24,123 @@ func TestUrl(t *testing.T) { assert.Equal(t, "http://handmade.test/test/foo?bar=baz&zig%3F%3F=zig+%26+zag%21%21", result) }) } + +func TestHomepage(t *testing.T) { + AssertRegexMatch(t, BuildHomepage(), RegexHomepage, nil) +} + +func TestLogin(t *testing.T) { + AssertRegexMatch(t, BuildLogin(), RegexLogin, nil) +} + +func TestLogout(t *testing.T) { + AssertRegexMatch(t, BuildLogout(), RegexLogout, nil) +} + +func TestStaticPages(t *testing.T) { + AssertRegexMatch(t, BuildManifesto(), RegexManifesto, nil) + AssertRegexMatch(t, BuildAbout(), RegexAbout, nil) + AssertRegexMatch(t, BuildCodeOfConduct(), RegexCodeOfConduct, nil) + AssertRegexMatch(t, BuildCommunicationGuidelines(), RegexCommunicationGuidelines, nil) + AssertRegexMatch(t, BuildContactPage(), RegexContactPage, nil) + AssertRegexMatch(t, BuildMonthlyUpdatePolicy(), RegexMonthlyUpdatePolicy, nil) + AssertRegexMatch(t, BuildProjectSubmissionGuidelines(), RegexProjectSubmissionGuidelines, nil) +} + +func TestFeed(t *testing.T) { + AssertRegexMatch(t, BuildFeed(), RegexFeed, nil) + assert.Equal(t, BuildFeed(), BuildFeedWithPage(1)) + AssertRegexMatch(t, BuildFeedWithPage(1), RegexFeed, nil) + AssertRegexMatch(t, "/feed/1", RegexFeed, nil) // NOTE(asaf): We should never build this URL, but we should still accept it. + AssertRegexMatch(t, BuildFeedWithPage(5), RegexFeed, map[string]string{"page": "5"}) + assert.Panics(t, func() { BuildFeedWithPage(-1) }) + assert.Panics(t, func() { BuildFeedWithPage(0) }) +} + +func TestForumThread(t *testing.T) { + AssertRegexMatch(t, BuildForumThread("", nil, 1, 1), RegexForumThread, map[string]string{"threadid": "1"}) + AssertRegexMatch(t, BuildForumThread("", []string{"wip"}, 1, 2), RegexForumThread, map[string]string{"cats": "forums/wip", "page": "2", "threadid": "1"}) + AssertRegexMatch(t, BuildForumThread("", []string{"sub", "wip"}, 1, 2), RegexForumThread, map[string]string{"cats": "forums/sub/wip", "page": "2", "threadid": "1"}) + AssertSubdomain(t, BuildForumThread("hmn", nil, 1, 1), "") + AssertSubdomain(t, BuildForumThread("", nil, 1, 1), "") + AssertSubdomain(t, BuildForumThread("hero", nil, 1, 1), "hero") + assert.Panics(t, func() { BuildForumThread("", []string{"", "wip"}, 1, 1) }) + assert.Panics(t, func() { BuildForumThread("", []string{" ", "wip"}, 1, 1) }) + assert.Panics(t, func() { BuildForumThread("", []string{"wip/jobs"}, 1, 1) }) +} + +func TestForumCategory(t *testing.T) { + AssertRegexMatch(t, BuildForumCategory("", nil, 1), RegexForumCategory, nil) + AssertRegexMatch(t, BuildForumCategory("", []string{"wip"}, 2), RegexForumCategory, map[string]string{"cats": "forums/wip", "page": "2"}) + AssertRegexMatch(t, BuildForumCategory("", []string{"sub", "wip"}, 2), RegexForumCategory, map[string]string{"cats": "forums/sub/wip", "page": "2"}) + AssertSubdomain(t, BuildForumCategory("hmn", nil, 1), "") + AssertSubdomain(t, BuildForumCategory("", nil, 1), "") + AssertSubdomain(t, BuildForumCategory("hero", nil, 1), "hero") + assert.Panics(t, func() { BuildForumCategory("", []string{"", "wip"}, 1) }) + assert.Panics(t, func() { BuildForumCategory("", []string{" ", "wip"}, 1) }) + assert.Panics(t, func() { BuildForumCategory("", []string{"wip/jobs"}, 1) }) +} + +func TestProjectCSS(t *testing.T) { + AssertRegexMatch(t, BuildProjectCSS("000000"), RegexProjectCSS, nil) +} + +func TestPublic(t *testing.T) { + AssertRegexMatch(t, BuildPublic("test"), RegexPublic, nil) + AssertRegexMatch(t, BuildPublic("/test"), RegexPublic, nil) + AssertRegexMatch(t, BuildPublic("/test/"), RegexPublic, nil) + AssertRegexMatch(t, BuildPublic("/test/thing/image.png"), RegexPublic, nil) + assert.Panics(t, func() { BuildPublic("") }) + assert.Panics(t, func() { BuildPublic("/") }) + assert.Panics(t, func() { BuildPublic("/thing//image.png") }) + assert.Panics(t, func() { BuildPublic("/thing/ /image.png") }) +} + +func AssertSubdomain(t *testing.T, fullUrl string, expectedSubdomain string) { + parsed, err := url.Parse(fullUrl) + ok := assert.Nilf(t, err, "Full url could not be parsed: %s", fullUrl) + if !ok { + return + } + + fullHost := parsed.Host + if len(expectedSubdomain) == 0 { + assert.Equal(t, baseUrlParsed.Host, fullHost, "Did not expect a subdomain") + } else { + assert.Equalf(t, expectedSubdomain+"."+baseUrlParsed.Host, fullHost, "Subdomain mismatch") + } +} + +func AssertRegexMatch(t *testing.T, fullUrl string, regex *regexp.Regexp, paramsToVerify map[string]string) { + parsed, err := url.Parse(fullUrl) + ok := assert.Nilf(t, err, "Full url could not be parsed: %s", fullUrl) + if !ok { + return + } + + requestPath := parsed.Path + if len(requestPath) == 0 { + requestPath = "/" + } + match := regex.FindStringSubmatch(requestPath) + assert.NotNilf(t, match, "Url did not match regex: [%s] vs [%s]", requestPath, regex.String()) + + if paramsToVerify != nil { + subexpNames := regex.SubexpNames() + for i, matchedValue := range match { + paramName := subexpNames[i] + expectedValue, ok := paramsToVerify[paramName] + if ok { + assert.Equalf(t, expectedValue, matchedValue, "Param mismatch for [%s]", paramName) + delete(paramsToVerify, paramName) + } + } + if len(paramsToVerify) > 0 { + unmatchedParams := make([]string, 0, len(paramsToVerify)) + for paramName := range paramsToVerify { + unmatchedParams = append(unmatchedParams, paramName) + } + assert.Fail(t, "Expected match groups not found", unmatchedParams) + } + } +} diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go new file mode 100644 index 0000000..12a9f8b --- /dev/null +++ b/src/hmnurl/urls.go @@ -0,0 +1,207 @@ +package hmnurl + +import ( + "git.handmade.network/hmn/hmn/src/oops" + "regexp" + "strconv" + "strings" +) + +var RegexHomepage *regexp.Regexp = regexp.MustCompile("^/$") + +func BuildHomepage() string { + return Url("/", nil) +} + +var RegexLogin *regexp.Regexp = regexp.MustCompile("^/login$") + +func BuildLogin() string { + return Url("/login", nil) +} + +var RegexLogout *regexp.Regexp = regexp.MustCompile("^/logout$") + +func BuildLogout() string { + return Url("/logout", nil) +} + +var RegexManifesto *regexp.Regexp = regexp.MustCompile("^/manifesto$") + +func BuildManifesto() string { + return Url("/manifesto", nil) +} + +var RegexAbout *regexp.Regexp = regexp.MustCompile("^/about$") + +func BuildAbout() string { + return Url("/about", nil) +} + +var RegexCodeOfConduct *regexp.Regexp = regexp.MustCompile("^/code-of-conduct$") + +func BuildCodeOfConduct() string { + return Url("/code-of-conduct", nil) +} + +var RegexCommunicationGuidelines *regexp.Regexp = regexp.MustCompile("^/communication-guidelines$") + +func BuildCommunicationGuidelines() string { + return Url("/communication-guidelines", nil) +} + +var RegexContactPage *regexp.Regexp = regexp.MustCompile("^/contact$") + +func BuildContactPage() string { + return Url("/contact", nil) +} + +var RegexMonthlyUpdatePolicy *regexp.Regexp = regexp.MustCompile("^/monthly-update-policy$") + +func BuildMonthlyUpdatePolicy() string { + return Url("/monthly-update-policy", nil) +} + +var RegexProjectSubmissionGuidelines *regexp.Regexp = regexp.MustCompile("^/project-guidelines$") + +func BuildProjectSubmissionGuidelines() string { + return Url("/project-guidelines", nil) +} + +var RegexFeed *regexp.Regexp = regexp.MustCompile(`^/feed(/(?P.+)?)?$`) + +func BuildFeed() string { + return Url("/feed", nil) +} + +func BuildFeedWithPage(page int) string { + if page < 1 { + panic(oops.New(nil, "Invalid feed page (%d), must be >= 1", page)) + } + if page == 1 { + return BuildFeed() + } + return Url("/feed/"+strconv.Itoa(page), nil) +} + +var RegexForumThread *regexp.Regexp = regexp.MustCompile(`^/(?Pforums(/[^\d]+?)*)/t/(?P\d+)(/(?P\d+))?$`) + +func BuildForumThread(projectSlug string, subforums []string, threadId int, page int) string { + if projectSlug == "hmn" { + projectSlug = "" + } + + if page < 1 { + panic(oops.New(nil, "Invalid forum thread page (%d), must be >= 1", page)) + } + + var builder strings.Builder + builder.WriteString("/forums") + for _, subforum := range subforums { + subforum = strings.TrimSpace(subforum) + if strings.Contains(subforum, "/") { + panic(oops.New(nil, "Tried building forum thread url with / in subforum name")) + } + if len(subforum) == 0 { + panic(oops.New(nil, "Tried building forum thread url with blank subforum")) + } + builder.WriteRune('/') + builder.WriteString(subforum) + } + builder.WriteString("/t/") + builder.WriteString(strconv.Itoa(threadId)) + if page > 1 { + builder.WriteRune('/') + builder.WriteString(strconv.Itoa(page)) + } + + return ProjectUrl(builder.String(), nil, projectSlug) +} + +var RegexForumCategory *regexp.Regexp = regexp.MustCompile(`^/(?Pforums(/[^\d]+?)*)(/(?P\d+))?$`) + +func BuildForumCategory(projectSlug string, subforums []string, page int) string { + if projectSlug == "hmn" { + projectSlug = "" + } + + if page < 1 { + panic(oops.New(nil, "Invalid forum thread page (%d), must be >= 1", page)) + } + + var builder strings.Builder + builder.WriteString("/forums") + for _, subforum := range subforums { + subforum = strings.TrimSpace(subforum) + if strings.Contains(subforum, "/") { + panic(oops.New(nil, "Tried building forum thread url with / in subforum name")) + } + if len(subforum) == 0 { + panic(oops.New(nil, "Tried building forum thread url with blank subforum")) + } + builder.WriteRune('/') + builder.WriteString(subforum) + } + if page > 1 { + builder.WriteRune('/') + builder.WriteString(strconv.Itoa(page)) + } + + return ProjectUrl(builder.String(), nil, projectSlug) +} + +var RegexForumPost *regexp.Regexp = regexp.MustCompile(``) // TODO(asaf): Complete this and test it + +func BuildForumPost(projectSlug string, subforums []string, threadId int, postId int) string { + if projectSlug == "hmn" { + projectSlug = "" + } + + var builder strings.Builder + builder.WriteString("/forums") + for _, subforum := range subforums { + subforum = strings.TrimSpace(subforum) + if strings.Contains(subforum, "/") { + panic(oops.New(nil, "Tried building forum thread url with / in subforum name")) + } + if len(subforum) == 0 { + panic(oops.New(nil, "Tried building forum thread url with blank subforum")) + } + builder.WriteRune('/') + builder.WriteString(subforum) + } + builder.WriteString("/t/") + builder.WriteString(strconv.Itoa(threadId)) + builder.WriteString("/p/") + builder.WriteString(strconv.Itoa(postId)) + + return ProjectUrl(builder.String(), nil, projectSlug) +} + +var RegexProjectCSS *regexp.Regexp = regexp.MustCompile("^/assets/project.css$") + +func BuildProjectCSS(color string) string { + return Url("/assets/project.css", []Q{Q{"color", color}}) +} + +var RegexPublic *regexp.Regexp = regexp.MustCompile("^/public/.+$") + +func BuildPublic(filepath string) string { + filepath = strings.Trim(filepath, "/") + if len(strings.TrimSpace(filepath)) == 0 { + panic(oops.New(nil, "Attempted to build a /public url with no path")) + } + var builder strings.Builder + builder.WriteString("/public") + pathParts := strings.Split(filepath, "/") + for _, part := range pathParts { + part = strings.TrimSpace(part) + if len(part) == 0 { + panic(oops.New(nil, "Attempted to build a /public url with blank path segments: %s", filepath)) + } + builder.WriteRune('/') + builder.WriteString(part) + } + return Url(builder.String(), nil) +} + +var RegexCatchAll *regexp.Regexp = regexp.MustCompile("") diff --git a/src/models/category.go b/src/models/category.go index 0aa0835..d9241dd 100644 --- a/src/models/category.go +++ b/src/models/category.go @@ -4,6 +4,7 @@ import ( "context" "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/oops" "github.com/jackc/pgx/v4/pgxpool" ) @@ -33,6 +34,96 @@ type Category struct { Depth int `db:"depth"` // TODO: What is this? } +type CategoryLineageBuilder struct { + Tree map[int]*CategoryTreeNode + CategoryCache map[int][]*Category + SlugCache map[int][]string +} + +func MakeCategoryLineageBuilder(fullCategoryTree map[int]*CategoryTreeNode) *CategoryLineageBuilder { + return &CategoryLineageBuilder{ + Tree: fullCategoryTree, + CategoryCache: make(map[int][]*Category), + SlugCache: make(map[int][]string), + } +} + +func (cl *CategoryLineageBuilder) GetLineage(catId int) []*Category { + _, ok := cl.CategoryCache[catId] + if !ok { + cl.CategoryCache[catId] = cl.Tree[catId].GetLineage() + } + return cl.CategoryCache[catId] +} + +func (cl *CategoryLineageBuilder) GetLineageSlugs(catId int) []string { + _, ok := cl.SlugCache[catId] + if !ok { + lineage := cl.GetLineage(catId) + result := make([]string, 0, len(lineage)) + for _, cat := range lineage { + name := "" + if cat.Slug != nil { + name = *cat.Slug + } + result = append(result, name) + } + cl.SlugCache[catId] = result + } + return cl.SlugCache[catId] +} + +type CategoryTreeNode struct { + Category + Parent *CategoryTreeNode +} + +func (node *CategoryTreeNode) GetLineage() []*Category { + current := node + length := 0 + for current != nil { + current = current.Parent + length += 1 + } + result := make([]*Category, length) + current = node + for i := length - 1; i >= 0; i -= 1 { + result[i] = ¤t.Category + current = current.Parent + } + return result +} + +func GetFullCategoryTree(ctx context.Context, conn *pgxpool.Pool) map[int]*CategoryTreeNode { + type categoryRow struct { + Cat Category `db:"cat"` + } + rows, err := db.Query(ctx, conn, categoryRow{}, + ` + SELECT $columns + FROM + handmade_category as cat + `, + ) + if err != nil { + panic(oops.New(err, "Failed to fetch category tree")) + } + + rowsSlice := rows.ToSlice() + catTreeMap := make(map[int]*CategoryTreeNode, len(rowsSlice)) + for _, row := range rowsSlice { + cat := row.(*categoryRow).Cat + catTreeMap[cat.ID] = &CategoryTreeNode{Category: cat} + } + + for _, node := range catTreeMap { + if node.ParentID != nil { + node.Parent = catTreeMap[*node.ParentID] + } + } + return catTreeMap +} + /* Gets the category and its parent categories, starting from the root and working toward the category itself. Useful for breadcrumbs and the like. diff --git a/src/models/project.go b/src/models/project.go index 63bb075..5c25cc8 100644 --- a/src/models/project.go +++ b/src/models/project.go @@ -9,6 +9,18 @@ const HMNProjectID = 1 var ProjectType = reflect.TypeOf(Project{}) +type ProjectLifecycle int + +const ( + ProjectLifecycleUnapproved = iota + ProjectLifecycleApprovalRequired + ProjectLifecycleActive + ProjectLifecycleHiatus + ProjectLifecycleDead + ProjectLifecycleLTSRequired + ProjectLifecycleLTS +) + type Project struct { ID int `db:"id"` @@ -17,6 +29,8 @@ type Project struct { Blurb *string `db:"blurb"` Description *string `db:"description"` + Lifecycle ProjectLifecycle `db:"lifecycle"` + Color1 string `db:"color_1"` Color2 string `db:"color_2"` diff --git a/src/utils/utils.go b/src/utils/utils.go new file mode 100644 index 0000000..6fb9926 --- /dev/null +++ b/src/utils/utils.go @@ -0,0 +1,19 @@ +package utils + +func IntMin(a, b int) int { + if a < b { + return a + } + return b +} + +func IntMax(a, b int) int { + if a > b { + return a + } + return b +} + +func IntClamp(min, t, max int) int { + return IntMax(min, IntMin(t, max)) +} diff --git a/src/website/feed.go b/src/website/feed.go index 92d31b0..289d86d 100644 --- a/src/website/feed.go +++ b/src/website/feed.go @@ -1,7 +1,6 @@ package website import ( - "fmt" "math" "net/http" "strconv" @@ -12,6 +11,7 @@ import ( "git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/templates" + "git.handmade.network/hmn/hmn/src/utils" ) type FeedData struct { @@ -32,7 +32,8 @@ func Feed(c *RequestContext) ResponseData { handmade_post AS post WHERE post.category_kind = ANY ($1) - AND NOT deleted + AND deleted = FALSE + AND post.thread_id IS NOT NULL `, []models.CategoryKind{models.CatKindForum, models.CatKindBlog, models.CatKindWiki, models.CatKindLibraryResource}, ) @@ -49,11 +50,11 @@ func Feed(c *RequestContext) ResponseData { if pageParsed, err := strconv.Atoi(pageString); err == nil { page = pageParsed } else { - return c.Redirect("/feed", http.StatusSeeOther) + return c.Redirect(hmnurl.BuildFeed(), http.StatusSeeOther) } } if page < 1 || numPages < page { - return c.Redirect("/feed", http.StatusSeeOther) + return c.Redirect(hmnurl.BuildFeedWithPage(utils.IntClamp(1, page, numPages)), http.StatusSeeOther) } howManyPostsToSkip := (page - 1) * postsPerPage @@ -62,10 +63,10 @@ func Feed(c *RequestContext) ResponseData { Current: page, Total: numPages, - FirstUrl: hmnurl.Url("/feed", nil), - LastUrl: hmnurl.Url(fmt.Sprintf("/feed/%d", numPages), nil), - NextUrl: hmnurl.Url(fmt.Sprintf("/feed/%d", page+1), nil), - PreviousUrl: hmnurl.Url(fmt.Sprintf("/feed/%d", page-1), nil), + FirstUrl: hmnurl.BuildFeed(), + LastUrl: hmnurl.BuildFeedWithPage(numPages), + NextUrl: hmnurl.BuildFeedWithPage(utils.IntClamp(1, page+1, numPages)), + PreviousUrl: hmnurl.BuildFeedWithPage(utils.IntClamp(1, page-1, numPages)), } var currentUserId *int @@ -117,8 +118,28 @@ func Feed(c *RequestContext) ResponseData { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch feed posts")) } - categoryUrls := GetAllCategoryUrls(c.Context(), c.Conn) + c.Perf.StartBlock("SQL", "Fetch category tree") + categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn) + lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree) + c.Perf.EndBlock() + categoryUrlCache := make(map[int]string) + getCategoryUrl := func(subdomain string, cat *models.Category) string { + _, ok := categoryUrlCache[cat.ID] + if !ok { + lineageNames := lineageBuilder.GetLineageSlugs(cat.ID) + switch cat.Kind { + case models.CatKindForum: + categoryUrlCache[cat.ID] = hmnurl.BuildForumCategory(subdomain, lineageNames[1:], 1) + // TODO(asaf): Add more kinds!!! + default: + categoryUrlCache[cat.ID] = "" + } + } + return categoryUrlCache[cat.ID] + } + + c.Perf.StartBlock("FEED", "Build post items") var postItems []templates.PostListItem for _, iPostResult := range posts.ToSlice() { postResult := iPostResult.(*feedPostQuery) @@ -130,32 +151,35 @@ func Feed(c *RequestContext) ResponseData { hasRead = true } - parents := postResult.Cat.GetHierarchy(c.Context(), c.Conn) - - var breadcrumbs []templates.Breadcrumb + breadcrumbs := make([]templates.Breadcrumb, 0, len(lineageBuilder.GetLineage(postResult.Cat.ID))) breadcrumbs = append(breadcrumbs, templates.Breadcrumb{ Name: *postResult.Proj.Name, Url: hmnurl.ProjectUrl("/", nil, postResult.Proj.Subdomain()), }) - for _, parent := range parents { - name := *parent.Name - if parent.ParentID == nil { - switch parent.Kind { - case models.CatKindForum: - name = "Forums" - case models.CatKindBlog: - name = "Blog" + if postResult.Post.CategoryKind == models.CatKindLibraryResource { + // TODO(asaf): Fetch library root topic for the project and construct breadcrumb for it + } else { + lineage := lineageBuilder.GetLineage(postResult.Cat.ID) + for i, cat := range lineage { + name := *cat.Name + if i == 0 { + switch cat.Kind { + case models.CatKindForum: + name = "Forums" + case models.CatKindBlog: + name = "Blog" + } } + breadcrumbs = append(breadcrumbs, templates.Breadcrumb{ + Name: name, + Url: getCategoryUrl(postResult.Proj.Subdomain(), cat), + }) } - breadcrumbs = append(breadcrumbs, templates.Breadcrumb{ - Name: name, - Url: categoryUrls[parent.ID], - }) } postItems = append(postItems, templates.PostListItem{ Title: postResult.Thread.Title, - Url: PostUrl(postResult.Post, postResult.Post.CategoryKind, categoryUrls[postResult.Post.CategoryID]), + Url: hmnurl.BuildForumPost(postResult.Proj.Subdomain(), lineageBuilder.GetLineageSlugs(postResult.Cat.ID)[1:], postResult.Post.ID, postResult.Post.ThreadID), User: templates.UserToTemplate(&postResult.User), Date: postResult.Post.PostDate, Breadcrumbs: breadcrumbs, @@ -164,6 +188,7 @@ func Feed(c *RequestContext) ResponseData { Content: postResult.Post.Preview, }) } + c.Perf.EndBlock() baseData := getBaseData(c) baseData.BodyClasses = append(baseData.BodyClasses, "feed") diff --git a/src/website/requesthandling.go b/src/website/requesthandling.go index a4dc914..fa14b23 100644 --- a/src/website/requesthandling.go +++ b/src/website/requesthandling.go @@ -46,29 +46,29 @@ func WrapStdHandler(h http.Handler) Handler { type Middleware func(h Handler) Handler -func (rb *RouteBuilder) Handle(method string, regexStr string, h Handler) { +func (rb *RouteBuilder) Handle(method string, regex *regexp.Regexp, h Handler) { h = rb.Middleware(h) rb.Router.Routes = append(rb.Router.Routes, Route{ Method: method, - Regex: regexp.MustCompile(regexStr), + Regex: regex, Handler: h, }) } -func (rb *RouteBuilder) AnyMethod(regexStr string, h Handler) { - rb.Handle("", regexStr, h) +func (rb *RouteBuilder) AnyMethod(regex *regexp.Regexp, h Handler) { + rb.Handle("", regex, h) } -func (rb *RouteBuilder) GET(regexStr string, h Handler) { - rb.Handle(http.MethodGet, regexStr, h) +func (rb *RouteBuilder) GET(regex *regexp.Regexp, h Handler) { + rb.Handle(http.MethodGet, regex, h) } -func (rb *RouteBuilder) POST(regexStr string, h Handler) { - rb.Handle(http.MethodPost, regexStr, h) +func (rb *RouteBuilder) POST(regex *regexp.Regexp, h Handler) { + rb.Handle(http.MethodPost, regex, h) } -func (rb *RouteBuilder) StdHandler(regexStr string, h http.Handler) { - rb.Handle("", regexStr, WrapStdHandler(h)) +func (rb *RouteBuilder) StdHandler(regex *regexp.Regexp, h http.Handler) { + rb.Handle("", regex, WrapStdHandler(h)) } func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) { diff --git a/src/website/routes.go b/src/website/routes.go index 09ad0d7..d4d9e60 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -31,9 +31,10 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt logPerf := TrackRequestPerf(c, perfCollector) defer logPerf() - defer LogContextErrors(c, res) + res = h(c) - return h(c) + LogContextErrors(c, res) + return } }, } @@ -46,14 +47,15 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt logPerf := TrackRequestPerf(c, perfCollector) defer logPerf() - defer LogContextErrors(c, res) - ok, errRes := LoadCommonWebsiteData(c) if !ok { return errRes } - return h(c) + res = h(c) + + LogContextErrors(c, res) + return } } @@ -65,29 +67,31 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt logPerf := TrackRequestPerf(c, perfCollector) defer logPerf() - defer LogContextErrors(c, res) - ok, errRes := LoadCommonWebsiteData(c) if !ok { return errRes } if !c.CurrentProject.IsHMN() { - res := c.Redirect(hmnurl.Url(c.URL().String(), nil), http.StatusMovedPermanently) - return res + res = c.Redirect(hmnurl.Url(c.URL().String(), nil), http.StatusMovedPermanently) + return } - return h(c) + res = h(c) + + LogContextErrors(c, res) + return } } - routes.POST("^/login$", Login) - routes.GET("^/logout$", Logout) - routes.StdHandler("^/public/.*$", + // TODO(asaf): login/logout shouldn't happen on subdomains. We should verify that in the middleware. + routes.POST(hmnurl.RegexLogin, Login) + routes.GET(hmnurl.RegexLogout, Logout) + routes.StdHandler(hmnurl.RegexPublic, http.StripPrefix("/public/", http.FileServer(http.Dir("public"))), ) - mainRoutes.GET("^/$", func(c *RequestContext) ResponseData { + mainRoutes.GET(hmnurl.RegexHomepage, func(c *RequestContext) ResponseData { if c.CurrentProject.IsHMN() { return Index(c) } else { @@ -95,24 +99,24 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt panic("route not implemented") } }) - staticPages.GET("^/manifesto$", Manifesto) - staticPages.GET("^/about$", About) - staticPages.GET("^/code-of-conduct$", CodeOfConduct) - staticPages.GET("^/communication-guidelines$", CommunicationGuidelines) - staticPages.GET("^/contact$", ContactPage) - staticPages.GET("^/monthly-update-policy$", MonthlyUpdatePolicy) - staticPages.GET("^/project-guidelines$", ProjectSubmissionGuidelines) + staticPages.GET(hmnurl.RegexManifesto, Manifesto) + staticPages.GET(hmnurl.RegexAbout, About) + staticPages.GET(hmnurl.RegexCodeOfConduct, CodeOfConduct) + staticPages.GET(hmnurl.RegexCommunicationGuidelines, CommunicationGuidelines) + staticPages.GET(hmnurl.RegexContactPage, ContactPage) + staticPages.GET(hmnurl.RegexMonthlyUpdatePolicy, MonthlyUpdatePolicy) + staticPages.GET(hmnurl.RegexProjectSubmissionGuidelines, ProjectSubmissionGuidelines) - mainRoutes.GET(`^/feed(/(?P.+)?)?$`, Feed) + mainRoutes.GET(hmnurl.RegexFeed, Feed) // TODO(asaf): Trailing slashes break these - mainRoutes.GET(`^/(?Pforums(/[^\d]+?)*)/t/(?P\d+)(/(?P\d+))?$`, ForumThread) + mainRoutes.GET(hmnurl.RegexForumThread, ForumThread) // mainRoutes.GET(`^/(?Pforums(/cat)*)/t/(?P\d+)/p/(?P\d+)$`, ForumPost) - mainRoutes.GET(`^/(?Pforums(/[^\d]+?)*)(/(?P\d+))?$`, ForumCategory) + mainRoutes.GET(hmnurl.RegexForumCategory, ForumCategory) - mainRoutes.GET("^/assets/project.css$", ProjectCSS) + mainRoutes.GET(hmnurl.RegexProjectCSS, ProjectCSS) - mainRoutes.AnyMethod("", FourOhFour) + mainRoutes.AnyMethod(hmnurl.RegexCatchAll, FourOhFour) return router } diff --git a/src/website/urls.go b/src/website/urls.go index 152c021..7fce5fa 100644 --- a/src/website/urls.go +++ b/src/website/urls.go @@ -23,7 +23,9 @@ func GetAllCategoryUrls(ctx context.Context, conn *pgxpool.Pool) map[int]string FROM handmade_category AS cat JOIN handmade_project AS project ON project.id = cat.project_id - `, + WHERE + cat.kind != 6 + `, // TODO(asaf): Clean up the db and remove the cat.kind != 6 check ) if err != nil { panic(err) @@ -42,7 +44,8 @@ func GetProjectCategoryUrls(ctx context.Context, conn *pgxpool.Pool, projectId . JOIN handmade_project AS project ON project.id = cat.project_id WHERE project.id = ANY ($1) - `, + AND cat.kind != 6 + `, // TODO(asaf): Clean up the db and remove the cat.kind != 6 check projectId, ) if err != nil { @@ -91,24 +94,16 @@ func makeCategoryUrls(rows []interface{}) map[int]string { } func CategoryUrl(subdomain string, cats ...*models.Category) string { - path := "" - for i, cat := range cats { - if i == 0 { - switch cat.Kind { - case models.CatKindBlog: - path += "/blogs" - case models.CatKindForum: - path += "/forums" - // TODO: All cat types? - default: - return "" - } - } else { - path += "/" + *cat.Slug - } + catNames := make([]string, 0, len(cats)) + for _, cat := range cats { + catNames = append(catNames, *cat.Name) + } + switch cats[0].Kind { + case models.CatKindForum: + return hmnurl.BuildForumCategory(subdomain, catNames[1:], 1) + default: + return "" } - - return hmnurl.ProjectUrl(path, nil, subdomain) } func PostUrl(post models.Post, catKind models.CategoryKind, categoryUrl string) string {