diff --git a/src/hmnurl/hmnurl.go b/src/hmnurl/hmnurl.go index ced295c..eb05dfe 100644 --- a/src/hmnurl/hmnurl.go +++ b/src/hmnurl/hmnurl.go @@ -18,6 +18,16 @@ type Q struct { Value string } +func QFromURL(u *url.URL) []Q { + var result []Q + for key, values := range u.Query() { + for _, v := range values { + result = append(result, Q{Name: key, Value: v}) + } + } + return result +} + var baseUrlParsed url.URL var cacheBust string var isTest bool diff --git a/src/hmnurl/hmnurl_test.go b/src/hmnurl/hmnurl_test.go index bd333fc..512404d 100644 --- a/src/hmnurl/hmnurl_test.go +++ b/src/hmnurl/hmnurl_test.go @@ -50,6 +50,8 @@ func TestSiteMap(t *testing.T) { func TestAtomFeed(t *testing.T) { AssertRegexMatch(t, BuildAtomFeed(), RegexAtomFeed, nil) + AssertRegexMatch(t, BuildAtomFeedForProjects(), RegexAtomFeed, map[string]string{"feedtype": "projects"}) + AssertRegexMatch(t, BuildAtomFeedForShowcase(), RegexAtomFeed, map[string]string{"feedtype": "showcase"}) } func TestLoginAction(t *testing.T) { diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go index 2232726..40c25a3 100644 --- a/src/hmnurl/urls.go +++ b/src/hmnurl/urls.go @@ -57,13 +57,6 @@ func BuildSiteMap() string { return Url("/sitemap", nil) } -var RegexAtomFeed = regexp.MustCompile("^/atom$") - -func BuildAtomFeed() string { - defer CatchPanic() - return Url("/atom", nil) -} - // QUESTION(ben): Can we change these routes? var RegexLoginAction = regexp.MustCompile("^/login$") @@ -176,6 +169,23 @@ func BuildFeedWithPage(page int) string { return Url("/feed/"+strconv.Itoa(page), nil) } +var RegexAtomFeed = regexp.MustCompile("^/atom(/(?P.+))?$") + +func BuildAtomFeed() string { + defer CatchPanic() + return Url("/atom", nil) +} + +func BuildAtomFeedForProjects() string { + defer CatchPanic() + return Url("/atom/projects", nil) +} + +func BuildAtomFeedForShowcase() string { + defer CatchPanic() + return Url("/atom/showcase", nil) +} + /* * Podcast */ diff --git a/src/oops/oops.go b/src/oops/oops.go index d2fc317..53d8d0b 100644 --- a/src/oops/oops.go +++ b/src/oops/oops.go @@ -46,14 +46,16 @@ var ZerologStackMarshaler = func(err error) interface{} { if asOops, ok := err.(*Error); ok { return asOops.Stack } - return nil + // NOTE(asaf): If we got here, it means zerolog is trying to output a non-oops error. + // We remove this call and the zerolog caller from the stack. + return Trace()[2:] } func New(wrapped error, format string, args ...interface{}) error { return &Error{ Message: fmt.Sprintf(format, args...), Wrapped: wrapped, - Stack: Trace(), + Stack: Trace()[1:], // NOTE(asaf): Remove the call to New from the stack } } diff --git a/src/templates/mapping.go b/src/templates/mapping.go index 148a2cf..0491478 100644 --- a/src/templates/mapping.go +++ b/src/templates/mapping.go @@ -102,7 +102,7 @@ func UserToTemplate(u *models.User, currentTheme string) User { Blurb: u.Blurb, Signature: u.Signature, AvatarUrl: avatar, - ProfileUrl: hmnurl.Url("m/"+u.Username, nil), + ProfileUrl: hmnurl.BuildMember(u.Username), DarkTheme: u.DarkTheme, Timezone: u.Timezone, diff --git a/src/templates/src/atom.xml b/src/templates/src/atom.xml new file mode 100644 index 0000000..9542ff8 --- /dev/null +++ b/src/templates/src/atom.xml @@ -0,0 +1,34 @@ +{{ noescape "" }} + + {{ .Title }} + {{ .Subtitle }} + + + + {{ .CopyrightStatement }} + Handmade Network site engine v{{ .SiteVersion }} + {{ rfc3339 .Updated }} + {{ .FeedID }} + {{ if .Posts }} + {{ range .Posts }} + + {{ if .PostTypePrefix }}{{ .PostTypePrefix }}: {{ end }}{{ .Title }} + + {{ .UUID }} + {{ rfc3339 .Date }} + {{ rfc3339 .LastEditDate }} + + {{ .User.Name }} + {{ .User.ProfileUrl }} + + {{ .Preview }} + + {{ end }} + {{ else if .Projects }} + {{ range .Projects }} + {{ end }} + {{ else if .Snippets }} + {{ range .Snippets }} + {{ end }} + {{ end }} + diff --git a/src/templates/src/feed.html b/src/templates/src/feed.html index cbeb614..d1574d9 100644 --- a/src/templates/src/feed.html +++ b/src/templates/src/feed.html @@ -20,7 +20,29 @@ {{ range .Posts }} - {{ template "post_list_item.html" . }} +
+ +
+ + +
+ {{ .User.Name }} — {{ timehtml (relativedate .Date) .Date }} +
+ {{ with .Preview }} +
+ {{ noescape . }} +
+ {{ end }} +
+
+ » +
+
{{ end }}
diff --git a/src/templates/src/include/post_list_item.html b/src/templates/src/include/post_list_item.html index d34062d..e43ef2b 100644 --- a/src/templates/src/include/post_list_item.html +++ b/src/templates/src/include/post_list_item.html @@ -13,15 +13,10 @@ It should be called with PostListItem. {{ $breadcrumb.Name }} {{ end }}
- +
{{ .User.Name }} — {{ timehtml (relativedate .Date) .Date }}
- {{ with .Content }} -
- {{ . }} -
- {{ end }}
» diff --git a/src/templates/src/landing.html b/src/templates/src/landing.html index a34a45e..861efd9 100644 --- a/src/templates/src/landing.html +++ b/src/templates/src/landing.html @@ -104,7 +104,7 @@
{{ end }} -{{/* +{{/* TODO(asaf): Delete this section once we're done with the landing page {% block columns %} {% include "showcase/js_templates.html" %} {% include "timeline/js_templates.html" %} @@ -268,7 +268,7 @@ {{/* Call this template with a LandingPageFeaturedPost. */}}
-
+
{{ .User.Name }} — {{ timehtml (relativedate .Date) .Date }} @@ -277,7 +277,7 @@
{{ .Content }}
-
+
Read More → @@ -285,3 +285,25 @@
{{ end }} + +{{ define "post_list_item" }} +{{/* Call this template with a PostListItem. */}} +
+ +
+ + +
+ {{ .User.Name }} — {{ timehtml (relativedate .Date) .Date }} +
+
+
+ » +
+
+{{ end }} diff --git a/src/templates/templates.go b/src/templates/templates.go index 381dedc..cf1628d 100644 --- a/src/templates/templates.go +++ b/src/templates/templates.go @@ -4,7 +4,6 @@ import ( "embed" "fmt" "html/template" - "net/url" "strings" "time" @@ -25,11 +24,7 @@ const ( var templateFs embed.FS var Templates map[string]*template.Template -var cachebust string - func Init() { - cachebust = fmt.Sprint(time.Now().Unix()) - Templates = make(map[string]*template.Template) files, _ := templateFs.ReadDir("src") @@ -53,6 +48,16 @@ func Init() { logging.Fatal().Str("filename", f.Name()).Err(err).Msg("failed to parse template") } + Templates[f.Name()] = t + } else if strings.HasSuffix(f.Name(), ".xml") { + t := template.New(f.Name()) + t = t.Funcs(sprig.FuncMap()) + t = t.Funcs(HMNTemplateFuncs) + t, err := t.ParseFS(templateFs, "src/"+f.Name()) + if err != nil { + logging.Fatal().Str("filename", f.Name()).Err(err).Msg("failed to parse template") + } + Templates[f.Name()] = t } } @@ -68,7 +73,10 @@ func names(ts []*template.Template) []string { var HMNTemplateFuncs = template.FuncMap{ "absolutedate": func(t time.Time) string { - return t.Format("January 2, 2006, 3:04pm") + return t.UTC().Format("January 2, 2006, 3:04pm") + }, + "rfc3339": func(t time.Time) string { + return t.UTC().Format(time.RFC3339) }, "alpha": func(alpha float64, color noire.Color) noire.Color { color.Alpha = alpha @@ -77,9 +85,6 @@ var HMNTemplateFuncs = template.FuncMap{ "brighten": func(amount float64, color noire.Color) noire.Color { return color.Tint(amount) }, - "cachebust": func() string { - return cachebust - }, "color2css": func(color noire.Color) template.CSS { return template.CSS(color.HTML()) }, @@ -96,20 +101,6 @@ var HMNTemplateFuncs = template.FuncMap{ h, s, _, a := color.HSLA() return noire.NewHSLA(h, s, lightness*100, a) }, - "projecturl": func(url string, proj interface{}) string { - return hmnurl.ProjectUrl(url, nil, getProjectSubdomain(proj)) - }, - "projecturlq": func(url string, proj interface{}, query string) string { - absUrl := hmnurl.ProjectUrl(url, nil, getProjectSubdomain(proj)) - return fmt.Sprintf("%s?%s", absUrl, query) - }, - "query": func(args ...string) string { - query := url.Values{} - for i := 0; i < len(args); i += 2 { - query.Set(args[i], args[i+1]) - } - return query.Encode() - }, "relativedate": func(t time.Time) string { // TODO: Support relative future dates @@ -161,9 +152,12 @@ var HMNTemplateFuncs = template.FuncMap{ return hmnurl.BuildTheme(filepath, theme, false) }, "timehtml": func(formatted string, t time.Time) template.HTML { - iso := t.Format(time.RFC3339) + iso := t.UTC().Format(time.RFC3339) return template.HTML(fmt.Sprintf(``, iso, formatted)) }, + "noescape": func(str string) template.HTML { + return template.HTML(str) + }, } type ErrInvalidHexColor struct { diff --git a/src/templates/types.go b/src/templates/types.go index 56c2728..fee8b88 100644 --- a/src/templates/types.go +++ b/src/templates/types.go @@ -135,18 +135,37 @@ type BackgroundImage struct { Size string // A valid CSS background-size value } +type PostType int + +const ( + PostTypeUnknown PostType = iota + PostTypeBlogPost + PostTypeBlogComment + PostTypeForumThread + PostTypeForumReply + PostTypeWikiCreate + PostTypeWikiTalk + PostTypeWikiEdit + PostTypeLibraryComment +) + // Data from post_list_item.html type PostListItem struct { Title string Url string + UUID string Breadcrumbs []Breadcrumb + PostType PostType + PostTypePrefix string + User User Date time.Time - Unread bool - Classes string - Content string + Unread bool + Classes string + Preview string + LastEditDate time.Time } // Data from thread_list_item.html diff --git a/src/website/feed.go b/src/website/feed.go index 3005582..74b7ff3 100644 --- a/src/website/feed.go +++ b/src/website/feed.go @@ -1,11 +1,15 @@ package website import ( + "fmt" "math" "net/http" "strconv" + "strings" "time" + "github.com/google/uuid" + "git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/models" @@ -76,9 +80,123 @@ func Feed(c *RequestContext) ResponseData { currentUserId = &c.CurrentUser.ID } + c.Perf.StartBlock("SQL", "Fetch category tree") + categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn) + lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree) + c.Perf.EndBlock() + + posts, err := fetchAllPosts(c, lineageBuilder, currentUserId, howManyPostsToSkip, postsPerPage) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch feed posts")) + } + + baseData := getBaseData(c) + baseData.BodyClasses = append(baseData.BodyClasses, "feed") + + var res ResponseData + res.WriteTemplate("feed.html", FeedData{ + BaseData: baseData, + + AtomFeedUrl: hmnurl.BuildAtomFeed(), + MarkAllReadUrl: hmnurl.BuildMarkRead(0), + Posts: posts, + Pagination: pagination, + }, c.Perf) + + return res +} + +type FeedType int + +const ( + FeedTypeAll = iota + FeedTypeProjects + FeedTypeShowcase +) + +// NOTE(asaf): UUID values copied from old website +var ( + FeedIDAll = "urn:uuid:1084fd28-993a-4961-9011-39ddeaeb3711" + FeedIDProjects = "urn:uuid:cfad0d50-cbcf-11e7-82d7-db1d52543cc7" + FeedIDShowcase = "urn:uuid:37d29027-2892-5a21-b521-951246c7aa46" +) + +type AtomFeedData struct { + Title string + Subtitle string + + HomepageUrl string + AtomFeedUrl string + FeedUrl string + + CopyrightStatement string + SiteVersion string + Updated time.Time + FeedID string + + FeedType FeedType + Posts []templates.PostListItem + Projects []int // TODO(asaf): Actually do this + Snippets []int // TODO(asaf): Actually do this +} + +func AtomFeed(c *RequestContext) ResponseData { + itemsPerFeed := 25 // NOTE(asaf): Copied from old website + + feedData := AtomFeedData{ + HomepageUrl: hmnurl.BuildHomepage(), + + CopyrightStatement: fmt.Sprintf("Copyright (C) 2014-%d Handmade.Network and its contributors", time.Now().Year()), + SiteVersion: "2.0", + } + + feedType, hasType := c.PathParams["feedtype"] + if !hasType || len(feedType) == 0 { + feedData.Title = "New Threads, Blog Posts, Replies and Comments | Site-wide | Handmade.Network" + feedData.Subtitle = feedData.Title + feedData.FeedType = FeedTypeAll + feedData.FeedID = FeedIDAll + feedData.AtomFeedUrl = hmnurl.BuildAtomFeed() + feedData.FeedUrl = hmnurl.BuildFeed() + + c.Perf.StartBlock("SQL", "Fetch category tree") + categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn) + lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree) + c.Perf.EndBlock() + + posts, err := fetchAllPosts(c, lineageBuilder, nil, 0, itemsPerFeed) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch feed posts")) + } + feedData.Posts = posts + + updated := time.Now() + if len(posts) > 0 { + updated = posts[0].Date + } + feedData.Updated = updated + } else { + switch strings.ToLower(feedType) { + case "projects": + // TODO(asaf): Implement this + case "showcase": + // TODO(asaf): Implement this + default: + return FourOhFour(c) + } + } + + var res ResponseData + res.WriteTemplate("atom.xml", feedData, c.Perf) + + return res +} + +func fetchAllPosts(c *RequestContext, lineageBuilder *models.CategoryLineageBuilder, currentUserID *int, offset int, limit int) ([]templates.PostListItem, error) { c.Perf.StartBlock("SQL", "Fetch posts") type feedPostQuery struct { Post models.Post `db:"post"` + PostVersion models.PostVersion `db:"version"` Thread models.Thread `db:"thread"` Cat models.Category `db:"cat"` Proj models.Project `db:"proj"` @@ -92,6 +210,7 @@ func Feed(c *RequestContext) ResponseData { SELECT $columns FROM handmade_post AS post + JOIN handmade_postversion AS version ON version.id = post.current_id JOIN handmade_thread AS thread ON thread.id = post.thread_id JOIN handmade_category AS cat ON cat.id = post.category_id JOIN handmade_project AS proj ON proj.id = post.project_id @@ -112,21 +231,16 @@ func Feed(c *RequestContext) ResponseData { ORDER BY postdate DESC LIMIT $3 OFFSET $4 `, - currentUserId, + currentUserID, []models.CategoryKind{models.CatKindForum, models.CatKindBlog, models.CatKindWiki, models.CatKindLibraryResource}, - postsPerPage, - howManyPostsToSkip, + limit, + offset, ) c.Perf.EndBlock() if err != nil { - return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch feed posts")) + return nil, err } - c.Perf.StartBlock("SQL", "Fetch category tree") - categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn) - lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree) - c.Perf.EndBlock() - c.Perf.StartBlock("FEED", "Build post items") var postItems []templates.PostListItem for _, iPostResult := range posts.ToSlice() { @@ -139,7 +253,7 @@ func Feed(c *RequestContext) ResponseData { hasRead = true } - postItems = append(postItems, MakePostListItem( + postItem := MakePostListItem( lineageBuilder, &postResult.Proj, &postResult.Thread, @@ -149,22 +263,14 @@ func Feed(c *RequestContext) ResponseData { !hasRead, true, c.Theme, - )) + ) + + postItem.UUID = uuid.NewSHA1(uuid.NameSpaceURL, []byte(postItem.Url)).URN() + postItem.LastEditDate = postResult.PostVersion.EditDate + + postItems = append(postItems, postItem) } c.Perf.EndBlock() - baseData := getBaseData(c) - baseData.BodyClasses = append(baseData.BodyClasses, "feed") - - var res ResponseData - res.WriteTemplate("feed.html", FeedData{ - BaseData: baseData, - - AtomFeedUrl: hmnurl.BuildAtomFeed(), - MarkAllReadUrl: hmnurl.BuildMarkRead(0), - Posts: postItems, - Pagination: pagination, - }, c.Perf) - - return res + return postItems, nil } diff --git a/src/website/post_helper.go b/src/website/post_helper.go index fd21045..3f9fc19 100644 --- a/src/website/post_helper.go +++ b/src/website/post_helper.go @@ -29,6 +29,24 @@ func UrlForGenericPost(post *models.Post, subforums []string, threadTitle string return hmnurl.BuildProjectHomepage(projectSlug) } +var PostTypeMap = map[models.CategoryKind][]templates.PostType{ + models.CatKindBlog: []templates.PostType{templates.PostTypeBlogPost, templates.PostTypeBlogComment}, + models.CatKindForum: []templates.PostType{templates.PostTypeForumThread, templates.PostTypeForumReply}, + models.CatKindWiki: []templates.PostType{templates.PostTypeWikiCreate, templates.PostTypeWikiTalk}, + models.CatKindLibraryResource: []templates.PostType{templates.PostTypeLibraryComment, templates.PostTypeLibraryComment}, +} + +var PostTypePrefix = map[templates.PostType]string{ + templates.PostTypeBlogPost: "New blog post", + templates.PostTypeBlogComment: "Blog comment", + templates.PostTypeForumThread: "New forum thread", + templates.PostTypeForumReply: "Forum reply", + templates.PostTypeWikiCreate: "New wiki page", + templates.PostTypeWikiTalk: "Wiki comment", + templates.PostTypeWikiEdit: "Wiki edit", + templates.PostTypeLibraryComment: "Library comment", +} + // NOTE(asaf): THIS DOESN'T HANDLE WIKI EDIT ITEMS. Wiki edits are PostTextVersions, not Posts. func MakePostListItem(lineageBuilder *models.CategoryLineageBuilder, project *models.Project, thread *models.Thread, post *models.Post, user *models.User, libraryResource *models.LibraryResource, unread bool, includeBreadcrumbs bool, currentTheme string) templates.PostListItem { var result templates.PostListItem @@ -42,6 +60,19 @@ func MakePostListItem(lineageBuilder *models.CategoryLineageBuilder, project *mo libraryResourceId = libraryResource.ID } result.Url = UrlForGenericPost(post, lineageBuilder.GetSubforumLineageSlugs(post.CategoryID), thread.Title, libraryResourceId, project.Slug) + result.Preview = post.Preview + + postType := templates.PostTypeUnknown + postTypeOptions, found := PostTypeMap[post.CategoryKind] + if found { + var hasParent int + if post.ParentID != nil { + hasParent = 1 + } + postType = postTypeOptions[hasParent] + } + result.PostType = postType + result.PostTypePrefix = PostTypePrefix[result.PostType] if includeBreadcrumbs { result.Breadcrumbs = append(result.Breadcrumbs, templates.Breadcrumb{ diff --git a/src/website/requesthandling.go b/src/website/requesthandling.go index aa958c2..f8dcd68 100644 --- a/src/website/requesthandling.go +++ b/src/website/requesthandling.go @@ -14,6 +14,7 @@ import ( "git.handmade.network/hmn/hmn/src/logging" "git.handmade.network/hmn/hmn/src/models" + "git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/perf" "git.handmade.network/hmn/hmn/src/templates" "github.com/jackc/pgx/v4/pgxpool" @@ -249,7 +250,11 @@ func (rd *ResponseData) WriteTemplate(name string, data interface{}, rp *perf.Re rp.StartBlock("TEMPLATE", name) defer rp.EndBlock() } - return templates.Templates[name].Execute(rd, data) + template, hasTemplate := templates.Templates[name] + if !hasTemplate { + panic(oops.New(nil, "Template not found: %s", name)) + } + return template.Execute(rd, data) } func ErrorResponse(status int, errs ...error) ResponseData { diff --git a/src/website/routes.go b/src/website/routes.go index 9d0ccf1..eebeafd 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -73,7 +73,7 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt } if !c.CurrentProject.IsHMN() { - return c.Redirect(hmnurl.Url(c.URL().String(), nil), http.StatusMovedPermanently) + return c.Redirect(hmnurl.Url(c.URL().Path, hmnurl.QFromURL(c.URL())), http.StatusMovedPermanently) } return h(c) @@ -104,6 +104,7 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt staticPages.GET(hmnurl.RegexProjectSubmissionGuidelines, ProjectSubmissionGuidelines) mainRoutes.GET(hmnurl.RegexFeed, Feed) + mainRoutes.GET(hmnurl.RegexAtomFeed, AtomFeed) // TODO(asaf): Trailing slashes break these mainRoutes.GET(hmnurl.RegexForumThread, ForumThread)