// 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" "strings" "sync" "sync/atomic" "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 } // 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 // non-hidden only, for URL routing } // 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 repos are not // found here, the same way they were excluded from Server's old repos map. 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. Nil-safe because tests construct // bare *Ctx values and LoadTemplates parses with &Ctx{}. func (s *Site) Revision() string { if s == nil || s.RevFn == nil { return "" } return s.RevFn() } // 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 { if !h.Info.Hidden { bySlug[h.Info.Slug] = h } } s.idx.Store(&repoIndex{list: next, bySlug: bySlug}) } // 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 // // 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 // 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 } 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"), GPGKeyring: r.Config("gsg.gpgkeyring"), } } // ----------------------------------------------------------------- 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 // 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 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.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] } // ---------------------------------------------------------------- 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 }