Part 1 of URL robustification
This commit is contained in:
parent
dca101fd20
commit
06bbc2b9cc
|
@ -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"))
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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("")
|
|
@ -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.
|
||||
|
|
|
@ -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"`
|
||||
|
||||
|
|
|
@ -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))
|
||||
}
|
|
@ -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,17 +151,19 @@ 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 {
|
||||
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:
|
||||
|
@ -149,13 +172,14 @@ func Feed(c *RequestContext) ResponseData {
|
|||
}
|
||||
breadcrumbs = append(breadcrumbs, templates.Breadcrumb{
|
||||
Name: name,
|
||||
Url: categoryUrls[parent.ID],
|
||||
Url: getCategoryUrl(postResult.Proj.Subdomain(), cat),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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<page>.+)?)?$`, Feed)
|
||||
mainRoutes.GET(hmnurl.RegexFeed, Feed)
|
||||
|
||||
// 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(/[^\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
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
catNames := make([]string, 0, len(cats))
|
||||
for _, cat := range cats {
|
||||
catNames = append(catNames, *cat.Name)
|
||||
}
|
||||
switch cats[0].Kind {
|
||||
case models.CatKindForum:
|
||||
path += "/forums"
|
||||
// TODO: All cat types?
|
||||
return hmnurl.BuildForumCategory(subdomain, catNames[1:], 1)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
} else {
|
||||
path += "/" + *cat.Slug
|
||||
}
|
||||
}
|
||||
|
||||
return hmnurl.ProjectUrl(path, nil, subdomain)
|
||||
}
|
||||
|
||||
func PostUrl(post models.Post, catKind models.CategoryKind, categoryUrl string) string {
|
||||
|
|
Loading…
Reference in New Issue