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

internal/core/server.go

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