diff options
| author | gramanas <grm@eyesin.space> | 2026-09-12 11:24:17 +0300 |
|---|---|---|
| committer | gramanas <grm@eyesin.space> | 2026-09-12 11:24:17 +0300 |
| commit | 3eb04b1a2bdf9e53231fe862cfd76327371a9741 (patch) | |
| tree | b38b2d82a47233fd8e0bb18c59e4a8f3dd2412d7 /internal/web/handlers_blog.go | |
| download | blogspace-3eb04b1a2bdf9e53231fe862cfd76327371a9741.tar.gz blogspace-3eb04b1a2bdf9e53231fe862cfd76327371a9741.tar.bz2 blogspace-3eb04b1a2bdf9e53231fe862cfd76327371a9741.zip | |
Initial multi-tenant blog host
Go + Postgres application serving a management dashboard on the base
domain and one public blog per subdomain. Markdown posts organised in
pages, form-based theme customisation, image uploads stored in Postgres,
JWT cookie sessions with CSRF, superadmin user management, RSS feeds.
Docker/compose deployment and a Makefile-driven dev environment with
seed data.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
Diffstat (limited to 'internal/web/handlers_blog.go')
| -rw-r--r-- | internal/web/handlers_blog.go | 150 |
1 files changed, 150 insertions, 0 deletions
diff --git a/internal/web/handlers_blog.go b/internal/web/handlers_blog.go new file mode 100644 index 0000000..b768c4d --- /dev/null +++ b/internal/web/handlers_blog.go @@ -0,0 +1,150 @@ +package web + +import ( + "encoding/xml" + "errors" + "net/http" + "strconv" + "time" + + "github.com/gramanas/blogspace/internal/store" +) + +const postsPerPage = 10 + +// blogView gathers what every public page needs: theme css, nav, the blog. +func (s *Server) blogView(r *http.Request) (map[string]any, error) { + blog := currentBlog(r) + pages, err := s.st.ListPages(r.Context(), blog.ID) + if err != nil { + return nil, err + } + theme := ParseTheme(blog.ThemeJSON) + var nav []store.Page + for _, p := range pages { + if p.ShowInNav && (theme.NavShowHome || !p.IsHome) { + nav = append(nav, p) + } + } + return map[string]any{"theme": theme, "css": theme.CSS(), "nav": nav}, nil +} + +func (s *Server) handleBlogHome(w http.ResponseWriter, r *http.Request) { + page, err := s.st.HomePage(r.Context(), currentBlog(r).ID) + 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 := s.st.PageBySlug(r.Context(), currentBlog(r).ID, 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, _ := strconv.Atoi(r.URL.Query().Get("p")) + if n < 1 { + n = 1 + } + posts, total, err := s.st.PublishedPosts(r.Context(), page.ID, postsPerPage, (n-1)*postsPerPage) + if err != nil { + s.serverError(w, err) + return + } + last := (total + postsPerPage - 1) / postsPerPage + v["page"], v["posts"], v["pageNum"], v["lastPage"] = page, posts, n, last + v["base"] = "/" + page.Slug + if page.IsHome { + v["base"] = "" + } + s.render(w, r, "blog/page.html", v) +} + +func (s *Server) handleBlogPost(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + page, err := s.st.PageBySlug(r.Context(), blog.ID, r.PathValue("page")) + if err != nil { + s.blogNotFound(w, r) + return + } + post, err := s.st.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 := s.st.RecentPublishedPosts(r.Context(), blog.ID, 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 + } +} |
