// Package core is the extension point. A module computes one piece of a // view; a route binds modules to an output file. Adding a feature means // adding a module and using it from a template -- nothing here changes. package core import ( "fmt" "sort" "strconv" "strings" "sync" "sync/atomic" "time" "gsg/internal/gitx" ) // ------------------------------------------------------------------ config type Site struct { Name string BaseURL string Root string // directory containing bare repos // RevFn, when set, returns a token that changes whenever site-level // configuration changes without a commit moving -- operator notices, // for example. Ctx.contentIdentity() folds it into both the module // cache key and the response ETag, so a module reading that config // cannot serve a result that predates the change. Set once at startup, // before the server begins serving. RevFn func() string idx atomic.Pointer[repoIndex] mu sync.Mutex // serializes upsert/remove; RepoList/Lookup never block // configRev counts every upsert/remove, i.e. every time a repo's own // git config or existence changes (watch.go's reconcile calls mutate // for exactly this). Folded into Revision() so that ETags and the // module cache invalidate when a repo's config changes -- gsg.hide, // gsg.visible-to, gsg.share-revoked-after -- not just when notices do. // Without this, a browser could revalidate a stale, cached page whose // ETag predates a config change that was supposed to take effect // immediately, such as a share-link revocation. configRev atomic.Uint64 } // repoIndex is an immutable snapshot, swapped in whole by upsert/remove so // readers (RepoList, Lookup) never need a lock. type repoIndex struct { list []Handle // every known repo, hidden or not -- repos.go filters Hidden itself bySlug map[string]Handle // every known repo, hidden or not -- Server.buildCtx enforces visibility } // RepoList is every discovered repository. Site-scoped routes (the index // page, the index endpoint) read it through the `repos` module. func (s *Site) RepoList() []Handle { if idx := s.idx.Load(); idx != nil { return idx.list } return nil } // Lookup resolves a repo by slug for per-repo routes, hidden or not -- the // caller (Server.buildCtx) is responsible for enforcing RepoInfo.Hidden / // CanView before using the result. func (s *Site) Lookup(slug string) (Handle, bool) { idx := s.idx.Load() if idx == nil { return Handle{}, false } h, ok := idx.bySlug[slug] return h, ok } // Revision is the site-level analogue of RepoInfo.GPGKeyring: a token for // inputs that change outside a commit -- notices, and any repo's own git // config (gsg.hide, gsg.visible-to, gsg.share-revoked-after, ...). Nil-safe // because tests construct bare *Ctx values and LoadTemplates parses with // &Ctx{}. func (s *Site) Revision() string { if s == nil { return "" } rev := strconv.FormatUint(s.configRev.Load(), 10) if s.RevFn != nil { rev += "|" + s.RevFn() } return rev } // upsert and remove are the only writers of the repo index, both called // exclusively from watch.go's reconcile -- at startup and at runtime alike, // through the same code path. func (s *Site) upsert(h Handle) { s.mutate(func(list []Handle) []Handle { for i, e := range list { if e.Info.Slug == h.Info.Slug { list[i] = h return list } } return append(list, h) }) } func (s *Site) remove(slug string) { s.mutate(func(list []Handle) []Handle { for i, e := range list { if e.Info.Slug == slug { return append(list[:i], list[i+1:]...) } } return list }) } func (s *Site) mutate(f func([]Handle) []Handle) { s.mu.Lock() defer s.mu.Unlock() var cur []Handle if idx := s.idx.Load(); idx != nil { cur = append([]Handle(nil), idx.list...) } next := f(cur) bySlug := make(map[string]Handle, len(next)) for _, h := range next { bySlug[h.Info.Slug] = h } s.idx.Store(&repoIndex{list: next, bySlug: bySlug}) s.configRev.Add(1) } // Handle pairs an open repository with its configuration. type Handle struct { Repo *gitx.Repo Info RepoInfo } // RepoInfo is the per-repository configuration, read from the repo's own // git config the way cgit does. Keys, in priority order: // // [gsg] [gitweb] // title = ... owner = ... // description = ... description = ... // owner = ... // clone = git://... // hide = true // section = infra // gpgkeyring = /path/to/keys.asc // visible-to = alice,bob // cache-max-age = 2m // // Set them with: git --git-dir=/srv/git/foo.git config gsg.title "Foo" type RepoInfo struct { Slug string Title string Description string Owner string Clone string Section string Hidden bool // VisibleTo is the gsg.visible-to allowlist: usernames (matched against // an authenticated session, case-insensitively) who may see this repo // even though Hidden is set. A separate key from Owner deliberately -- // Owner is a free-text display field (and falls back to gitweb.owner // for cgit-migrated repos), so reusing it as an ACL could silently // grant access nobody intended. VisibleTo []string // ShareMaxAge is gsg.share-max-age: both the ceiling no share-link // expiry for this repo may exceed, and the default when a mint request // doesn't specify one. Falls back to defaultShareMaxAge when unset. ShareMaxAge time.Duration // ShareRevokedAfter is gsg.share-revoked-after: any share-link grant // for this repo issued at or before this instant is rejected, // regardless of the link's own expiry. It is the only revocation lever // a stateless share token has -- see internal/share. ShareRevokedAfter time.Time // GPGKeyring is the configured path to a file of armored public keys // (gsg.gpgkeyring), if any. Reading it here is just config; the actual // import into an isolated keyring happens once at startup (see // gitx.Repo.UseKeyring) and, on failure, this is cleared again so // templates don't advertise a verification column that isn't backed // by anything. GPGKeyring string // CacheMaxAge is gsg.cache-max-age: how long an anonymous (no session, // no share grant) response for this repo may be reused -- both by the // requesting browser (Cache-Control: public, max-age=...) and by gsg's // own in-memory response cache (Server.respCache) -- before either has // to ask again. Falls back to defaultAnonCacheMaxAge when unset. CacheMaxAge time.Duration } func LoadRepoInfo(r *gitx.Repo) RepoInfo { title := r.ConfigFirst("gsg.title") if title == "" { title = r.Slug } desc := r.ConfigFirst("gsg.description", "gitweb.description") if desc == "" { desc = r.DescriptionFile() // cgit-compatible fallback } return RepoInfo{ Slug: r.Slug, Title: title, Description: desc, Owner: r.ConfigFirst("gsg.owner", "gitweb.owner"), Clone: r.ConfigFirst("gsg.clone", "gitweb.url"), Section: r.ConfigFirst("gsg.section", "gitweb.category"), Hidden: strings.EqualFold(r.Config("gsg.hide"), "true"), VisibleTo: splitUsers(r.Config("gsg.visible-to")), ShareMaxAge: parseShareMaxAge(r.Config("gsg.share-max-age")), ShareRevokedAfter: parseUnixTime(r.Config("gsg.share-revoked-after")), GPGKeyring: r.Config("gsg.gpgkeyring"), CacheMaxAge: parseCacheMaxAge(r.Config("gsg.cache-max-age")), } } // defaultShareMaxAge is the ceiling/default applied when a repo doesn't set // gsg.share-max-age. const defaultShareMaxAge = 30 * 24 * time.Hour func parseShareMaxAge(v string) time.Duration { if v == "" { return defaultShareMaxAge } d, err := time.ParseDuration(v) if err != nil || d <= 0 { return defaultShareMaxAge } return d } // defaultAnonCacheMaxAge is the ceiling/default applied when a repo doesn't // set gsg.cache-max-age. const defaultAnonCacheMaxAge = 2 * time.Minute func parseCacheMaxAge(v string) time.Duration { if v == "" { return defaultAnonCacheMaxAge } d, err := time.ParseDuration(v) if err != nil || d <= 0 { return defaultAnonCacheMaxAge } return d } func parseUnixTime(v string) time.Time { if v == "" { return time.Time{} } sec, err := strconv.ParseInt(v, 10, 64) if err != nil { return time.Time{} } return time.Unix(sec, 0) } // splitUsers parses a comma-separated gsg.visible-to value into trimmed, // non-empty usernames. func splitUsers(v string) []string { if v == "" { return nil } parts := strings.Split(v, ",") out := make([]string, 0, len(parts)) for _, p := range parts { if p = strings.TrimSpace(p); p != "" { out = append(out, p) } } return out } // CanView reports whether user (an authenticated username, or "" for // anonymous) may see this repo even if it's Hidden. Anonymous callers never // match -- VisibleTo entries are login usernames, and "" would otherwise // vacuously match a blank/misconfigured entry. func (info RepoInfo) CanView(user string) bool { if user == "" { return false } for _, u := range info.VisibleTo { if strings.EqualFold(u, user) { return true } } return false } // SharedVisible reports whether a verified gsg_share grant -- the slug set // and issued-at time from its ShareClaims -- still unlocks this repo. It's // not enough for slugs to just name this repo: if gsg.share-revoked-after // has been bumped since the grant was issued, it's revoked, even though the // signature on the browser's cookie is still perfectly valid. This is // checked on every request (not just at redemption), which is what makes // bumping the timestamp reach a browser that already redeemed the link. func (info RepoInfo) SharedVisible(slugs []string, issuedAt time.Time) bool { found := false for _, s := range slugs { if s == info.Slug { found = true break } } if !found { return false } if !info.ShareRevokedAfter.IsZero() && !issuedAt.After(info.ShareRevokedAfter) { return false } return true } // ----------------------------------------------------------------- context // Ctx is what a module is given: which repo, at which ref, at which path. // The cache makes `mod` idempotent within a single page render, so two // templates asking for the same module do not pay for it twice. type Ctx struct { Site *Site Repo *gitx.Repo Info RepoInfo Ref string // the ref as the user spelled it ("main", "v0.1.0", a sha) Path string Vars map[string]string // User is the authenticated username from the gsg_user session cookie, // or "" for an anonymous request. Resolved once in Server.buildCtx. // Never derived from a request header directly -- see internal/session. User string // SharedSlugs and SharedIssuedAt come from a verified gsg_share cookie, // if one was present (SharedSlugs is nil otherwise). They are not yet // filtered for revocation -- RepoInfo.SharedVisible applies // gsg.share-revoked-after against SharedIssuedAt at the point of use, // since revocation is per-repo and this Ctx may span several. SharedSlugs []string SharedIssuedAt time.Time // Nav is the repo-scoped tab bar, built once in Server.serve from the // route table (see nav.go). Empty for site-scoped routes. Nav []NavItem // Commit is Ref resolved to a full SHA. Everything a module computes // at a given Commit is immutable, which is what makes the shared // cache and ETags correct rather than merely convenient. Commit string // Immutable is true when this response is served with the year-long // immutable cache policy (see Server.serve). Page chrome that can change // without the commit moving must not be rendered into it, because the // browser is told never to revalidate. Operator notices are the first // such thing; this is deliberately the *computed* condition, not just // Route.Immutable, so it follows whatever the caching rule becomes. Immutable bool // Store is the process-wide cache, nil in tests. Only consulted when // Commit is set. Store Store // StaticTag is Server.StaticTag, threaded through so the `static` // template func can build cache-busting asset URLs without a package // import cycle back to Server. StaticTag string mu sync.Mutex cache map[string]result } // Store is the cross-request cache. Implemented by internal/core.LRU. type Store interface { Get(key string) (any, bool) Put(key string, v any) } type result struct { val any err error } func NewCtx(site *Site, r *gitx.Repo, info RepoInfo, ref string) *Ctx { return &Ctx{Site: site, Repo: r, Info: info, Ref: ref, Vars: map[string]string{}, cache: map[string]result{}} } // contentIdentity captures every input that can change what a route // renders at a fixed commit -- the commit itself, plus anything that can // change without the commit moving. Vars are part of it: /compare/a..b and // /compare/a...b resolve to the same head commit, so without the base ref // in here the second request was served the first one's diff. GPGKeyring // is part of it for the same reason: watch.go's reconcile can change a // repo's keyring under commits whose SHAs haven't moved, and a page // fetched before that would otherwise cache "unsigned" forever. // Site.Revision() is the site-level equivalent of the keyring: operator // notices change without a commit moving, so a page cached before a notice // edit would otherwise keep serving the old banners forever. // // CacheKey and the response ETag (server.go) are both built from this, so // a new Var or a keyring change can't invalidate one without invalidating // the other -- that used to be two hand-maintained lists that had already // drifted (the ETag was missing GPGKeyring and any Var besides baseSHA). func (c *Ctx) contentIdentity() string { keys := make([]string, 0, len(c.Vars)) for k := range c.Vars { keys = append(keys, k) } sort.Strings(keys) var b strings.Builder b.WriteString(c.Commit + "|" + c.Info.GPGKeyring + "|" + c.Site.Revision()) for _, k := range keys { b.WriteString("|" + k + "=" + c.Vars[k]) } return b.String() } // CacheKey identifies an immutable module result. Empty when the result // cannot be cached (no resolved commit). func (c *Ctx) CacheKey(name string, args []string) string { if c.Commit == "" || c.Store == nil { return "" } return c.Info.Slug + "|" + c.Path + "|" + name + "|" + strings.Join(args, "\x00") + "|" + c.contentIdentity() } // Derive returns a shallow copy for a different path, with a fresh cache. func (c *Ctx) Derive(path string, vars map[string]string) *Ctx { n := NewCtx(c.Site, c.Repo, c.Info, c.Ref) n.User = c.User n.SharedSlugs = c.SharedSlugs n.SharedIssuedAt = c.SharedIssuedAt n.Path = path for k, v := range c.Vars { n.Vars[k] = v } for k, v := range vars { n.Vars[k] = v } return n } func (c *Ctx) Var(k string) string { return c.Vars[k] } // cacheMaxAge is how long an anonymous response for this repo may be // reused before revalidating. Server.buildResponseHead (the browser-facing // Cache-Control header) and Server.fillResponse (the server-side response // cache's own TTL) both read this, so the two never disagree about // staleness. func (c *Ctx) cacheMaxAge() time.Duration { if c.Info.CacheMaxAge > 0 { return c.Info.CacheMaxAge } return defaultAnonCacheMaxAge } // ---------------------------------------------------------------- registry // Module computes one piece of a view. Anything a template or an endpoint // might want is a module: readme, tree, log, refs, graph, blame, stats. type Module func(c *Ctx, args ...string) (any, error) var ( regMu sync.RWMutex registry = map[string]Module{} ) // Register adds a module. Call it from an init() in the modules package. func Register(name string, m Module) { regMu.Lock() defer regMu.Unlock() if _, dup := registry[name]; dup { panic("core: duplicate module " + name) } registry[name] = m } func Names() []string { regMu.RLock() defer regMu.RUnlock() out := make([]string, 0, len(registry)) for n := range registry { out = append(out, n) } return out } // Mod runs a module, memoized per Ctx. This is the single entry point // used by both templates (via the funcmap) and JSON endpoints. func (c *Ctx) Mod(name string, args ...string) (any, error) { key := name + "\x00" + strings.Join(args, "\x00") c.mu.Lock() if c.cache == nil { c.cache = map[string]result{} } if r, ok := c.cache[key]; ok { c.mu.Unlock() return r.val, r.err } c.mu.Unlock() regMu.RLock() m, ok := registry[name] regMu.RUnlock() if !ok { return nil, fmt.Errorf("no such module %q (have: %s)", name, strings.Join(Names(), ", ")) } // Cross-request cache: a module's output at a fixed commit SHA cannot // change, so this is safe without any invalidation logic. Nothing here // is keyed on a ref name -- refs move, SHAs do not. shared := c.CacheKey(name, args) if shared != "" { if v, ok := c.Store.Get(shared); ok { c.mu.Lock() c.cache[key] = result{v, nil} c.mu.Unlock() return v, nil } } val, err := m(c, args...) if err == nil && shared != "" { c.Store.Put(shared, val) } c.mu.Lock() c.cache[key] = result{val, err} c.mu.Unlock() return val, err }