package core import ( "crypto/sha256" "encoding/hex" "errors" "fmt" "io/fs" "log/slog" "net/http" "strconv" "strings" "sync" "time" "gsg/internal/gitx" "gsg/internal/session" ) 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. TemplateDir string TemplateFS fs.FS mu sync.RWMutex templates TemplateSet tmplTag string // changes when templates change; part of every ETag cache Store } 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), } 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 template so that editing the theme invalidates every // ETag at once. Without this, a restyled page keeps serving 304s. 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 } 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 } } ctx, cancel := contextWithTimeout(r, s.Timeout) defer cancel() c, err := s.buildCtx(ctx, rt, r) if err != nil { s.fail(w, r, rt, err) return } c.Nav = BuildNav(s.Routes, rt.Name, c.Info.Slug, c.Ref) // ETag is derived from the resolved commit SHA, not the response body, // so a conditional request costs one ref lookup instead of a render. s.mu.RLock() tag := s.tmplTag ts := s.templates 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. shareCookie := "" if ck, err := r.Cookie(session.ShareCookieName); err == nil { shareCookie = ck.Value } etag := `"` + shortHash(rt.Name, r.URL.Path, r.URL.RawQuery, tag, c.contentIdentity(), c.User, shareCookie) + `"` w.Header().Set("ETag", etag) if c.Immutable { w.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. w.Header().Add("Vary", "Cookie") if c.User != "" || len(c.SharedSlugs) > 0 { // No shared/intermediary cache (proxy, CDN) 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. w.Header().Set("Cache-Control", "private, no-cache") } else { w.Header().Set("Cache-Control", "no-cache") } } if match := r.Header.Get("If-None-Match"); match != "" && strings.Contains(match, etag) { w.WriteHeader(http.StatusNotModified) return } 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) } 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. user := s.Sessions.Verify(r) var sharedSlugs []string var sharedIssuedAt time.Time if claims, ok := s.Sessions.VerifyShareCookie(r); ok { sharedSlugs, sharedIssuedAt = claims.Slugs, claims.IssuedAt() } if rt.Scope == ScopeSite { c := NewCtx(s.Site, nil, RepoInfo{}, "") c.Store = s.cache c.User = user c.SharedSlugs, c.SharedIssuedAt = sharedSlugs, sharedIssuedAt 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, either as an // owner (gsg.visible-to) or via an unrevoked share grant. if h.Info.Hidden && !h.Info.CanView(user) && !h.Info.SharedVisible(sharedSlugs, sharedIssuedAt) { return nil, Errorf(http.StatusNotFound, "no such repository %q", slug) } repo := h.Repo.WithContext(ctx.Ctx(), s.MaxGit) // {rest...} carries ref and path fused together; {rev} is a bare ref; // {range} is "base..head" or "base...head". ref, path := "", "" var base string dots := "2" switch { case r.PathValue("range") != "": rng := r.PathValue("range") var head string if b, hd, ok := strings.Cut(rng, "..."); ok { base, head, dots = b, hd, "3" } else if b, hd, ok := strings.Cut(rng, ".."); ok { base, head = b, hd } else { return nil, Errorf(http.StatusBadRequest, "compare needs base..head") } ref = head case r.PathValue("rest") != "": ref, path = repo.SplitRefPath(r.PathValue("rest")) case r.PathValue("rev") != "": ref = r.PathValue("rev") default: ref = repo.HeadRef() } c := NewCtx(s.Site, repo, h.Info, ref) c.Path = path c.Store = s.cache c.User = user c.SharedSlugs, c.SharedIssuedAt = sharedSlugs, sharedIssuedAt if !repo.Empty() { sha, err := repo.Resolve(ref) if err != nil { return nil, Errorf(http.StatusNotFound, "%v", err) } c.Commit = sha } if base != "" { 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.Name == "log" || rt.Name == "graph" { pageSize := LogPageSize if rt.Name == "graph" { pageSize = GraphPageSize } if v := r.URL.Query().Get("ofs"); v != "" { if ofs, err := strconv.Atoi(v); err == nil && ofs > 0 { ofs -= ofs % 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 // serve() 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 } 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) } // serve() sets ETag/Cache-Control before Render runs, assuming success. // An error response must never inherit those, especially the // year-long immutable Cache-Control on commit routes. 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] }