// 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/i18n" "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 // Anonymous endpoints worth abusing get a token bucket each (ratelimit.go). loginLimit *limiter // per client address and per username: 10 guesses, then 10 a minute } // logf is log.Printf, a variable so tests can silence it. var logf = log.Printf func NewServer(cfg *config.Config, st *store.Store) *Server { s := &Server{cfg: cfg, st: st, tpl: newTemplates(cfg.Dev), loginLimit: newLimiter(10, 10)} 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) { if s.cfg.HTTPS { // every subdomain is ours, so the whole site may pin https w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") } 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 ctxLang // language of the page, once a middleware has settled it ) 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, which speak the blog's language. 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, withLang(r, currentBlog(r).Language)) } } func withLang(r *http.Request, lang string) *http.Request { return r.WithContext(context.WithValue(r.Context(), ctxLang, lang)) } // lang is the language of the page being served: settled by hostBlog/withBlog, // otherwise the user's own blog's, otherwise whatever the browser asks for. func (s *Server) lang(r *http.Request) string { if l, ok := r.Context().Value(ctxLang).(string); ok { return l } if u := currentUser(r); u != nil { return s.userLang(r, u) } return i18n.Match(r.Header.Get("Accept-Language")) } // userLang is the language of the user's own blog (a user has one blog), so // the dashboard reads the same on every page, including another user's blog. func (s *Server) userLang(r *http.Request, u *store.User) string { blog, err := s.st.BlogByOwner(r.Context(), u.ID) if err == nil { _, err = s.st.Open(r.Context(), blog) } if err != nil { if !errors.Is(err, store.ErrNotFound) { log.Printf("language of %s: %v", u.Username, err) } return i18n.Default } return blog.Language } // tr translates a message for the request's language; trf also formats it. func (s *Server) tr(r *http.Request, key string) string { return i18n.T(s.lang(r), key) } func (s *Server) trf(r *http.Request, key string, args ...any) string { return i18n.Tf(s.lang(r), key, args...) } // 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, s.cfg.HTTPS) 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, s.cfg.HTTPS) next.ServeHTTP(w, r) return } next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxUser, u))) }) } // requireLogin sends anonymous users to the login page and nothing else; the // body cap and the CSRF check come in guardPOST, once the upload limit is known. func (s *Server) requireLogin(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if currentUser(r) == nil { http.Redirect(w, r, "/webadmin?next="+r.URL.Path, http.StatusSeeOther) return } next(w, r) } } // guardPOST caps a POST body at limit plus 1 MB of form overhead, parses it and // checks the CSRF token; false means an error response was written. Other // methods pass straight through. func (s *Server) guardPOST(w http.ResponseWriter, r *http.Request, limit int64) bool { if r.Method != http.MethodPost { return true } u := currentUser(r) // Cap the request body before any form parsing (uploads included). r.Body = http.MaxBytesReader(w, r.Body, limit+1<<20) if err := parseForm(r); err != nil { var tooBig *http.MaxBytesError if errors.As(err, &tooBig) { s.fail(w, r, http.StatusRequestEntityTooLarge, s.trf(r, "Upload too large: the limit is %d MB.", limit>>20)) return false } s.fail(w, r, http.StatusBadRequest, s.tr(r, "Could not read the form.")) return false } if !auth.CheckCSRF(s.cfg.JWTSecret, u.ID, u.TokenVersion, r.FormValue("_csrf")) { s.fail(w, r, http.StatusForbidden, s.tr(r, "Form expired or invalid. Go back, reload the page and try again.")) return false } return true } // requireAuth is for management pages outside a blog (dashboard, password, // admin): logged in, small forms only. func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { return s.requireLogin(func(w http.ResponseWriter, r *http.Request) { if s.guardPOST(w, r, 0) { next(w, r) } }) } // parseForm parses urlencoded or multipart bodies, surfacing size errors. // Multipart parts beyond 1 MB in total spill to temp files, which net/http // removes once the handler returns. 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() } // wantsJSON is how the upload scripts ask for answers they can parse. func wantsJSON(r *http.Request) bool { return strings.Contains(r.Header.Get("Accept"), "application/json") } // fail answers an error as JSON when the client asked for it, else as the plain page. func (s *Server) fail(w http.ResponseWriter, r *http.Request, status int, msg string) { if wantsJSON(r) { writeJSON(w, status, map[string]string{"error": msg}) return } s.plainError(w, status, msg) } 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, s.tr(r, "Superadmin only.")) return } next(w, r) }) } // withBlog resolves /b/{sub}/..., enforces owner-or-superadmin and caps a POST // at the blog's own upload limit. func (s *Server) withBlog(next http.HandlerFunc) http.HandlerFunc { return s.withBlogFiles(1, next) } // withBlogFiles is withBlog for a form that may carry up to n files at once // (the Files page's multi-upload): the body cap is n limits. func (s *Server) withBlogFiles(n int, next http.HandlerFunc) http.HandlerFunc { return s.requireLogin(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 } blog := currentBlog(r) if blog.OwnerID != u.ID && !u.IsSuperadmin() { s.plainError(w, http.StatusForbidden, s.tr(r, "This is not your blog.")) return } lang := blog.Language if blog.OwnerID != u.ID { lang = s.userLang(r, u) } r = withLang(r, lang) if s.guardPOST(w, r, int64(n)*blog.UploadLimit(s.cfg)) { 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 Lang string // i18n code, also the 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, Lang: s.lang(r), 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, v.Lang, 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) }