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