server.go
⎇
Raw
1// Package web wires the HTTP server: router, middleware and handlers.
2package web
3
4import (
5 "encoding/json"
6 "io/fs"
7 "net/http"
8 "net/url"
9 "strings"
10
11 "github.com/go-chi/chi/v5"
12 "github.com/go-chi/chi/v5/middleware"
13
14 hearthforge "hearthforge"
15 "hearthforge/internal/ci"
16 "hearthforge/internal/config"
17 "hearthforge/internal/db"
18 "hearthforge/internal/gitcmd"
19 "hearthforge/internal/highlight"
20 "hearthforge/internal/markdown"
21)
22
23const csp = "default-src 'self'; " +
24 "script-src 'self' 'wasm-unsafe-eval'; " +
25 "style-src 'self' 'unsafe-inline'; " +
26 "img-src 'self' blob: data:; " +
27 "connect-src 'self'; " +
28 "worker-src blob:; " +
29 "frame-ancestors 'none'; " +
30 "form-action 'self'; " +
31 "base-uri 'self'; " +
32 "object-src 'none'"
33
34// Server holds everything handlers need.
35type Server struct {
36 Cfg *config.Config
37 DB *db.DB
38 MD *markdown.Renderer
39 HL *highlight.Highlighter
40 CI *ci.Runner
41 Git *gitcmd.Git
42 Patches *gitcmd.PatchCache
43}
44
45// Router builds the chi router with global middleware. Route groups are
46// mounted in routes.go.
47func (s *Server) Router() http.Handler {
48 r := chi.NewRouter()
49 r.NotFound(func(w http.ResponseWriter, r *http.Request) {
50 http.Error(w, "NOT_FOUND", http.StatusNotFound)
51 })
52 // A panic in a handler answers 500 instead of dropping the connection.
53 r.Use(middleware.Recoverer)
54 r.Use(s.securityHeaders)
55 r.Use(s.csrf)
56 r.Use(s.bodyLimit)
57
58 static, _ := fs.Sub(hearthforge.StaticFS, "web/static")
59 r.Handle("/assets/*", http.FileServerFS(static))
60 r.Get("/health", s.health)
61
62 s.routes(r)
63 return r
64}
65
66func (s *Server) securityHeaders(next http.Handler) http.Handler {
67 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
68 h := w.Header()
69 h.Set("Content-Security-Policy", csp)
70 h.Set("X-Frame-Options", "DENY")
71 h.Set("X-Content-Type-Options", "nosniff")
72 if s.Cfg.PublicHTTPS {
73 h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
74 }
75 next.ServeHTTP(w, r)
76 })
77}
78
79// csrf is defense in depth on top of SameSite=Lax session cookies.
80// Mutating requests must send no Origin (git, curl: they use Basic auth)
81// or an Origin that matches. HTTPS mode compares the full origin against
82// BASE_URL. Plain-http dev mode compares Origin host against Host so
83// localhost and 127.0.0.1 both work.
84func (s *Server) csrf(next http.Handler) http.Handler {
85 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
86 switch r.Method {
87 case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
88 default:
89 next.ServeHTTP(w, r)
90 return
91 }
92 origin := r.Header.Get("Origin")
93 if origin == "" {
94 next.ServeHTTP(w, r)
95 return
96 }
97 if s.Cfg.PublicHTTPS {
98 if origin != s.Cfg.PublicOrigin {
99 http.Error(w, "Cross-origin request rejected", http.StatusForbidden)
100 return
101 }
102 next.ServeHTTP(w, r)
103 return
104 }
105 u, err := url.Parse(origin)
106 if err != nil {
107 http.Error(w, "Bad Origin", http.StatusForbidden)
108 return
109 }
110 if r.Host == "" || !strings.EqualFold(u.Host, r.Host) {
111 http.Error(w, "Cross-origin request rejected", http.StatusForbidden)
112 return
113 }
114 next.ServeHTTP(w, r)
115 })
116}
117
118// bodyLimit caps request bodies at MaxUploadBytes. git push is exempt: it is
119// admin-only behind Basic auth, streams to git's stdin, and SSH push has no
120// cap either.
121func (s *Server) bodyLimit(next http.Handler) http.Handler {
122 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
123 isPush := r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/git-receive-pack")
124 if r.Body != nil && s.Cfg.MaxUploadBytes > 0 && !isPush {
125 r.Body = http.MaxBytesReader(w, r.Body, s.Cfg.MaxUploadBytes)
126 }
127 next.ServeHTTP(w, r)
128 })
129}
130
131func (s *Server) health(w http.ResponseWriter, r *http.Request) {
132 w.Header().Set("Content-Type", "application/json")
133 if err := s.DB.PingContext(r.Context()); err != nil {
134 w.WriteHeader(http.StatusServiceUnavailable)
135 json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": err.Error()})
136 return
137 }
138 json.NewEncoder(w).Encode(map[string]any{"ok": true})
139}
140