From 3eb04b1a2bdf9e53231fe862cfd76327371a9741 Mon Sep 17 00:00:00 2001 From: gramanas Date: Sat, 12 Sep 2026 11:24:17 +0300 Subject: 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 Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A --- internal/web/handlers_posts.go | 158 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 internal/web/handlers_posts.go (limited to 'internal/web/handlers_posts.go') diff --git a/internal/web/handlers_posts.go b/internal/web/handlers_posts.go new file mode 100644 index 0000000..8678985 --- /dev/null +++ b/internal/web/handlers_posts.go @@ -0,0 +1,158 @@ +package web + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "github.com/gramanas/blogspace/internal/markdown" + "github.com/gramanas/blogspace/internal/slug" + "github.com/gramanas/blogspace/internal/store" +) + +func (s *Server) handlePosts(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + pageID, _ := strconv.ParseInt(r.URL.Query().Get("page"), 10, 64) + pages, err := s.st.ListPages(r.Context(), blog.ID) + if err != nil { + s.serverError(w, err) + return + } + posts, err := s.st.ListPosts(r.Context(), blog.ID, pageID) + if err != nil { + s.serverError(w, err) + return + } + s.render(w, r, "dashboard/posts.html", map[string]any{"posts": posts, "pages": pages, "pageID": pageID}) +} + +func (s *Server) loadPost(w http.ResponseWriter, r *http.Request) *store.Post { + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + p, err := s.st.PostByID(r.Context(), currentBlog(r).ID, id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + } else { + s.serverError(w, err) + } + return nil + } + return p +} + +func (s *Server) handlePostForm(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + pages, err := s.st.ListPages(r.Context(), blog.ID) + if err != nil { + s.serverError(w, err) + return + } + p := &store.Post{Published: true} + if r.PathValue("id") != "" { + if p = s.loadPost(w, r); p == nil { + return + } + } else if pid, _ := strconv.ParseInt(r.URL.Query().Get("page"), 10, 64); pid != 0 { + p.PageID = pid + } else if hp, err := s.st.HomePage(r.Context(), blog.ID); err == nil { + p.PageID = hp.ID + } + s.render(w, r, "dashboard/post_form.html", map[string]any{"post": p, "pages": pages}) +} + +func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + pages, err := s.st.ListPages(r.Context(), blog.ID) + if err != nil { + s.serverError(w, err) + return + } + p := &store.Post{} + if r.PathValue("id") != "" { + if p = s.loadPost(w, r); p == nil { + return + } + } + p.Title = strings.TrimSpace(r.FormValue("title")) + p.Slug = strings.TrimSpace(r.FormValue("slug")) + p.BodyMD = strings.ReplaceAll(r.FormValue("body"), "\r\n", "\n") + p.Published = r.FormValue("published") == "on" + p.PageID, _ = strconv.ParseInt(r.FormValue("page_id"), 10, 64) + autoSlug := p.Slug == "" + if autoSlug { + p.Slug = slug.Make(p.Title) + } + fail := func(status int, msg string) { + s.renderStatus(w, r, status, "dashboard/post_form.html", map[string]any{"post": p, "pages": pages, "error": msg}) + } + pageOK := false + for _, pg := range pages { + if pg.ID == p.PageID { + pageOK = true + } + } + switch { + case p.Title == "" || len(p.Title) > 200: + fail(http.StatusBadRequest, "Title is required (max 200 characters).") + return + case !slug.Valid(p.Slug): + fail(http.StatusBadRequest, "Slug may only contain lowercase letters, digits and dashes.") + return + case !pageOK: + fail(http.StatusBadRequest, "Pick a page for this post.") + return + case len(p.BodyMD) > 200_000: + fail(http.StatusBadRequest, "Post is too long (200 KB max).") + return + } + p.BodyHTML = markdown.Render(p.BodyMD) + base := p.Slug + for n := 1; ; n++ { // generated slugs get -2, -3… on collision; typed ones report the conflict + p.Slug = slug.WithSuffix(base, n) + if p.ID == 0 { + var created *store.Post + if created, err = s.st.CreatePost(r.Context(), p); err == nil { + p = created + } + } else { + err = s.st.UpdatePost(r.Context(), p) + } + if !errors.Is(err, store.ErrConflict) || !autoSlug || n >= 50 { + break + } + } + if err != nil { + if errors.Is(err, store.ErrConflict) { + fail(http.StatusConflict, "A post with that slug already exists on this page; choose another slug.") + return + } + s.serverError(w, err) + return + } + redirectOK(w, r, "/b/"+blog.Subdomain+"/posts/"+strconv.FormatInt(p.ID, 10)+"/edit", "Saved. Refresh your blog to see it.") +} + +func (s *Server) handlePostDeleteConfirm(w http.ResponseWriter, r *http.Request) { + p := s.loadPost(w, r) + if p == nil { + return + } + s.render(w, r, "dashboard/confirm.html", map[string]any{ + "what": "the post \"" + p.Title + "\"", + "action": r.URL.Path, + "back": "/b/" + currentBlog(r).Subdomain + "/posts", + }) +} + +func (s *Server) handlePostDelete(w http.ResponseWriter, r *http.Request) { + p := s.loadPost(w, r) + if p == nil { + return + } + if err := s.st.DeletePost(r.Context(), currentBlog(r).ID, p.ID); err != nil { + s.serverError(w, err) + return + } + redirectOK(w, r, "/b/"+currentBlog(r).Subdomain+"/posts", "Post deleted.") +} -- cgit v1.2.3