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_design.go | 155 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 internal/web/handlers_design.go (limited to 'internal/web/handlers_design.go') diff --git a/internal/web/handlers_design.go b/internal/web/handlers_design.go new file mode 100644 index 0000000..601ba34 --- /dev/null +++ b/internal/web/handlers_design.go @@ -0,0 +1,155 @@ +package web + +import ( + "bytes" + "errors" + "io" + "mime/multipart" + "net/http" + "path/filepath" + "strconv" + + "github.com/google/uuid" + "github.com/gramanas/blogspace/internal/store" +) + +var allowedImageTypes = map[string]bool{"image/png": true, "image/jpeg": true, "image/gif": true, "image/webp": true} + +func (s *Server) handleDesignForm(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + images, err := s.st.ListImages(r.Context(), blog.ID) + if err != nil { + s.serverError(w, err) + return + } + s.render(w, r, "dashboard/design.html", map[string]any{"theme": ParseTheme(blog.ThemeJSON), "images": images}) +} + +func (s *Server) handleDesign(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + if err := r.ParseMultipartForm(1 << 20); err != nil && !errors.Is(err, http.ErrNotMultipart) { + s.plainError(w, http.StatusBadRequest, "Upload too large or malformed form.") + return + } + theme := ThemeFromForm(ParseTheme(blog.ThemeJSON), r.Form) + // Optional direct uploads from the design form. + for field, dst := range map[string]*string{"bg_image_file": &theme.BgImage, "header_image_file": &theme.HeaderImage} { + img, err := s.readUpload(r, field) + if err != nil { + s.renderStatus(w, r, http.StatusBadRequest, "dashboard/design.html", map[string]any{"theme": theme, "error": err.Error()}) + return + } + if img != nil { + *dst = img.ID.String() + } + } + if err := s.st.UpdateBlogTheme(r.Context(), blog.ID, theme.JSON()); err != nil { + s.serverError(w, err) + return + } + redirectOK(w, r, "/b/"+blog.Subdomain+"/design", "Design saved. Refresh your blog to see it.") +} + +// readUpload stores the file from a multipart field, returning nil if the field is empty. +func (s *Server) readUpload(r *http.Request, field string) (*store.Image, error) { + if r.MultipartForm == nil { + return nil, nil + } + fhs := r.MultipartForm.File[field] + if len(fhs) == 0 { + return nil, nil + } + return s.storeUpload(r, fhs[0]) +} + +func (s *Server) storeUpload(r *http.Request, fh *multipart.FileHeader) (*store.Image, error) { + if fh.Size > s.cfg.MaxUploadBytes { + return nil, errors.New("image is too large (max " + kbString(s.cfg.MaxUploadBytes) + ")") + } + f, err := fh.Open() + if err != nil { + return nil, err + } + defer f.Close() + var buf bytes.Buffer + if _, err := io.CopyN(&buf, f, s.cfg.MaxUploadBytes+1); err != nil && !errors.Is(err, io.EOF) { + return nil, err + } + if int64(buf.Len()) > s.cfg.MaxUploadBytes { + return nil, errors.New("image is too large (max " + kbString(s.cfg.MaxUploadBytes) + ")") + } + ct := http.DetectContentType(buf.Bytes()) + if !allowedImageTypes[ct] { + return nil, errors.New("only PNG, JPEG, GIF and WebP images are accepted") + } + name := filepath.Base(fh.Filename) + if name == "" || name == "." || len(name) > 120 { + name = "image" + } + return s.st.CreateImage(r.Context(), currentBlog(r).ID, name, ct, buf.Bytes()) +} + +func kbString(n int64) string { + if n >= 1<<20 { + return strconv.FormatInt(n>>20, 10) + " MB" + } + return strconv.FormatInt(n>>10, 10) + " KB" +} + +// ---- image library --------------------------------------------------------- + +func (s *Server) handleImages(w http.ResponseWriter, r *http.Request) { + images, err := s.st.ListImages(r.Context(), currentBlog(r).ID) + if err != nil { + s.serverError(w, err) + return + } + s.render(w, r, "dashboard/images.html", map[string]any{"images": images}) +} + +func (s *Server) handleImageUpload(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + if err := r.ParseMultipartForm(1 << 20); err != nil { + s.plainError(w, http.StatusBadRequest, "Upload too large or malformed form.") + return + } + img, err := s.readUpload(r, "file") + if err != nil { + s.plainError(w, http.StatusBadRequest, err.Error()) + return + } + if img == nil { + s.plainError(w, http.StatusBadRequest, "Choose a file first.") + return + } + redirectOK(w, r, "/b/"+blog.Subdomain+"/images", "Uploaded "+img.Filename+".") +} + +func (s *Server) handleImageDelete(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + http.NotFound(w, r) + return + } + if err := s.st.DeleteImage(r.Context(), blog.ID, id); err != nil { + s.serverError(w, err) + return + } + // Drop dangling references from the theme. + theme := ParseTheme(blog.ThemeJSON) + changed := false + if theme.BgImage == id.String() { + theme.BgImage, changed = "", true + } + if theme.HeaderImage == id.String() { + theme.HeaderImage, changed = "", true + } + if changed { + if err := s.st.UpdateBlogTheme(r.Context(), blog.ID, theme.JSON()); err != nil { + s.serverError(w, err) + return + } + } + redirectOK(w, r, "/b/"+blog.Subdomain+"/images", "Image deleted.") +} -- cgit v1.2.3