Migration This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs. Contact me

internal/core/server.go

8d1bb5bfdd2cba82488775b46308efed740132a8 · 17.6 KB · 528 lines raw

1 package core
2
3 import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "errors"
8 "fmt"
9 "io/fs"
10 "log/slog"
11 "net/http"
12 "strconv"
13 "strings"
14 "sync"
15 "time"
16
17 "golang.org/x/sync/singleflight"
18
19 "gsg/internal/gitx"
20 "gsg/internal/session"
21 )
22
23 type Server struct {
24 Site *Site
25 Routes []Route
26 Log *slog.Logger
27 Timeout time.Duration // per-request git budget
28 MaxGit int64 // per-command output cap, bytes
29
30 // Sessions verifies the gsg_user login cookie. Nil-safe: an unset
31 // Sessions makes every request resolve as anonymous, i.e. today's
32 // behavior.
33 Sessions *session.Manager
34
35 // TemplateDir, when set, re-parses templates on every request. Theme
36 // iteration without a restart; leave empty in production. Also disables
37 // the response cache below, so a live-edited template is never masked
38 // by a stale cached response.
39 TemplateDir string
40 TemplateFS fs.FS
41
42 // StaticTag is a content hash of the embedded static assets (set once
43 // at startup in main.go via HashFS), threaded onto every Ctx so the
44 // `static` template func can build cache-busting asset URLs.
45 StaticTag string
46
47 mu sync.RWMutex
48 templates TemplateSet
49 tmplTag string // changes when templates change; part of every ETag
50 cache Store
51
52 // respCache holds full rendered responses for CacheableAnon routes,
53 // keyed on the request (route+path+query, see cacheEligible) rather
54 // than content identity. sf collapses every request that arrives while
55 // a key is cold into one fill instead of one per request -- the thing
56 // that actually matters during a traffic spike is many distinct
57 // anonymous visitors hitting the same hot page, not just requests that
58 // happen to land in the same instant.
59 respCache *LRU
60 sf singleflight.Group
61 }
62
63 func NewServer(site *Site, routes []Route, tfs fs.FS, cacheSize int) (*Server, error) {
64 s := &Server{
65 Site: site, Routes: routes, TemplateFS: tfs,
66 Log: slog.Default(),
67 Timeout: 10 * time.Second,
68 MaxGit: 16 << 20, // headroom above modules.maxPreviewSize (10 MiB)
69 cache: NewLRU(cacheSize),
70 respCache: NewLRU(cacheSize),
71 }
72 if err := s.loadTemplates(); err != nil {
73 return nil, err
74 }
75 return s, nil
76 }
77
78 func (s *Server) loadTemplates() error {
79 fsys := s.TemplateFS
80 set, err := LoadTemplates(fsys)
81 if err != nil {
82 return err
83 }
84 tag, err := HashFS(fsys)
85 if err != nil {
86 return err
87 }
88 s.mu.Lock()
89 s.templates, s.tmplTag = set, tag
90 s.mu.Unlock()
91 return nil
92 }
93
94 // HashFS digests every file in fsys, path and contents both, into a short
95 // stable tag. Used for the template set (so editing the theme invalidates
96 // every ETag at once -- without this a restyled page keeps serving 304s)
97 // and, via main.go, for the embedded static assets, so a CSS/JS edit mints
98 // a new cache-busting URL instead of being masked by a year-long immutable
99 // Cache-Control.
100 func HashFS(fsys fs.FS) (string, error) {
101 h := sha256.New()
102 err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
103 if err != nil || d.IsDir() {
104 return err
105 }
106 b, err := fs.ReadFile(fsys, p)
107 if err != nil {
108 return err
109 }
110 fmt.Fprintf(h, "%s:%x\n", p, sha256.Sum256(b))
111 return nil
112 })
113 return hex.EncodeToString(h.Sum(nil))[:12], err
114 }
115
116 func (s *Server) Handler() http.Handler {
117 mux := http.NewServeMux()
118 for _, rt := range s.Routes {
119 rt := rt
120 mux.HandleFunc(rt.Pattern, func(w http.ResponseWriter, r *http.Request) {
121 s.serve(rt, w, r)
122 })
123 }
124 return mux
125 }
126
127 // cachedResponse is a full rendered response, header and body both, as
128 // stored in respCache. expiresAt is only meaningful for entries actually
129 // stored there.
130 type cachedResponse struct {
131 header http.Header
132 body []byte
133 expiresAt time.Time
134 }
135
136 func (s *Server) serve(rt Route, w http.ResponseWriter, r *http.Request) {
137 if s.TemplateDir != "" {
138 if err := s.loadTemplates(); err != nil {
139 s.fail(w, r, rt, err)
140 return
141 }
142 }
143
144 if key, ok := s.cacheEligible(rt, r); ok {
145 if cr, ok := s.respCacheGet(key); ok {
146 writeResponse(w, r, cr)
147 return
148 }
149 // singleflight collapses every request that arrives while this key
150 // is cold into one fill. That fill runs on a context detached from
151 // whichever request happened to trigger it: without that, the
152 // first caller to disconnect or time out would kill the shared
153 // git/render work and fail every other request that coalesced onto
154 // it too, even ones with their own deadline still well in the
155 // future. Bounded by s.Timeout so a genuinely stuck fill still
156 // gets reaped.
157 //
158 // The closure below captures r, the *triggering* (leader) request
159 // -- every follower that coalesces onto this Do gets a response
160 // built from the leader's request, not their own. That's only safe
161 // because cacheEligible already pinned every request-varying input
162 // that matters (user == "", no share cookie) and the key covers
163 // path+query, so leader and followers are asking an identical
164 // question. A future CacheableAnon route whose render depends on
165 // anything else request-specific (a header, say) would silently
166 // break this.
167 v, err, _ := s.sf.Do(key, func() (any, error) {
168 fillCtx, cancel := context.WithTimeout(context.Background(), s.Timeout)
169 defer cancel()
170 return s.fillResponse(reqCtx{fillCtx}, rt, r)
171 })
172 if err != nil {
173 s.fail(w, r, rt, err)
174 return
175 }
176 cr := v.(*cachedResponse)
177 s.respCache.Put(key, cr)
178 writeResponse(w, r, cr)
179 return
180 }
181
182 ctx, cancel := contextWithTimeout(r, s.Timeout)
183 defer cancel()
184
185 c, header, err := s.buildResponseHead(ctx, rt, r)
186 if err != nil {
187 s.fail(w, r, rt, err)
188 return
189 }
190 copyHeader(w.Header(), header)
191 if match := r.Header.Get("If-None-Match"); match != "" && strings.Contains(match, header.Get("ETag")) {
192 w.WriteHeader(http.StatusNotModified)
193 return
194 }
195
196 s.mu.RLock()
197 ts := s.templates
198 s.mu.RUnlock()
199 body, err := rt.Render.Render(c, ts)
200 if err != nil {
201 s.fail(w, r, rt, err)
202 return
203 }
204 w.Header().Set("Content-Type", rt.Render.ContentType())
205 if hs, ok := rt.Render.(HeaderSetter); ok {
206 hs.Headers(c, w.Header())
207 }
208 w.Write(body)
209 }
210
211 // cacheEligible reports whether rt/r may be served from, and stored in,
212 // the shared response cache, and if so, the key to use. Eligibility is
213 // decided entirely from cheap, git-free signals -- cookies and
214 // already-loaded repo config -- so a cache hit costs nothing beyond a map
215 // lookup, and a miss doesn't cost anything extra either before falling
216 // through to the real render.
217 //
218 // Two independent conditions gate it, deliberately redundant: the request
219 // must carry neither a session nor a share cookie (the same
220 // anonymous-and-shareless condition the Cache-Control split in
221 // buildResponseHead already trusts), AND, for a repo-scoped route, that
222 // repo must not be Hidden -- checked directly from RepoInfo, with no
223 // reference to any cookie at all. A cache entry is shared state replayed
224 // to whoever asks next, so a false negative here has a bigger blast
225 // radius than a single mis-issued header would: the Hidden check means a
226 // bug in cookie detection alone can never leak a hidden repo's content,
227 // since that repo's responses are never eligible for the shared cache in
228 // the first place.
229 func (s *Server) cacheEligible(rt Route, r *http.Request) (string, bool) {
230 if !rt.CacheableAnon || s.TemplateDir != "" {
231 return "", false
232 }
233 if s.Sessions.Verify(r) != "" {
234 return "", false
235 }
236 if _, ok := s.Sessions.VerifyShareCookie(r); ok {
237 return "", false
238 }
239 if rt.Scope == ScopeRepo {
240 h, ok := s.Site.Lookup(r.PathValue("repo"))
241 if !ok || h.Info.Hidden {
242 return "", false
243 }
244 }
245 return rt.Name + "\x00" + r.URL.Path + "\x00" + r.URL.RawQuery, true
246 }
247
248 func (s *Server) respCacheGet(key string) (*cachedResponse, bool) {
249 v, ok := s.respCache.Get(key)
250 if !ok {
251 return nil, false
252 }
253 cr := v.(*cachedResponse)
254 if time.Now().After(cr.expiresAt) {
255 return nil, false
256 }
257 return cr, true
258 }
259
260 // fillResponse builds a full response for storage in respCache: header and
261 // body both, since an entry has to be complete before anyone else can be
262 // served from it.
263 func (s *Server) fillResponse(ctx contexter, rt Route, r *http.Request) (*cachedResponse, error) {
264 c, header, err := s.buildResponseHead(ctx, rt, r)
265 if err != nil {
266 return nil, err
267 }
268 s.mu.RLock()
269 ts := s.templates
270 s.mu.RUnlock()
271 body, err := rt.Render.Render(c, ts)
272 if err != nil {
273 return nil, err
274 }
275 header.Set("Content-Type", rt.Render.ContentType())
276 if hs, ok := rt.Render.(HeaderSetter); ok {
277 hs.Headers(c, header)
278 }
279 return &cachedResponse{header: header, body: body, expiresAt: time.Now().Add(c.cacheMaxAge())}, nil
280 }
281
282 // buildResponseHead resolves the Ctx and computes the ETag/Cache-Control
283 // header, without executing the template -- split out so a matching
284 // If-None-Match can skip rendering the body entirely.
285 //
286 // ETag is derived from the resolved commit SHA and every other input that
287 // can change what's rendered, not the response body, so a conditional
288 // request costs one ref lookup instead of a render.
289 func (s *Server) buildResponseHead(ctx contexter, rt Route, r *http.Request) (*Ctx, http.Header, error) {
290 c, err := s.buildCtx(ctx, rt, r)
291 if err != nil {
292 return nil, nil, err
293 }
294 c.Nav = BuildNav(s.Routes, rt.Name, c.Info.Slug, c.Ref)
295
296 s.mu.RLock()
297 tag := s.tmplTag
298 s.mu.RUnlock()
299
300 // contentIdentity carries the commit, keyring, and every Var (e.g.
301 // compare's base ref) -- the same fragment module results are keyed
302 // on, so this can't drift out of sync with what CacheKey covers.
303 //
304 // c.User and the raw gsg_share cookie value are folded in here,
305 // deliberately not into contentIdentity itself: contentIdentity also
306 // drives Ctx.CacheKey, the cross-request module Store key, and module
307 // results (tree/blob/log/...) aren't user-dependent -- authorization
308 // happens in buildCtx's Lookup gate, not inside a module. Keying the
309 // shared LRU per grant would fragment it for no benefit. The ETag,
310 // unlike the module cache, must vary by identity: without c.User,
311 // logging in or out keeps the same ETag as the anonymous response and a
312 // browser's stale If-None-Match earns a 304 with the wrong body. The
313 // share cookie needs the same treatment for the same reason: two
314 // anonymous visitors holding different share grants both have
315 // c.User == "", so without it they'd collide on one ETag and one
316 // share-holder's listing could be served to another. The cookie's own
317 // signature bytes already uniquely identify the grant, and folding them
318 // into a hash never echoes them back in a header.
319 //
320 // s.StaticTag is folded in too: a CSS/JS-only edit changes every
321 // rendered `static "..."` URL, so page ETags have to move with it.
322 shareCookie := ""
323 if ck, err := r.Cookie(session.ShareCookieName); err == nil {
324 shareCookie = ck.Value
325 }
326 etag := `"` + shortHash(rt.Name, r.URL.Path, r.URL.RawQuery, tag, s.StaticTag, c.contentIdentity(), c.User, shareCookie) + `"`
327
328 header := http.Header{}
329 header.Set("ETag", etag)
330 if c.Immutable {
331 header.Set("Cache-Control", "public, max-age=31536000, immutable")
332 } else {
333 // Vary: Cookie applies to every response here, not just personalized
334 // ones -- the *absence* of gsg_user is also a cache dimension. Without
335 // it on the anonymous branch too, a shared cache could store an
336 // anonymous response keyed on URL alone and later serve it back to a
337 // request that does carry a session cookie.
338 header.Add("Vary", "Cookie")
339 if c.User != "" || len(c.SharedSlugs) > 0 {
340 // No shared/intermediary cache (proxy, CDN, or gsg's own
341 // respCache) may ever store this response, only the requesting
342 // browser -- otherwise a misbehaving shared cache could serve
343 // one user's hidden-repo listing to a different visitor. Vary:
344 // Cookie above is defense in depth for any cache that honors
345 // Vary but not Cache-Control: private.
346 header.Set("Cache-Control", "private, no-cache")
347 } else {
348 // public, with a real max-age rather than no-cache: the ETag
349 // already folds in c.User and the share cookie (above), so the
350 // only failure mode for a shared cache that mishandles Vary is
351 // serving this anonymous view to a logged-in or share-holding
352 // visitor within the TTL -- they see fewer repos, or a 404 on a
353 // hidden one. A UX papercut, not a disclosure: the dangerous
354 // direction (private content served to an anonymous visitor)
355 // can't happen here, since that's the separate branch above.
356 header.Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(c.cacheMaxAge().Seconds())))
357 }
358 }
359 return c, header, nil
360 }
361
362 func copyHeader(dst, src http.Header) {
363 for k, vs := range src {
364 for _, v := range vs {
365 dst.Add(k, v)
366 }
367 }
368 }
369
370 func writeResponse(w http.ResponseWriter, r *http.Request, cr *cachedResponse) {
371 copyHeader(w.Header(), cr.header)
372 if match := r.Header.Get("If-None-Match"); match != "" && strings.Contains(match, cr.header.Get("ETag")) {
373 w.WriteHeader(http.StatusNotModified)
374 return
375 }
376 w.Write(cr.body)
377 }
378
379 func (s *Server) buildCtx(ctx contexter, rt Route, r *http.Request) (*Ctx, error) {
380 // Resolved once, before the scope branch, so the site-scoped early
381 // return (the index page -- the main surface a login or share link is
382 // meant to affect) doesn't skip it.
383 user := s.Sessions.Verify(r)
384 var sharedSlugs []string
385 var sharedIssuedAt time.Time
386 if claims, ok := s.Sessions.VerifyShareCookie(r); ok {
387 sharedSlugs, sharedIssuedAt = claims.Slugs, claims.IssuedAt()
388 }
389
390 if rt.Scope == ScopeSite {
391 c := NewCtx(s.Site, nil, RepoInfo{}, "")
392 c.Store = s.cache
393 c.User = user
394 c.SharedSlugs, c.SharedIssuedAt = sharedSlugs, sharedIssuedAt
395 c.StaticTag = s.StaticTag
396 return c, nil
397 }
398
399 slug := r.PathValue("repo")
400 h, ok := s.Site.Lookup(slug)
401 if !ok {
402 return nil, Errorf(http.StatusNotFound, "no such repository %q", slug)
403 }
404 // Hidden repos are 404, indistinguishable from nonexistent, unless the
405 // resolved identity is explicitly allowed to see this one, either as an
406 // owner (gsg.visible-to) or via an unrevoked share grant.
407 if h.Info.Hidden && !h.Info.CanView(user) && !h.Info.SharedVisible(sharedSlugs, sharedIssuedAt) {
408 return nil, Errorf(http.StatusNotFound, "no such repository %q", slug)
409 }
410 repo := h.Repo.WithContext(ctx.Ctx(), s.MaxGit)
411
412 // {rest...} carries ref and path fused together; {rev} is a bare ref;
413 // {range} is "base..head" or "base...head".
414 ref, path := "", ""
415 var base string
416 dots := "2"
417
418 switch {
419 case r.PathValue("range") != "":
420 rng := r.PathValue("range")
421 var head string
422 if b, hd, ok := strings.Cut(rng, "..."); ok {
423 base, head, dots = b, hd, "3"
424 } else if b, hd, ok := strings.Cut(rng, ".."); ok {
425 base, head = b, hd
426 } else {
427 return nil, Errorf(http.StatusBadRequest, "compare needs base..head")
428 }
429 ref = head
430 case r.PathValue("rest") != "":
431 ref, path = repo.SplitRefPath(r.PathValue("rest"))
432 case r.PathValue("rev") != "":
433 ref = r.PathValue("rev")
434 default:
435 ref = repo.HeadRef()
436 }
437
438 c := NewCtx(s.Site, repo, h.Info, ref)
439 c.Path = path
440 c.Store = s.cache
441 c.User = user
442 c.SharedSlugs, c.SharedIssuedAt = sharedSlugs, sharedIssuedAt
443 c.StaticTag = s.StaticTag
444
445 if !repo.Empty() {
446 sha, err := repo.Resolve(ref)
447 if err != nil {
448 return nil, Errorf(http.StatusNotFound, "%v", err)
449 }
450 c.Commit = sha
451 }
452 if base != "" {
453 baseSHA, err := repo.Resolve(base)
454 if err != nil {
455 return nil, Errorf(http.StatusNotFound, "%v", err)
456 }
457 headSHA := c.Commit
458 // Two-dot compares are undirected as far as the caller is concerned:
459 // whichever ref is the ancestor should read as "base", regardless of
460 // which side of ".." it was typed on. Three-dot is left alone --
461 // its merge-base semantics are direction-sensitive by design.
462 if dots == "2" && baseSHA != headSHA {
463 if anc, err := repo.IsAncestor(headSHA, baseSHA); err == nil && anc {
464 base, ref = ref, base
465 baseSHA, headSHA = headSHA, baseSHA
466 }
467 }
468 c.Ref, c.Commit = ref, headSHA
469 c.Vars["base"], c.Vars["head"] = base, ref
470 c.Vars["baseSHA"], c.Vars["headSHA"] = baseSHA, headSHA
471 c.Vars["dots"] = dots
472 }
473 if rt.Name == "log" || rt.Name == "graph" {
474 pageSize := LogPageSize
475 if rt.Name == "graph" {
476 pageSize = GraphPageSize
477 }
478 if v := r.URL.Query().Get("ofs"); v != "" {
479 if ofs, err := strconv.Atoi(v); err == nil && ofs > 0 {
480 ofs -= ofs % pageSize // snap to a page boundary before it can become a cache key
481 c.Vars["ofs"] = strconv.Itoa(ofs)
482 }
483 }
484 }
485 // One source of truth for the immutable policy: the cache header in
486 // buildResponseHead reads the same field, so page chrome that must not
487 // be frozen into a year-long response is suppressed by exactly the
488 // condition that makes the response year-long.
489 c.Immutable = rt.Immutable && c.Commit != ""
490 return c, nil
491 }
492
493 func (s *Server) fail(w http.ResponseWriter, r *http.Request, rt Route, err error) {
494 code := statusOf(err)
495 if errors.Is(err, gitx.ErrTruncated) {
496 code = http.StatusRequestEntityTooLarge
497 }
498 if code >= 500 {
499 s.Log.Error("render failed", "route", rt.Name, "path", r.URL.Path, "err", err)
500 } else {
501 s.Log.Info("request rejected", "route", rt.Name, "path", r.URL.Path, "status", code, "err", err)
502 }
503
504 // The cached-response and cache-fill paths in serve() only ever reach
505 // fail() before anything is written to w, so for those there is
506 // nothing to strip. But the plain (non-cacheable) path copies
507 // buildResponseHead's ETag/Cache-Control onto w *before* calling
508 // Render, on the assumption that Render will succeed -- if it doesn't,
509 // this Del/Set is still what prevents a failed response from going out
510 // with a stale ETag or, worse, the year-long immutable Cache-Control
511 // from a commit route.
512 w.Header().Del("ETag")
513 w.Header().Set("Cache-Control", "no-store")
514
515 body, cType := renderErrorPage(code, s.Site.Name)
516 w.Header().Set("Content-Type", cType)
517 w.WriteHeader(code)
518 w.Write(body)
519 }
520
521 func shortHash(parts ...string) string {
522 h := sha256.New()
523 for _, p := range parts {
524 h.Write([]byte(p))
525 h.Write([]byte{0})
526 }
527 return hex.EncodeToString(h.Sum(nil))[:16]
528 }