package core import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "io/fs" "log/slog" "net/http" "strconv" "strings" "sync" "time" "gsg/internal/access" "gsg/internal/gitx" "gsg/internal/session" "golang.org/x/sync/singleflight" ) type Server struct { Site *Site Routes []Route Log *slog.Logger Timeout time.Duration // per-request git budget MaxGit int64 // per-command output cap, bytes // Sessions verifies the gsg_user login cookie. Nil-safe: an unset // Sessions makes every request resolve as anonymous, i.e. today's // behavior. Sessions *session.Manager // TemplateDir, when set, re-parses templates on every request. Theme // iteration without a restart; leave empty in production. Also disables // the response cache below, so a live-edited template is never masked // by a stale cached response. TemplateDir string TemplateFS fs.FS // StaticTag is a content hash of the embedded static assets (set once // at startup in main.go via HashFS), threaded onto every Ctx so the // `static` template func can build cache-busting asset URLs. StaticTag string mu sync.RWMutex templates TemplateSet tmplTag string // changes when templates change; part of every ETag cache Store // respCache holds full rendered responses for CacheableAnon routes, // keyed on the request (route+path+query, see cacheEligible) rather // than content identity. sf collapses every request that arrives while // a key is cold into one fill instead of one per request -- the thing // that actually matters during a traffic spike is many distinct // anonymous visitors hitting the same hot page, not just requests that // happen to land in the same instant. respCache *LRU sf singleflight.Group } func NewServer(site *Site, routes []Route, tfs fs.FS, cacheSize int) (*Server, error) { s := &Server{ Site: site, Routes: routes, TemplateFS: tfs, Log: slog.Default(), Timeout: 10 * time.Second, MaxGit: 16 << 20, // headroom above modules.maxPreviewSize (10 MiB) cache: NewLRU(cacheSize), respCache: NewLRU(cacheSize), } if err := s.loadTemplates(); err != nil { return nil, err } return s, nil } func (s *Server) loadTemplates() error { fsys := s.TemplateFS set, err := LoadTemplates(fsys) if err != nil { return err } tag, err := HashFS(fsys) if err != nil { return err } s.mu.Lock() s.templates, s.tmplTag = set, tag s.mu.Unlock() return nil } // HashFS digests every file in fsys, path and contents both, into a short // stable tag. Used for the template set (so editing the theme invalidates // every ETag at once -- without this a restyled page keeps serving 304s) // and, via main.go, for the embedded static assets, so a CSS/JS edit mints // a new cache-busting URL instead of being masked by a year-long immutable // Cache-Control. func HashFS(fsys fs.FS) (string, error) { h := sha256.New() err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return err } b, err := fs.ReadFile(fsys, p) if err != nil { return err } fmt.Fprintf(h, "%s:%x\n", p, sha256.Sum256(b)) return nil }) return hex.EncodeToString(h.Sum(nil))[:12], err } func (s *Server) Handler() http.Handler { mux := http.NewServeMux() for _, rt := range s.Routes { rt := rt mux.HandleFunc(rt.Pattern, func(w http.ResponseWriter, r *http.Request) { s.serve(rt, w, r) }) } return mux } // cachedResponse is a full rendered response, header and body both, as // stored in respCache. expiresAt is only meaningful for entries actually // stored there. type cachedResponse struct { header http.Header body []byte expiresAt time.Time } func (s *Server) serve(rt Route, w http.ResponseWriter, r *http.Request) { if s.TemplateDir != "" { if err := s.loadTemplates(); err != nil { s.fail(w, r, rt, err) return } } if key, ok := s.cacheEligible(rt, r); ok { if cr, ok := s.respCacheGet(key); ok { writeResponse(w, r, cr) return } // singleflight collapses every request that arrives while this key // is cold into one fill. That fill runs on a context detached from // whichever request happened to trigger it: without that, the // first caller to disconnect or time out would kill the shared // git/render work and fail every other request that coalesced onto // it too, even ones with their own deadline still well in the // future. Bounded by s.Timeout so a genuinely stuck fill still // gets reaped. // // The closure below captures r, the *triggering* (leader) request // -- every follower that coalesces onto this Do gets a response // built from the leader's request, not their own. That's only safe // because cacheEligible already pinned every request-varying input // that matters (user == "", no share cookie) and the key covers // path+query, so leader and followers are asking an identical // question. A future CacheableAnon route whose render depends on // anything else request-specific (a header, say) would silently // break this. v, err, _ := s.sf.Do(key, func() (any, error) { fillCtx, cancel := context.WithTimeout(context.Background(), s.Timeout) defer cancel() return s.fillResponse(reqCtx{fillCtx}, rt, r) }) if err != nil { s.fail(w, r, rt, err) return } cr := v.(*cachedResponse) s.respCache.Put(key, cr) writeResponse(w, r, cr) return } ctx, cancel := contextWithTimeout(r, s.Timeout) defer cancel() c, header, err := s.buildResponseHead(ctx, rt, r) if err != nil { s.fail(w, r, rt, err) return } copyHeader(w.Header(), header) if match := r.Header.Get("If-None-Match"); match != "" && strings.Contains(match, header.Get("ETag")) { w.WriteHeader(http.StatusNotModified) return } s.mu.RLock() ts := s.templates s.mu.RUnlock() body, err := rt.Render.Render(c, ts) if err != nil { s.fail(w, r, rt, err) return } w.Header().Set("Content-Type", rt.Render.ContentType()) if hs, ok := rt.Render.(HeaderSetter); ok { hs.Headers(c, w.Header()) } w.Write(body) } // cacheEligible reports whether rt/r may be served from, and stored in, // the shared response cache, and if so, the key to use. Eligibility is // decided entirely from cheap, git-free signals -- cookies and // already-loaded repo config -- so a cache hit costs nothing beyond a map // lookup, and a miss doesn't cost anything extra either before falling // through to the real render. // // Two independent conditions gate it, deliberately redundant: the request // must carry neither a session nor a share cookie (the same // anonymous-and-shareless condition the Cache-Control split in // buildResponseHead already trusts), AND, for a repo-scoped route, that // repo must not be Hidden -- checked directly from RepoInfo, with no // reference to any cookie at all. A cache entry is shared state replayed // to whoever asks next, so a false negative here has a bigger blast // radius than a single mis-issued header would: the Hidden check means a // bug in cookie detection alone can never leak a hidden repo's content, // since that repo's responses are never eligible for the shared cache in // the first place. func (s *Server) cacheEligible(rt Route, r *http.Request) (string, bool) { if !rt.CacheableAnon || s.TemplateDir != "" { return "", false } if s.Sessions.Verify(r) != "" { return "", false } if _, ok := s.Sessions.VerifyShareCookie(r); ok { return "", false } if rt.Scope == ScopeRepo { h, ok := s.Site.Lookup(r.PathValue("repo")) if !ok || h.Info.Hidden { return "", false } } return rt.Name + "\x00" + r.URL.Path + "\x00" + r.URL.RawQuery, true } func (s *Server) respCacheGet(key string) (*cachedResponse, bool) { v, ok := s.respCache.Get(key) if !ok { return nil, false } cr := v.(*cachedResponse) if time.Now().After(cr.expiresAt) { return nil, false } return cr, true } // fillResponse builds a full response for storage in respCache: header and // body both, since an entry has to be complete before anyone else can be // served from it. func (s *Server) fillResponse(ctx contexter, rt Route, r *http.Request) (*cachedResponse, error) { c, header, err := s.buildResponseHead(ctx, rt, r) if err != nil { return nil, err } s.mu.RLock() ts := s.templates s.mu.RUnlock() body, err := rt.Render.Render(c, ts) if err != nil { return nil, err } header.Set("Content-Type", rt.Render.ContentType()) if hs, ok := rt.Render.(HeaderSetter); ok { hs.Headers(c, header) } return &cachedResponse{header: header, body: body, expiresAt: time.Now().Add(c.cacheMaxAge())}, nil } // buildResponseHead resolves the Ctx and computes the ETag/Cache-Control // header, without executing the template -- split out so a matching // If-None-Match can skip rendering the body entirely. // // ETag is derived from the resolved commit SHA and every other input that // can change what's rendered, not the response body, so a conditional // request costs one ref lookup instead of a render. func (s *Server) buildResponseHead(ctx contexter, rt Route, r *http.Request) (*Ctx, http.Header, error) { c, err := s.buildCtx(ctx, rt, r) if err != nil { return nil, nil, err } c.Nav = BuildNav(s.Routes, rt.Name, c.Info.Slug, c.Ref) s.mu.RLock() tag := s.tmplTag s.mu.RUnlock() // contentIdentity carries the commit, keyring, and every Var (e.g. // compare's base ref) -- the same fragment module results are keyed // on, so this can't drift out of sync with what CacheKey covers. // // c.User and the raw gsg_share cookie value are folded in here, // deliberately not into contentIdentity itself: contentIdentity also // drives Ctx.CacheKey, the cross-request module Store key, and module // results (tree/blob/log/...) aren't user-dependent -- authorization // happens in buildCtx's Lookup gate, not inside a module. Keying the // shared LRU per grant would fragment it for no benefit. The ETag, // unlike the module cache, must vary by identity: without c.User, // logging in or out keeps the same ETag as the anonymous response and a // browser's stale If-None-Match earns a 304 with the wrong body. The // share cookie needs the same treatment for the same reason: two // anonymous visitors holding different share grants both have // c.User == "", so without it they'd collide on one ETag and one // share-holder's listing could be served to another. The cookie's own // signature bytes already uniquely identify the grant, and folding them // into a hash never echoes them back in a header. // // s.StaticTag is folded in too: a CSS/JS-only edit changes every // rendered `static "..."` URL, so page ETags have to move with it. shareCookie := "" if ck, err := r.Cookie(session.ShareCookieName); err == nil { shareCookie = ck.Value } etag := `"` + shortHash(rt.Name, r.URL.Path, r.URL.RawQuery, tag, s.StaticTag, c.contentIdentity(), c.User, shareCookie) + `"` header := http.Header{} header.Set("ETag", etag) if c.Immutable { header.Set("Cache-Control", "public, max-age=31536000, immutable") } else { // Vary: Cookie applies to every response here, not just personalized // ones -- the *absence* of gsg_user is also a cache dimension. Without // it on the anonymous branch too, a shared cache could store an // anonymous response keyed on URL alone and later serve it back to a // request that does carry a session cookie. header.Add("Vary", "Cookie") if c.User != "" || len(c.SharedSlugs) > 0 { // No shared/intermediary cache (proxy, CDN, or gsg's own // respCache) may ever store this response, only the requesting // browser -- otherwise a misbehaving shared cache could serve // one user's hidden-repo listing to a different visitor. Vary: // Cookie above is defense in depth for any cache that honors // Vary but not Cache-Control: private. header.Set("Cache-Control", "private, no-cache") } else { // public, with a real max-age rather than no-cache: the ETag // already folds in c.User and the share cookie (above), so the // only failure mode for a shared cache that mishandles Vary is // serving this anonymous view to a logged-in or share-holding // visitor within the TTL -- they see fewer repos, or a 404 on a // hidden one. A UX papercut, not a disclosure: the dangerous // direction (private content served to an anonymous visitor) // can't happen here, since that's the separate branch above. header.Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(c.cacheMaxAge().Seconds()))) } } return c, header, nil } func copyHeader(dst, src http.Header) { for k, vs := range src { for _, v := range vs { dst.Add(k, v) } } } func writeResponse(w http.ResponseWriter, r *http.Request, cr *cachedResponse) { copyHeader(w.Header(), cr.header) if match := r.Header.Get("If-None-Match"); match != "" && strings.Contains(match, cr.header.Get("ETag")) { w.WriteHeader(http.StatusNotModified) return } w.Write(cr.body) } func (s *Server) buildCtx(ctx contexter, rt Route, r *http.Request) (*Ctx, error) { // Resolved once, before the scope branch, so the site-scoped early // return (the index page -- the main surface a login or share link is // meant to affect) doesn't skip it. identity := access.Identity{User: s.Sessions.Verify(r)} if claims, ok := s.Sessions.VerifyShareCookie(r); ok { identity.SharedSlugs, identity.SharedIssuedAt = claims.Slugs, claims.IssuedAt() } if rt.Scope == ScopeSite { c := NewCtx(s.Site, nil, RepoInfo{}, "") c.Store = s.cache c.User = identity.User c.SharedSlugs, c.SharedIssuedAt = identity.SharedSlugs, identity.SharedIssuedAt c.StaticTag = s.StaticTag return c, nil } slug := r.PathValue("repo") h, ok := s.Site.Lookup(slug) if !ok { return nil, Errorf(http.StatusNotFound, "no such repository %q", slug) } // Hidden repos are 404, indistinguishable from nonexistent, unless the // resolved identity is explicitly allowed to see this one. The rule lives // in internal/access so this gate, the index module and share redemption // agree about owners, grants and revocation. if !h.Info.AccessPolicy().CanView(identity) { return nil, Errorf(http.StatusNotFound, "no such repository %q", slug) } repo := h.Repo.WithContext(ctx.Ctx(), s.MaxGit) req, err := parseRequest(rt, repo, r) if err != nil { return nil, err } ref, path := req.Ref, req.Path c := NewCtx(s.Site, repo, h.Info, ref) c.Path = path c.Store = s.cache c.User = identity.User c.SharedSlugs, c.SharedIssuedAt = identity.SharedSlugs, identity.SharedIssuedAt c.StaticTag = s.StaticTag if !repo.Empty() { sha, err := repo.Resolve(ref) if err != nil { return nil, Errorf(http.StatusNotFound, "%v", err) } c.Commit = sha } if req.Range != nil { base := req.Range.Base dots := "2" if req.Range.ThreeDot { dots = "3" } baseSHA, err := repo.Resolve(base) if err != nil { return nil, Errorf(http.StatusNotFound, "%v", err) } headSHA := c.Commit // Two-dot compares are undirected as far as the caller is concerned: // whichever ref is the ancestor should read as "base", regardless of // which side of ".." it was typed on. Three-dot is left alone -- // its merge-base semantics are direction-sensitive by design. if dots == "2" && baseSHA != headSHA { if anc, err := repo.IsAncestor(headSHA, baseSHA); err == nil && anc { base, ref = ref, base baseSHA, headSHA = headSHA, baseSHA } } c.Ref, c.Commit = ref, headSHA c.Vars["base"], c.Vars["head"] = base, ref c.Vars["baseSHA"], c.Vars["headSHA"] = baseSHA, headSHA c.Vars["dots"] = dots } if rt.PageSize > 0 { if v := r.URL.Query().Get("ofs"); v != "" { if ofs, err := strconv.Atoi(v); err == nil && ofs > 0 { ofs -= ofs % rt.PageSize // snap to a page boundary before it can become a cache key c.Vars["ofs"] = strconv.Itoa(ofs) } } } // One source of truth for the immutable policy: the cache header in // buildResponseHead reads the same field, so page chrome that must not // be frozen into a year-long response is suppressed by exactly the // condition that makes the response year-long. c.Immutable = rt.Immutable && c.Commit != "" return c, nil } // parseRequest applies the route's parser, or the default {rest...}/{rev} // handling. Both only identify ref candidates; buildCtx below always calls // Repo.Resolve before anything downstream sees a commit. func parseRequest(rt Route, repo *gitx.Repo, r *http.Request) (Request, error) { if rt.Request != nil { return rt.Request(r) } switch { case r.PathValue("rest") != "": ref, path := repo.SplitRefPath(r.PathValue("rest")) return Request{Ref: ref, Path: path}, nil case r.PathValue("rev") != "": return Request{Ref: r.PathValue("rev")}, nil default: return Request{Ref: repo.HeadRef()}, nil } } func (s *Server) fail(w http.ResponseWriter, r *http.Request, rt Route, err error) { code := statusOf(err) if errors.Is(err, gitx.ErrTruncated) { code = http.StatusRequestEntityTooLarge } if code >= 500 { s.Log.Error("render failed", "route", rt.Name, "path", r.URL.Path, "err", err) } else { s.Log.Info("request rejected", "route", rt.Name, "path", r.URL.Path, "status", code, "err", err) } // The cached-response and cache-fill paths in serve() only ever reach // fail() before anything is written to w, so for those there is // nothing to strip. But the plain (non-cacheable) path copies // buildResponseHead's ETag/Cache-Control onto w *before* calling // Render, on the assumption that Render will succeed -- if it doesn't, // this Del/Set is still what prevents a failed response from going out // with a stale ETag or, worse, the year-long immutable Cache-Control // from a commit route. w.Header().Del("ETag") w.Header().Set("Cache-Control", "no-store") body, cType := renderErrorPage(code, s.Site.Name) w.Header().Set("Content-Type", cType) w.WriteHeader(code) w.Write(body) } func shortHash(parts ...string) string { h := sha256.New() for _, p := range parts { h.Write([]byte(p)) h.Write([]byte{0}) } return hex.EncodeToString(h.Sum(nil))[:16] }