Part 1 of URL robustification

This commit is contained in:
Asaf Gartner 2021-05-05 23:34:32 +03:00
parent dca101fd20
commit 06bbc2b9cc
10 changed files with 566 additions and 85 deletions

View File

@ -18,7 +18,11 @@ type Q struct {
var baseUrlParsed url.URL var baseUrlParsed url.URL
func init() { 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 { if err != nil {
panic(oops.New(err, "could not parse base URL")) panic(oops.New(err, "could not parse base URL"))
} }

View File

@ -1,6 +1,8 @@
package hmnurl package hmnurl
import ( import (
"net/url"
"regexp"
"testing" "testing"
"git.handmade.network/hmn/hmn/src/config" "git.handmade.network/hmn/hmn/src/config"
@ -8,10 +10,10 @@ import (
) )
func TestUrl(t *testing.T) { func TestUrl(t *testing.T) {
defer func(original string) { defer func() {
config.Config.BaseUrl = original SetGlobalBaseUrl(config.Config.BaseUrl)
}(config.Config.BaseUrl) }()
config.Config.BaseUrl = "http://handmade.test" SetGlobalBaseUrl("http://handmade.test")
t.Run("no query", func(t *testing.T) { t.Run("no query", func(t *testing.T) {
result := Url("/test/foo", nil) 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) 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)
}
}
}

207
src/hmnurl/urls.go Normal file
View File

@ -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<page>.+)?)?$`)
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(`^/(?P<cats>forums(/[^\d]+?)*)/t/(?P<threadid>\d+)(/(?P<page>\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(`^/(?P<cats>forums(/[^\d]+?)*)(/(?P<page>\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("")

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgx/v4/pgxpool" "github.com/jackc/pgx/v4/pgxpool"
) )
@ -33,6 +34,96 @@ type Category struct {
Depth int `db:"depth"` // TODO: What is this? 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] = &current.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 Gets the category and its parent categories, starting from the root and working toward the
category itself. Useful for breadcrumbs and the like. category itself. Useful for breadcrumbs and the like.

View File

@ -9,6 +9,18 @@ const HMNProjectID = 1
var ProjectType = reflect.TypeOf(Project{}) var ProjectType = reflect.TypeOf(Project{})
type ProjectLifecycle int
const (
ProjectLifecycleUnapproved = iota
ProjectLifecycleApprovalRequired
ProjectLifecycleActive
ProjectLifecycleHiatus
ProjectLifecycleDead
ProjectLifecycleLTSRequired
ProjectLifecycleLTS
)
type Project struct { type Project struct {
ID int `db:"id"` ID int `db:"id"`
@ -17,6 +29,8 @@ type Project struct {
Blurb *string `db:"blurb"` Blurb *string `db:"blurb"`
Description *string `db:"description"` Description *string `db:"description"`
Lifecycle ProjectLifecycle `db:"lifecycle"`
Color1 string `db:"color_1"` Color1 string `db:"color_1"`
Color2 string `db:"color_2"` Color2 string `db:"color_2"`

19
src/utils/utils.go Normal file
View File

@ -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))
}

View File

@ -1,7 +1,6 @@
package website package website
import ( import (
"fmt"
"math" "math"
"net/http" "net/http"
"strconv" "strconv"
@ -12,6 +11,7 @@ import (
"git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/templates" "git.handmade.network/hmn/hmn/src/templates"
"git.handmade.network/hmn/hmn/src/utils"
) )
type FeedData struct { type FeedData struct {
@ -32,7 +32,8 @@ func Feed(c *RequestContext) ResponseData {
handmade_post AS post handmade_post AS post
WHERE WHERE
post.category_kind = ANY ($1) 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}, []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 { if pageParsed, err := strconv.Atoi(pageString); err == nil {
page = pageParsed page = pageParsed
} else { } else {
return c.Redirect("/feed", http.StatusSeeOther) return c.Redirect(hmnurl.BuildFeed(), http.StatusSeeOther)
} }
} }
if page < 1 || numPages < page { 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 howManyPostsToSkip := (page - 1) * postsPerPage
@ -62,10 +63,10 @@ func Feed(c *RequestContext) ResponseData {
Current: page, Current: page,
Total: numPages, Total: numPages,
FirstUrl: hmnurl.Url("/feed", nil), FirstUrl: hmnurl.BuildFeed(),
LastUrl: hmnurl.Url(fmt.Sprintf("/feed/%d", numPages), nil), LastUrl: hmnurl.BuildFeedWithPage(numPages),
NextUrl: hmnurl.Url(fmt.Sprintf("/feed/%d", page+1), nil), NextUrl: hmnurl.BuildFeedWithPage(utils.IntClamp(1, page+1, numPages)),
PreviousUrl: hmnurl.Url(fmt.Sprintf("/feed/%d", page-1), nil), PreviousUrl: hmnurl.BuildFeedWithPage(utils.IntClamp(1, page-1, numPages)),
} }
var currentUserId *int var currentUserId *int
@ -117,8 +118,28 @@ func Feed(c *RequestContext) ResponseData {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch feed posts")) 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 var postItems []templates.PostListItem
for _, iPostResult := range posts.ToSlice() { for _, iPostResult := range posts.ToSlice() {
postResult := iPostResult.(*feedPostQuery) postResult := iPostResult.(*feedPostQuery)
@ -130,32 +151,35 @@ func Feed(c *RequestContext) ResponseData {
hasRead = true hasRead = true
} }
parents := postResult.Cat.GetHierarchy(c.Context(), c.Conn) breadcrumbs := make([]templates.Breadcrumb, 0, len(lineageBuilder.GetLineage(postResult.Cat.ID)))
var breadcrumbs []templates.Breadcrumb
breadcrumbs = append(breadcrumbs, templates.Breadcrumb{ breadcrumbs = append(breadcrumbs, templates.Breadcrumb{
Name: *postResult.Proj.Name, Name: *postResult.Proj.Name,
Url: hmnurl.ProjectUrl("/", nil, postResult.Proj.Subdomain()), Url: hmnurl.ProjectUrl("/", nil, postResult.Proj.Subdomain()),
}) })
for _, parent := range parents { if postResult.Post.CategoryKind == models.CatKindLibraryResource {
name := *parent.Name // TODO(asaf): Fetch library root topic for the project and construct breadcrumb for it
if parent.ParentID == nil { } else {
switch parent.Kind { lineage := lineageBuilder.GetLineage(postResult.Cat.ID)
case models.CatKindForum: for i, cat := range lineage {
name = "Forums" name := *cat.Name
case models.CatKindBlog: if i == 0 {
name = "Blog" 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{ postItems = append(postItems, templates.PostListItem{
Title: postResult.Thread.Title, 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), User: templates.UserToTemplate(&postResult.User),
Date: postResult.Post.PostDate, Date: postResult.Post.PostDate,
Breadcrumbs: breadcrumbs, Breadcrumbs: breadcrumbs,
@ -164,6 +188,7 @@ func Feed(c *RequestContext) ResponseData {
Content: postResult.Post.Preview, Content: postResult.Post.Preview,
}) })
} }
c.Perf.EndBlock()
baseData := getBaseData(c) baseData := getBaseData(c)
baseData.BodyClasses = append(baseData.BodyClasses, "feed") baseData.BodyClasses = append(baseData.BodyClasses, "feed")

View File

@ -46,29 +46,29 @@ func WrapStdHandler(h http.Handler) Handler {
type Middleware func(h 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) h = rb.Middleware(h)
rb.Router.Routes = append(rb.Router.Routes, Route{ rb.Router.Routes = append(rb.Router.Routes, Route{
Method: method, Method: method,
Regex: regexp.MustCompile(regexStr), Regex: regex,
Handler: h, Handler: h,
}) })
} }
func (rb *RouteBuilder) AnyMethod(regexStr string, h Handler) { func (rb *RouteBuilder) AnyMethod(regex *regexp.Regexp, h Handler) {
rb.Handle("", regexStr, h) rb.Handle("", regex, h)
} }
func (rb *RouteBuilder) GET(regexStr string, h Handler) { func (rb *RouteBuilder) GET(regex *regexp.Regexp, h Handler) {
rb.Handle(http.MethodGet, regexStr, h) rb.Handle(http.MethodGet, regex, h)
} }
func (rb *RouteBuilder) POST(regexStr string, h Handler) { func (rb *RouteBuilder) POST(regex *regexp.Regexp, h Handler) {
rb.Handle(http.MethodPost, regexStr, h) rb.Handle(http.MethodPost, regex, h)
} }
func (rb *RouteBuilder) StdHandler(regexStr string, h http.Handler) { func (rb *RouteBuilder) StdHandler(regex *regexp.Regexp, h http.Handler) {
rb.Handle("", regexStr, WrapStdHandler(h)) rb.Handle("", regex, WrapStdHandler(h))
} }
func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) { func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) {

View File

@ -31,9 +31,10 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
logPerf := TrackRequestPerf(c, perfCollector) logPerf := TrackRequestPerf(c, perfCollector)
defer logPerf() 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) logPerf := TrackRequestPerf(c, perfCollector)
defer logPerf() defer logPerf()
defer LogContextErrors(c, res)
ok, errRes := LoadCommonWebsiteData(c) ok, errRes := LoadCommonWebsiteData(c)
if !ok { if !ok {
return errRes 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) logPerf := TrackRequestPerf(c, perfCollector)
defer logPerf() defer logPerf()
defer LogContextErrors(c, res)
ok, errRes := LoadCommonWebsiteData(c) ok, errRes := LoadCommonWebsiteData(c)
if !ok { if !ok {
return errRes return errRes
} }
if !c.CurrentProject.IsHMN() { if !c.CurrentProject.IsHMN() {
res := c.Redirect(hmnurl.Url(c.URL().String(), nil), http.StatusMovedPermanently) res = c.Redirect(hmnurl.Url(c.URL().String(), nil), http.StatusMovedPermanently)
return res return
} }
return h(c) res = h(c)
LogContextErrors(c, res)
return
} }
} }
routes.POST("^/login$", Login) // TODO(asaf): login/logout shouldn't happen on subdomains. We should verify that in the middleware.
routes.GET("^/logout$", Logout) routes.POST(hmnurl.RegexLogin, Login)
routes.StdHandler("^/public/.*$", routes.GET(hmnurl.RegexLogout, Logout)
routes.StdHandler(hmnurl.RegexPublic,
http.StripPrefix("/public/", http.FileServer(http.Dir("public"))), 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() { if c.CurrentProject.IsHMN() {
return Index(c) return Index(c)
} else { } else {
@ -95,24 +99,24 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
panic("route not implemented") panic("route not implemented")
} }
}) })
staticPages.GET("^/manifesto$", Manifesto) staticPages.GET(hmnurl.RegexManifesto, Manifesto)
staticPages.GET("^/about$", About) staticPages.GET(hmnurl.RegexAbout, About)
staticPages.GET("^/code-of-conduct$", CodeOfConduct) staticPages.GET(hmnurl.RegexCodeOfConduct, CodeOfConduct)
staticPages.GET("^/communication-guidelines$", CommunicationGuidelines) staticPages.GET(hmnurl.RegexCommunicationGuidelines, CommunicationGuidelines)
staticPages.GET("^/contact$", ContactPage) staticPages.GET(hmnurl.RegexContactPage, ContactPage)
staticPages.GET("^/monthly-update-policy$", MonthlyUpdatePolicy) staticPages.GET(hmnurl.RegexMonthlyUpdatePolicy, MonthlyUpdatePolicy)
staticPages.GET("^/project-guidelines$", ProjectSubmissionGuidelines) staticPages.GET(hmnurl.RegexProjectSubmissionGuidelines, ProjectSubmissionGuidelines)
mainRoutes.GET(`^/feed(/(?P<page>.+)?)?$`, Feed) mainRoutes.GET(hmnurl.RegexFeed, Feed)
// TODO(asaf): Trailing slashes break these // TODO(asaf): Trailing slashes break these
mainRoutes.GET(`^/(?P<cats>forums(/[^\d]+?)*)/t/(?P<threadid>\d+)(/(?P<page>\d+))?$`, ForumThread) mainRoutes.GET(hmnurl.RegexForumThread, ForumThread)
// mainRoutes.GET(`^/(?P<cats>forums(/cat)*)/t/(?P<threadid>\d+)/p/(?P<postid>\d+)$`, ForumPost) // mainRoutes.GET(`^/(?P<cats>forums(/cat)*)/t/(?P<threadid>\d+)/p/(?P<postid>\d+)$`, ForumPost)
mainRoutes.GET(`^/(?P<cats>forums(/[^\d]+?)*)(/(?P<page>\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 return router
} }

View File

@ -23,7 +23,9 @@ func GetAllCategoryUrls(ctx context.Context, conn *pgxpool.Pool) map[int]string
FROM FROM
handmade_category AS cat handmade_category AS cat
JOIN handmade_project AS project ON project.id = cat.project_id 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 { if err != nil {
panic(err) 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 JOIN handmade_project AS project ON project.id = cat.project_id
WHERE WHERE
project.id = ANY ($1) project.id = ANY ($1)
`, AND cat.kind != 6
`, // TODO(asaf): Clean up the db and remove the cat.kind != 6 check
projectId, projectId,
) )
if err != nil { if err != nil {
@ -91,24 +94,16 @@ func makeCategoryUrls(rows []interface{}) map[int]string {
} }
func CategoryUrl(subdomain string, cats ...*models.Category) string { func CategoryUrl(subdomain string, cats ...*models.Category) string {
path := "" catNames := make([]string, 0, len(cats))
for i, cat := range cats { for _, cat := range cats {
if i == 0 { catNames = append(catNames, *cat.Name)
switch cat.Kind { }
case models.CatKindBlog: switch cats[0].Kind {
path += "/blogs" case models.CatKindForum:
case models.CatKindForum: return hmnurl.BuildForumCategory(subdomain, catNames[1:], 1)
path += "/forums" default:
// TODO: All cat types? return ""
default:
return ""
}
} else {
path += "/" + *cat.Slug
}
} }
return hmnurl.ProjectUrl(path, nil, subdomain)
} }
func PostUrl(post models.Post, catKind models.CategoryKind, categoryUrl string) string { func PostUrl(post models.Post, catKind models.CategoryKind, categoryUrl string) string {