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

internal/core/core.go

38fa4e6cd9bb9dbf0ae23ef02e50b1c75cd84ebb · 10.5 KB · 359 lines raw

1 // Package core is the extension point. A module computes one piece of a
2 // view; a route binds modules to an output file. Adding a feature means
3 // adding a module and using it from a template -- nothing here changes.
4 package core
5
6 import (
7 "fmt"
8 "sort"
9 "strings"
10 "sync"
11 "sync/atomic"
12
13 "gsg/internal/gitx"
14 )
15
16 // ------------------------------------------------------------------ config
17
18 type Site struct {
19 Name string
20 BaseURL string
21 Root string // directory containing bare repos
22
23 // RevFn, when set, returns a token that changes whenever site-level
24 // configuration changes without a commit moving -- operator notices,
25 // for example. Ctx.contentIdentity() folds it into both the module
26 // cache key and the response ETag, so a module reading that config
27 // cannot serve a result that predates the change. Set once at startup,
28 // before the server begins serving.
29 RevFn func() string
30
31 idx atomic.Pointer[repoIndex]
32 mu sync.Mutex // serializes upsert/remove; RepoList/Lookup never block
33 }
34
35 // repoIndex is an immutable snapshot, swapped in whole by upsert/remove so
36 // readers (RepoList, Lookup) never need a lock.
37 type repoIndex struct {
38 list []Handle // every known repo, hidden or not -- repos.go filters Hidden itself
39 bySlug map[string]Handle // non-hidden only, for URL routing
40 }
41
42 // RepoList is every discovered repository. Site-scoped routes (the index
43 // page, the index endpoint) read it through the `repos` module.
44 func (s *Site) RepoList() []Handle {
45 if idx := s.idx.Load(); idx != nil {
46 return idx.list
47 }
48 return nil
49 }
50
51 // Lookup resolves a repo by slug for per-repo routes. Hidden repos are not
52 // found here, the same way they were excluded from Server's old repos map.
53 func (s *Site) Lookup(slug string) (Handle, bool) {
54 idx := s.idx.Load()
55 if idx == nil {
56 return Handle{}, false
57 }
58 h, ok := idx.bySlug[slug]
59 return h, ok
60 }
61
62 // Revision is the site-level analogue of RepoInfo.GPGKeyring: a token for
63 // inputs that change outside a commit. Nil-safe because tests construct
64 // bare *Ctx values and LoadTemplates parses with &Ctx{}.
65 func (s *Site) Revision() string {
66 if s == nil || s.RevFn == nil {
67 return ""
68 }
69 return s.RevFn()
70 }
71
72 // upsert and remove are the only writers of the repo index, both called
73 // exclusively from watch.go's reconcile -- at startup and at runtime alike,
74 // through the same code path.
75 func (s *Site) upsert(h Handle) {
76 s.mutate(func(list []Handle) []Handle {
77 for i, e := range list {
78 if e.Info.Slug == h.Info.Slug {
79 list[i] = h
80 return list
81 }
82 }
83 return append(list, h)
84 })
85 }
86
87 func (s *Site) remove(slug string) {
88 s.mutate(func(list []Handle) []Handle {
89 for i, e := range list {
90 if e.Info.Slug == slug {
91 return append(list[:i], list[i+1:]...)
92 }
93 }
94 return list
95 })
96 }
97
98 func (s *Site) mutate(f func([]Handle) []Handle) {
99 s.mu.Lock()
100 defer s.mu.Unlock()
101
102 var cur []Handle
103 if idx := s.idx.Load(); idx != nil {
104 cur = append([]Handle(nil), idx.list...)
105 }
106 next := f(cur)
107
108 bySlug := make(map[string]Handle, len(next))
109 for _, h := range next {
110 if !h.Info.Hidden {
111 bySlug[h.Info.Slug] = h
112 }
113 }
114 s.idx.Store(&repoIndex{list: next, bySlug: bySlug})
115 }
116
117 // Handle pairs an open repository with its configuration.
118 type Handle struct {
119 Repo *gitx.Repo
120 Info RepoInfo
121 }
122
123 // RepoInfo is the per-repository configuration, read from the repo's own
124 // git config the way cgit does. Keys, in priority order:
125 //
126 // [gsg] [gitweb]
127 // title = ... owner = ...
128 // description = ... description = ...
129 // owner = ...
130 // clone = git://...
131 // hide = true
132 // section = infra
133 // gpgkeyring = /path/to/keys.asc
134 //
135 // Set them with: git --git-dir=/srv/git/foo.git config gsg.title "Foo"
136 type RepoInfo struct {
137 Slug string
138 Title string
139 Description string
140 Owner string
141 Clone string
142 Section string
143 Hidden bool
144
145 // GPGKeyring is the configured path to a file of armored public keys
146 // (gsg.gpgkeyring), if any. Reading it here is just config; the actual
147 // import into an isolated keyring happens once at startup (see
148 // gitx.Repo.UseKeyring) and, on failure, this is cleared again so
149 // templates don't advertise a verification column that isn't backed
150 // by anything.
151 GPGKeyring string
152 }
153
154 func LoadRepoInfo(r *gitx.Repo) RepoInfo {
155 title := r.ConfigFirst("gsg.title")
156 if title == "" {
157 title = r.Slug
158 }
159 desc := r.ConfigFirst("gsg.description", "gitweb.description")
160 if desc == "" {
161 desc = r.DescriptionFile() // cgit-compatible fallback
162 }
163 return RepoInfo{
164 Slug: r.Slug,
165 Title: title,
166 Description: desc,
167 Owner: r.ConfigFirst("gsg.owner", "gitweb.owner"),
168 Clone: r.ConfigFirst("gsg.clone", "gitweb.url"),
169 Section: r.ConfigFirst("gsg.section", "gitweb.category"),
170 Hidden: strings.EqualFold(r.Config("gsg.hide"), "true"),
171 GPGKeyring: r.Config("gsg.gpgkeyring"),
172 }
173 }
174
175 // ----------------------------------------------------------------- context
176
177 // Ctx is what a module is given: which repo, at which ref, at which path.
178 // The cache makes `mod` idempotent within a single page render, so two
179 // templates asking for the same module do not pay for it twice.
180 type Ctx struct {
181 Site *Site
182 Repo *gitx.Repo
183 Info RepoInfo
184 Ref string // the ref as the user spelled it ("main", "v0.1.0", a sha)
185 Path string
186 Vars map[string]string
187
188 // Nav is the repo-scoped tab bar, built once in Server.serve from the
189 // route table (see nav.go). Empty for site-scoped routes.
190 Nav []NavItem
191
192 // Commit is Ref resolved to a full SHA. Everything a module computes
193 // at a given Commit is immutable, which is what makes the shared
194 // cache and ETags correct rather than merely convenient.
195 Commit string
196
197 // Immutable is true when this response is served with the year-long
198 // immutable cache policy (see Server.serve). Page chrome that can change
199 // without the commit moving must not be rendered into it, because the
200 // browser is told never to revalidate. Operator notices are the first
201 // such thing; this is deliberately the *computed* condition, not just
202 // Route.Immutable, so it follows whatever the caching rule becomes.
203 Immutable bool
204
205 // Store is the process-wide cache, nil in tests. Only consulted when
206 // Commit is set.
207 Store Store
208
209 mu sync.Mutex
210 cache map[string]result
211 }
212
213 // Store is the cross-request cache. Implemented by internal/core.LRU.
214 type Store interface {
215 Get(key string) (any, bool)
216 Put(key string, v any)
217 }
218
219 type result struct {
220 val any
221 err error
222 }
223
224 func NewCtx(site *Site, r *gitx.Repo, info RepoInfo, ref string) *Ctx {
225 return &Ctx{Site: site, Repo: r, Info: info, Ref: ref,
226 Vars: map[string]string{}, cache: map[string]result{}}
227 }
228
229 // contentIdentity captures every input that can change what a route
230 // renders at a fixed commit -- the commit itself, plus anything that can
231 // change without the commit moving. Vars are part of it: /compare/a..b and
232 // /compare/a...b resolve to the same head commit, so without the base ref
233 // in here the second request was served the first one's diff. GPGKeyring
234 // is part of it for the same reason: watch.go's reconcile can change a
235 // repo's keyring under commits whose SHAs haven't moved, and a page
236 // fetched before that would otherwise cache "unsigned" forever.
237 // Site.Revision() is the site-level equivalent of the keyring: operator
238 // notices change without a commit moving, so a page cached before a notice
239 // edit would otherwise keep serving the old banners forever.
240 //
241 // CacheKey and the response ETag (server.go) are both built from this, so
242 // a new Var or a keyring change can't invalidate one without invalidating
243 // the other -- that used to be two hand-maintained lists that had already
244 // drifted (the ETag was missing GPGKeyring and any Var besides baseSHA).
245 func (c *Ctx) contentIdentity() string {
246 keys := make([]string, 0, len(c.Vars))
247 for k := range c.Vars {
248 keys = append(keys, k)
249 }
250 sort.Strings(keys)
251 var b strings.Builder
252 b.WriteString(c.Commit + "|" + c.Info.GPGKeyring + "|" + c.Site.Revision())
253 for _, k := range keys {
254 b.WriteString("|" + k + "=" + c.Vars[k])
255 }
256 return b.String()
257 }
258
259 // CacheKey identifies an immutable module result. Empty when the result
260 // cannot be cached (no resolved commit).
261 func (c *Ctx) CacheKey(name string, args []string) string {
262 if c.Commit == "" || c.Store == nil {
263 return ""
264 }
265 return c.Info.Slug + "|" + c.Path + "|" + name + "|" +
266 strings.Join(args, "\x00") + "|" + c.contentIdentity()
267 }
268
269 // Derive returns a shallow copy for a different path, with a fresh cache.
270 func (c *Ctx) Derive(path string, vars map[string]string) *Ctx {
271 n := NewCtx(c.Site, c.Repo, c.Info, c.Ref)
272 n.Path = path
273 for k, v := range c.Vars {
274 n.Vars[k] = v
275 }
276 for k, v := range vars {
277 n.Vars[k] = v
278 }
279 return n
280 }
281
282 func (c *Ctx) Var(k string) string { return c.Vars[k] }
283
284 // ---------------------------------------------------------------- registry
285
286 // Module computes one piece of a view. Anything a template or an endpoint
287 // might want is a module: readme, tree, log, refs, graph, blame, stats.
288 type Module func(c *Ctx, args ...string) (any, error)
289
290 var (
291 regMu sync.RWMutex
292 registry = map[string]Module{}
293 )
294
295 // Register adds a module. Call it from an init() in the modules package.
296 func Register(name string, m Module) {
297 regMu.Lock()
298 defer regMu.Unlock()
299 if _, dup := registry[name]; dup {
300 panic("core: duplicate module " + name)
301 }
302 registry[name] = m
303 }
304
305 func Names() []string {
306 regMu.RLock()
307 defer regMu.RUnlock()
308 out := make([]string, 0, len(registry))
309 for n := range registry {
310 out = append(out, n)
311 }
312 return out
313 }
314
315 // Mod runs a module, memoized per Ctx. This is the single entry point
316 // used by both templates (via the funcmap) and JSON endpoints.
317 func (c *Ctx) Mod(name string, args ...string) (any, error) {
318 key := name + "\x00" + strings.Join(args, "\x00")
319
320 c.mu.Lock()
321 if c.cache == nil {
322 c.cache = map[string]result{}
323 }
324 if r, ok := c.cache[key]; ok {
325 c.mu.Unlock()
326 return r.val, r.err
327 }
328 c.mu.Unlock()
329
330 regMu.RLock()
331 m, ok := registry[name]
332 regMu.RUnlock()
333 if !ok {
334 return nil, fmt.Errorf("no such module %q (have: %s)", name, strings.Join(Names(), ", "))
335 }
336
337 // Cross-request cache: a module's output at a fixed commit SHA cannot
338 // change, so this is safe without any invalidation logic. Nothing here
339 // is keyed on a ref name -- refs move, SHAs do not.
340 shared := c.CacheKey(name, args)
341 if shared != "" {
342 if v, ok := c.Store.Get(shared); ok {
343 c.mu.Lock()
344 c.cache[key] = result{v, nil}
345 c.mu.Unlock()
346 return v, nil
347 }
348 }
349
350 val, err := m(c, args...)
351 if err == nil && shared != "" {
352 c.Store.Put(shared, val)
353 }
354
355 c.mu.Lock()
356 c.cache[key] = result{val, err}
357 c.mu.Unlock()
358 return val, err
359 }