// Package web wires the HTTP server: router, middleware and handlers. package web import ( "encoding/json" "io/fs" "net/http" "net/url" "strings" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" hearthforge "hearthforge" "hearthforge/internal/ci" "hearthforge/internal/config" "hearthforge/internal/db" "hearthforge/internal/gitcmd" "hearthforge/internal/highlight" "hearthforge/internal/markdown" ) const csp = "default-src 'self'; " + "script-src 'self' 'wasm-unsafe-eval'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' blob: data:; " + "connect-src 'self'; " + "worker-src blob:; " + "frame-ancestors 'none'; " + "form-action 'self'; " + "base-uri 'self'; " + "object-src 'none'" // Server holds everything handlers need. type Server struct { Cfg *config.Config DB *db.DB MD *markdown.Renderer HL *highlight.Highlighter CI *ci.Runner Git *gitcmd.Git Patches *gitcmd.PatchCache } // Router builds the chi router with global middleware. Route groups are // mounted in routes.go. func (s *Server) Router() http.Handler { r := chi.NewRouter() r.NotFound(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "NOT_FOUND", http.StatusNotFound) }) // A panic in a handler answers 500 instead of dropping the connection. r.Use(middleware.Recoverer) r.Use(s.securityHeaders) r.Use(s.csrf) r.Use(s.bodyLimit) static, _ := fs.Sub(hearthforge.StaticFS, "web/static") r.Handle("/assets/*", http.FileServerFS(static)) r.Get("/health", s.health) s.routes(r) return r } func (s *Server) securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() h.Set("Content-Security-Policy", csp) h.Set("X-Frame-Options", "DENY") h.Set("X-Content-Type-Options", "nosniff") if s.Cfg.PublicHTTPS { h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") } next.ServeHTTP(w, r) }) } // csrf is defense in depth on top of SameSite=Lax session cookies. // Mutating requests must send no Origin (git, curl: they use Basic auth) // or an Origin that matches. HTTPS mode compares the full origin against // BASE_URL. Plain-http dev mode compares Origin host against Host so // localhost and 127.0.0.1 both work. func (s *Server) csrf(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: default: next.ServeHTTP(w, r) return } origin := r.Header.Get("Origin") if origin == "" { next.ServeHTTP(w, r) return } if s.Cfg.PublicHTTPS { if origin != s.Cfg.PublicOrigin { http.Error(w, "Cross-origin request rejected", http.StatusForbidden) return } next.ServeHTTP(w, r) return } u, err := url.Parse(origin) if err != nil { http.Error(w, "Bad Origin", http.StatusForbidden) return } if r.Host == "" || !strings.EqualFold(u.Host, r.Host) { http.Error(w, "Cross-origin request rejected", http.StatusForbidden) return } next.ServeHTTP(w, r) }) } // bodyLimit caps request bodies at MaxUploadBytes. git push is exempt: it is // admin-only behind Basic auth, streams to git's stdin, and SSH push has no // cap either. func (s *Server) bodyLimit(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { isPush := r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/git-receive-pack") if r.Body != nil && s.Cfg.MaxUploadBytes > 0 && !isPush { r.Body = http.MaxBytesReader(w, r.Body, s.Cfg.MaxUploadBytes) } next.ServeHTTP(w, r) }) } func (s *Server) health(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if err := s.DB.PingContext(r.Context()); err != nil { w.WriteHeader(http.StatusServiceUnavailable) json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": err.Error()}) return } json.NewEncoder(w).Encode(map[string]any{"ok": true}) }