package web import ( "encoding/xml" "errors" "net/http" "regexp" "strconv" "strings" "time" "unicode/utf8" "github.com/gramanas/blogspace/internal/store" ) // blogView gathers what every public page needs: theme css, the layout with // its modules and their data, the menu, announcements, the blog. func (s *Server) blogView(r *http.Request) (map[string]any, error) { blog := currentBlog(r) ctx := r.Context() pages, err := blogStore(r).ListPages(ctx) if err != nil { return nil, err } menu, err := blogStore(r).ListMenu(ctx) if err != nil { return nil, err } mods, err := blogStore(r).ListModules(ctx) if err != nil { return nil, err } sections, err := blogStore(r).EnabledSections(ctx) if err != nil { return nil, err } theme := ParseTheme(blog.ThemeJSON) layout := buildLayout(theme, mods) v := map[string]any{"theme": theme, "css": theme.CSS(), "layout": layout, "menu": menu, "pages": pages} // Module data is only fetched when a visible module shows it. if layout.NeedsArchive() { index, err := blogStore(r).PublishedPostIndex(ctx) if err != nil { return nil, err } v["archive"] = groupArchive(index) } if n := layout.MaxRecent(); n > 0 { recent, err := blogStore(r).RecentPublishedPosts(ctx, n) if err != nil { return nil, err } v["recent"] = recent } if layout.NeedsTags() { counts, err := blogStore(r).TagCounts(ctx) if err != nil { return nil, err } v["tags"], v["tagcloud"] = topTags(counts), cloudSizes(counts) } v["notices"] = placeNotices(sections, layout) return v, nil } // pageNum reads the ?p= of a paginated listing (1 when absent or silly). func pageNum(r *http.Request) int { n, _ := strconv.Atoi(r.URL.Query().Get("p")) if n < 1 { n = 1 } return n } func (s *Server) handleBlogHome(w http.ResponseWriter, r *http.Request) { page, err := blogStore(r).HomePage(r.Context()) if err != nil { s.blogNotFound(w, r) return } s.renderPage(w, r, page) } func (s *Server) handleBlogPage(w http.ResponseWriter, r *http.Request) { page, err := blogStore(r).PageBySlug(r.Context(), r.PathValue("page")) if err != nil { s.blogNotFound(w, r) return } if page.IsHome { // canonical URL for the home page is / http.Redirect(w, r, "/", http.StatusMovedPermanently) return } s.renderPage(w, r, page) } func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, page *store.Page) { v, err := s.blogView(r) if err != nil { s.serverError(w, err) return } n, per := pageNum(r), v["theme"].(Theme).PostsPerPage posts, total, err := blogStore(r).PublishedPosts(r.Context(), page.ID, per, (n-1)*per) if err != nil { s.serverError(w, err) return } last := (total + per - 1) / per fillExcerpts(v["theme"].(Theme), posts) v["page"], v["posts"], v["pageNum"], v["lastPage"] = page, posts, n, last // The pager appends ?p=N to base: no trailing slash, which /{page} would not match. v["base"] = "/" + page.Slug if page.IsHome { v["base"] = "/" } s.render(w, r, "blog/page.html", v) } // handleBlogTag lists the published posts carrying a tag, from every page of // the blog. A tag that is only on hidden posts is as good as absent. func (s *Server) handleBlogTag(w http.ResponseWriter, r *http.Request) { tag, err := blogStore(r).TagBySlug(r.Context(), r.PathValue("tag")) if err != nil { s.blogNotFound(w, r) return } v, err := s.blogView(r) if err != nil { s.serverError(w, err) return } n, per := pageNum(r), v["theme"].(Theme).PostsPerPage posts, total, err := blogStore(r).PublishedPostsByTag(r.Context(), tag.ID, per, (n-1)*per) if err != nil { s.serverError(w, err) return } if total == 0 { s.blogNotFound(w, r) return } last := (total + per - 1) / per fillExcerpts(v["theme"].(Theme), posts) v["tag"], v["posts"], v["pageNum"], v["lastPage"], v["base"] = tag, posts, n, last, "/tag/"+tag.Slug s.render(w, r, "blog/tag.html", v) } // handleBlogSearch lists the published posts matching ?q= (see searchPattern), // from every page of the blog. A blank query shows just the box; no matches // is an ordinary result, not a 404. func (s *Server) handleBlogSearch(w http.ResponseWriter, r *http.Request) { q := strings.TrimSpace(r.URL.Query().Get("q")) if utf8.RuneCountInString(q) > maxSearchRunes { q = string([]rune(q)[:maxSearchRunes]) } v, err := s.blogView(r) if err != nil { s.serverError(w, err) return } v["q"] = q pattern := searchPattern(q) if pattern != "" { n := pageNum(r) posts, total, err := blogStore(r).SearchPublishedPosts(r.Context(), pattern, searchPerPage, (n-1)*searchPerPage) if err != nil { s.serverError(w, err) return } re := regexp.MustCompile("(?is)" + pattern) // (?s): "." crosses lines as it does in Postgres; QuoteMeta output always compiles hits := make([]searchHit, 0, len(posts)) for _, p := range posts { hits = append(hits, searchHit{Post: p, Snippet: searchSnippet(snippetSource(&p), re)}) } v["hits"], v["total"], v["pageNum"], v["lastPage"] = hits, total, n, (total+searchPerPage-1)/searchPerPage } s.render(w, r, "blog/search.html", v) } func (s *Server) handleBlogPost(w http.ResponseWriter, r *http.Request) { page, err := blogStore(r).PageBySlug(r.Context(), r.PathValue("page")) if err != nil { s.blogNotFound(w, r) return } post, err := blogStore(r).PublishedPostBySlug(r.Context(), page.ID, r.PathValue("post")) if err != nil { s.blogNotFound(w, r) return } v, err := s.blogView(r) if err != nil { s.serverError(w, err) return } v["page"], v["post"] = page, post s.render(w, r, "blog/post.html", v) } func (s *Server) blogNotFound(w http.ResponseWriter, r *http.Request) { v, err := s.blogView(r) if err != nil { s.serverError(w, err) return } s.renderStatus(w, r, http.StatusNotFound, "blog/404.html", v) } // ---- RSS --------------------------------------------------------------------- type rss struct { XMLName xml.Name `xml:"rss"` Version string `xml:"version,attr"` Channel struct { Title string `xml:"title"` Link string `xml:"link"` Description string `xml:"description"` Items []rssItem `xml:"item"` } `xml:"channel"` } type rssItem struct { Title string `xml:"title"` Link string `xml:"link"` GUID string `xml:"guid"` PubDate string `xml:"pubDate"` Desc string `xml:"description"` } func (s *Server) handleBlogFeed(w http.ResponseWriter, r *http.Request) { blog := currentBlog(r) posts, err := blogStore(r).RecentPublishedPosts(r.Context(), 30) if err != nil { s.serverError(w, err) return } base := s.cfg.BlogURL(blog.Subdomain) var f rss f.Version = "2.0" f.Channel.Title, f.Channel.Link, f.Channel.Description = blog.Title, base, blog.Tagline for _, p := range posts { link := base + "/" + p.PageSlug + "/" + p.Slug f.Channel.Items = append(f.Channel.Items, rssItem{Title: p.Title, Link: link, GUID: link, PubDate: p.CreatedAt.Format(time.RFC1123Z), Desc: p.BodyHTML}) } w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8") w.Write([]byte(xml.Header)) if err := xml.NewEncoder(w).Encode(f); err != nil && !errors.Is(err, http.ErrHandlerTimeout) { return } }