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/server.go | 251 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 internal/web/server.go (limited to 'internal/web/server.go') diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..e8a5e6d --- /dev/null +++ b/internal/web/server.go @@ -0,0 +1,251 @@ +// Package web is the HTTP layer: host routing, handlers and templates. +package web + +import ( + "context" + "errors" + "fmt" + "log" + "net" + "net/http" + "strings" + + "github.com/gramanas/blogspace/internal/auth" + "github.com/gramanas/blogspace/internal/config" + "github.com/gramanas/blogspace/internal/store" +) + +// Subdomains that can never be blogs (kept free for infrastructure). +var reservedSubdomains = map[string]bool{"www": true, "admin": true, "api": true, "mail": true, "static": true, "media": true, "ftp": true, "smtp": true} + +type Server struct { + cfg *config.Config + st *store.Store + tpl *templates + root http.Handler + blog http.Handler +} + +func NewServer(cfg *config.Config, st *store.Store) *Server { + s := &Server{cfg: cfg, st: st, tpl: newTemplates(cfg.Dev, funcs)} + s.root = s.rootRoutes() + s.blog = s.blogRoutes() + return s +} + +// ServeHTTP dispatches on the Host header: the base domain is the management +// site, one label below it is a blog, anything else is a 404. +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + host := hostname(r.Host) + switch { + case host == s.cfg.BaseDomain, host == "www."+s.cfg.BaseDomain: + s.root.ServeHTTP(w, r) + case strings.HasSuffix(host, "."+s.cfg.BaseDomain): + sub := strings.TrimSuffix(host, "."+s.cfg.BaseDomain) + if strings.Contains(sub, ".") || reservedSubdomains[sub] { + http.NotFound(w, r) + return + } + blog, err := s.st.BlogBySubdomain(r.Context(), sub) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + s.plainError(w, http.StatusNotFound, "No blog here (yet).") + return + } + s.serverError(w, err) + return + } + s.blog.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxBlog, blog))) + default: + s.plainError(w, http.StatusNotFound, fmt.Sprintf("Unknown host %q. Blogs live at .%s", host, s.cfg.BaseDomain)) + } +} + +func hostname(h string) string { + if host, _, err := net.SplitHostPort(h); err == nil { + h = host + } + return strings.ToLower(strings.TrimSuffix(h, ".")) +} + +// ---- context keys -------------------------------------------------------- + +type ctxKey int + +const ( + ctxUser ctxKey = iota + ctxBlog +) + +func currentUser(r *http.Request) *store.User { + u, _ := r.Context().Value(ctxUser).(*store.User) + return u +} + +func currentBlog(r *http.Request) *store.Blog { + b, _ := r.Context().Value(ctxBlog).(*store.Blog) + return b +} + +// ---- middleware ---------------------------------------------------------- + +// session loads the user from the JWT cookie (if any) into the context. +func (s *Server) session(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie(auth.CookieName) + if err != nil || c.Value == "" { + next.ServeHTTP(w, r) + return + } + claims, err := auth.ParseToken(s.cfg.JWTSecret, c.Value) + if err != nil { + auth.ClearSessionCookie(w) + next.ServeHTTP(w, r) + return + } + u, err := s.st.UserByID(r.Context(), claims.UserID) + if err != nil || u.Disabled || u.TokenVersion != claims.TokenVersion { + auth.ClearSessionCookie(w) + next.ServeHTTP(w, r) + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxUser, u))) + }) +} + +// requireAuth redirects anonymous users to the login page. +func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + if u == nil { + http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther) + return + } + if r.Method == http.MethodPost { + // Cap the request body before any form parsing (uploads included). + r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUploadBytes+1<<20) + if err := parseForm(r); err != nil { + var tooBig *http.MaxBytesError + if errors.As(err, &tooBig) { + s.plainError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("Upload too large: the limit is %d MB.", s.cfg.MaxUploadBytes>>20)) + return + } + s.plainError(w, http.StatusBadRequest, "Could not read the form.") + return + } + if !auth.CheckCSRF(s.cfg.JWTSecret, u.ID, u.TokenVersion, r.FormValue("_csrf")) { + s.plainError(w, http.StatusForbidden, "Form expired or invalid. Go back, reload the page and try again.") + return + } + } + next(w, r) + } +} + +// parseForm parses urlencoded or multipart bodies, surfacing size errors. +func parseForm(r *http.Request) error { + ct := r.Header.Get("Content-Type") + if strings.HasPrefix(ct, "multipart/form-data") { + return r.ParseMultipartForm(1 << 20) + } + return r.ParseForm() +} + +func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc { + return s.requireAuth(func(w http.ResponseWriter, r *http.Request) { + if !currentUser(r).IsSuperadmin() { + s.plainError(w, http.StatusForbidden, "Superadmin only.") + return + } + next(w, r) + }) +} + +// withBlog resolves /b/{sub}/... and enforces owner-or-superadmin. +func (s *Server) withBlog(next http.HandlerFunc) http.HandlerFunc { + return s.requireAuth(func(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + blog, err := s.st.BlogBySubdomain(r.Context(), r.PathValue("sub")) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + return + } + s.serverError(w, err) + return + } + if blog.OwnerID != u.ID && !u.IsSuperadmin() { + s.plainError(w, http.StatusForbidden, "This is not your blog.") + return + } + next(w, r.WithContext(context.WithValue(r.Context(), ctxBlog, blog))) + }) +} + +// ---- rendering helpers --------------------------------------------------- + +// view is the common data every template receives; page data goes in Data. +type view struct { + User *store.User + CSRF string + Flash string + Error string + Blog *store.Blog + BlogURL string + RootURL string + Path string + Data map[string]any +} + +func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data map[string]any) { + s.renderStatus(w, r, http.StatusOK, name, data) +} + +func (s *Server) renderStatus(w http.ResponseWriter, r *http.Request, status int, name string, data map[string]any) { + if data == nil { + data = map[string]any{} + } + v := view{User: currentUser(r), Blog: currentBlog(r), RootURL: s.cfg.RootURL(), Path: r.URL.Path, Data: data} + if v.User != nil { + v.CSRF = auth.CSRFToken(s.cfg.JWTSecret, v.User.ID, v.User.TokenVersion) + } + if v.Blog != nil { + v.BlogURL = s.cfg.BlogURL(v.Blog.Subdomain) + } + v.Flash = r.URL.Query().Get("ok") + if e, ok := data["error"].(string); ok { + v.Error = e + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + if err := s.tpl.render(w, name, v); err != nil { + log.Printf("render %s: %v", name, err) + fmt.Fprintf(w, "
template error: %v
", err) + } +} + +func (s *Server) serverError(w http.ResponseWriter, err error) { + log.Printf("error: %v", err) + s.plainError(w, http.StatusInternalServerError, "Something went wrong.") +} + +func (s *Server) plainError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + fmt.Fprintf(w, `%d

%d %s

%s

Home

`, + status, status, http.StatusText(status), htmlEscape(msg)) +} + +func htmlEscape(s string) string { + r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """) + return r.Replace(s) +} + +// redirectOK redirects with a flash message shown by the layout. +func redirectOK(w http.ResponseWriter, r *http.Request, to, msg string) { + sep := "?" + if strings.Contains(to, "?") { + sep = "&" + } + http.Redirect(w, r, to+sep+"ok="+urlQuery(msg), http.StatusSeeOther) +} -- cgit v1.2.3