// 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.subdomainRoutes() return s } // ServeHTTP dispatches on the Host header: the base domain is the management // site plus the superadmin's root blog, 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 == config.RootSubdomain+"."+s.cfg.BaseDomain: // The root blog is looked up lazily by hostBlog so management pages work even without one. s.root.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxHostSub, config.RootSubdomain))) 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 } s.blog.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxHostSub, sub))) 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 // the blog a handler operates on (public page, or /b/{sub}/ management) ctxBlogStore // that blog's store, bound to its own database ctxHostSub // subdomain derived from the Host header ) 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 } // blogStore is the store of the blog in the context; only valid behind hostBlog or withBlog. func blogStore(r *http.Request) *store.BlogStore { bs, _ := r.Context().Value(ctxBlogStore).(*store.BlogStore) return bs } // ---- middleware ---------------------------------------------------------- // resolveBlog looks a subdomain up in the registry and opens its database. func (s *Server) resolveBlog(r *http.Request, sub string) (*http.Request, error) { blog, err := s.st.BlogBySubdomain(r.Context(), sub) if err != nil { return r, err } bs, err := s.st.Open(r.Context(), blog) if err != nil { return r, err } ctx := context.WithValue(r.Context(), ctxBlog, blog) return r.WithContext(context.WithValue(ctx, ctxBlogStore, bs)), nil } // hostBlog resolves the blog named by the Host header into the context for public pages. func (s *Server) hostBlog(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { sub, _ := r.Context().Value(ctxHostSub).(string) r, err := s.resolveBlog(r, 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 } next(w, r) } } // 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, "/webadmin?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) r, err := s.resolveBlog(r, r.PathValue("sub")) if err != nil { if errors.Is(err, store.ErrNotFound) { http.NotFound(w, r) return } s.serverError(w, err) return } if blog := currentBlog(r); blog.OwnerID != u.ID && !u.IsSuperadmin() { s.plainError(w, http.StatusForbidden, "This is not your blog.") return } next(w, r) }) } // ---- 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) { to, frag, _ := strings.Cut(to, "#") // keep a #anchor after the query sep := "?" if strings.Contains(to, "?") { sep = "&" } to += sep + "ok=" + urlQuery(msg) if frag != "" { to += "#" + frag } http.Redirect(w, r, to, http.StatusSeeOther) }