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

internal/core/core.go

main · 16.0 KB · 512 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 "strconv"
10 "strings"
11 "sync"
12 "sync/atomic"
13 "time"
14
15 "gsg/internal/access"
16 "gsg/internal/gitx"
17 )
18
19 // ------------------------------------------------------------------ config
20
21 type Site struct {
22 Name string
23 BaseURL string
24 Root string // directory containing bare repos
25
26 // RevFn, when set, returns a token that changes whenever site-level
27 // configuration changes without a commit moving -- operator notices,
28 // for example. Ctx.contentIdentity() folds it into both the module
29 // cache key and the response ETag, so a module reading that config
30 // cannot serve a result that predates the change. Set once at startup,
31 // before the server begins serving.
32 RevFn func() string
33
34 idx atomic.Pointer[repoIndex]
35 mu sync.Mutex // serializes upsert/remove; RepoList/Lookup never block
36
37 // configRev counts every upsert/remove, i.e. every time a repo's own
38 // git config or existence changes (watch.go's reconcile calls mutate
39 // for exactly this). Folded into Revision() so that ETags and the
40 // module cache invalidate when a repo's config changes -- gsg.hide,
41 // gsg.visible-to, gsg.share-revoked-after -- not just when notices do.
42 // Without this, a browser could revalidate a stale, cached page whose
43 // ETag predates a config change that was supposed to take effect
44 // immediately, such as a share-link revocation.
45 configRev atomic.Uint64
46 }
47
48 // repoIndex is an immutable snapshot, swapped in whole by upsert/remove so
49 // readers (RepoList, Lookup) never need a lock.
50 type repoIndex struct {
51 list []Handle // every known repo, hidden or not -- repos.go filters Hidden itself
52 bySlug map[string]Handle // every known repo, hidden or not -- Server.buildCtx enforces visibility
53 }
54
55 // RepoList is every discovered repository. Site-scoped routes (the index
56 // page, the index endpoint) read it through the `repos` module.
57 func (s *Site) RepoList() []Handle {
58 if idx := s.idx.Load(); idx != nil {
59 return idx.list
60 }
61 return nil
62 }
63
64 // Lookup resolves a repo by slug for per-repo routes, hidden or not -- the
65 // caller (Server.buildCtx) is responsible for enforcing the access policy
66 // before using the result.
67 func (s *Site) Lookup(slug string) (Handle, bool) {
68 idx := s.idx.Load()
69 if idx == nil {
70 return Handle{}, false
71 }
72 h, ok := idx.bySlug[slug]
73 return h, ok
74 }
75
76 // Revision is the site-level analogue of RepoInfo.GPGKeyring: a token for
77 // inputs that change outside a commit -- notices, and any repo's own git
78 // config (gsg.hide, gsg.visible-to, gsg.share-revoked-after, ...). Nil-safe
79 // because tests construct bare *Ctx values and LoadTemplates parses with
80 // &Ctx{}.
81 func (s *Site) Revision() string {
82 if s == nil {
83 return ""
84 }
85 rev := strconv.FormatUint(s.configRev.Load(), 10)
86 if s.RevFn != nil {
87 rev += "|" + s.RevFn()
88 }
89 return rev
90 }
91
92 // upsert and remove are the only writers of the repo index, both called
93 // exclusively from watch.go's reconcile -- at startup and at runtime alike,
94 // through the same code path.
95 func (s *Site) upsert(h Handle) {
96 s.mutate(func(list []Handle) []Handle {
97 for i, e := range list {
98 if e.Info.Slug == h.Info.Slug {
99 list[i] = h
100 return list
101 }
102 }
103 return append(list, h)
104 })
105 }
106
107 func (s *Site) remove(slug string) {
108 s.mutate(func(list []Handle) []Handle {
109 for i, e := range list {
110 if e.Info.Slug == slug {
111 return append(list[:i], list[i+1:]...)
112 }
113 }
114 return list
115 })
116 }
117
118 func (s *Site) mutate(f func([]Handle) []Handle) {
119 s.mu.Lock()
120 defer s.mu.Unlock()
121
122 var cur []Handle
123 if idx := s.idx.Load(); idx != nil {
124 cur = append([]Handle(nil), idx.list...)
125 }
126 next := f(cur)
127
128 bySlug := make(map[string]Handle, len(next))
129 for _, h := range next {
130 bySlug[h.Info.Slug] = h
131 }
132 s.idx.Store(&repoIndex{list: next, bySlug: bySlug})
133 s.configRev.Add(1)
134 }
135
136 // Handle pairs an open repository with its configuration.
137 type Handle struct {
138 Repo *gitx.Repo
139 Info RepoInfo
140 }
141
142 // RepoInfo is the per-repository configuration, read from the repo's own
143 // git config the way cgit does. Keys, in priority order:
144 //
145 // [gsg] [gitweb]
146 // title = ... owner = ...
147 // description = ... description = ...
148 // owner = ...
149 // clone = git://...
150 // hide = true
151 // section = infra
152 // gpgkeyring = /path/to/keys.asc
153 // visible-to = alice,bob
154 // cache-max-age = 2m
155 //
156 // Set them with: git --git-dir=/srv/git/foo.git config gsg.title "Foo"
157 type RepoInfo struct {
158 Slug string
159 Title string
160 Description string
161 Owner string
162 Clone string
163 Section string
164 Hidden bool
165
166 // VisibleTo is the gsg.visible-to allowlist: usernames (matched against
167 // an authenticated session, case-insensitively) who may see this repo
168 // even though Hidden is set. A separate key from Owner deliberately --
169 // Owner is a free-text display field (and falls back to gitweb.owner
170 // for cgit-migrated repos), so reusing it as an ACL could silently
171 // grant access nobody intended.
172 VisibleTo []string
173
174 // ShareMaxAge is gsg.share-max-age: both the ceiling no share-link
175 // expiry for this repo may exceed, and the default when a mint request
176 // doesn't specify one. Falls back to defaultShareMaxAge when unset.
177 ShareMaxAge time.Duration
178
179 // ShareRevokedAfter is gsg.share-revoked-after: any share-link grant
180 // for this repo issued at or before this instant is rejected,
181 // regardless of the link's own expiry. It is the only revocation lever
182 // a stateless share token has -- see internal/share.
183 ShareRevokedAfter time.Time
184
185 // GPGKeyring is the configured path to a file of armored public keys
186 // (gsg.gpgkeyring), if any. Reading it here is just config; the actual
187 // import into an isolated keyring happens once at startup (see
188 // gitx.Repo.UseKeyring) and, on failure, this is cleared again so
189 // templates don't advertise a verification column that isn't backed
190 // by anything.
191 GPGKeyring string
192
193 // CacheMaxAge is gsg.cache-max-age: how long an anonymous (no session,
194 // no share grant) response for this repo may be reused -- both by the
195 // requesting browser (Cache-Control: public, max-age=...) and by gsg's
196 // own in-memory response cache (Server.respCache) -- before either has
197 // to ask again. Falls back to defaultAnonCacheMaxAge when unset.
198 CacheMaxAge time.Duration
199 }
200
201 func LoadRepoInfo(r *gitx.Repo) RepoInfo {
202 title := r.ConfigFirst("gsg.title")
203 if title == "" {
204 title = r.Slug
205 }
206 desc := r.ConfigFirst("gsg.description", "gitweb.description")
207 if desc == "" {
208 desc = r.DescriptionFile() // cgit-compatible fallback
209 }
210 return RepoInfo{
211 Slug: r.Slug,
212 Title: title,
213 Description: desc,
214 Owner: r.ConfigFirst("gsg.owner", "gitweb.owner"),
215 Clone: r.ConfigFirst("gsg.clone", "gitweb.url"),
216 Section: r.ConfigFirst("gsg.section", "gitweb.category"),
217 Hidden: strings.EqualFold(r.Config("gsg.hide"), "true"),
218 VisibleTo: splitUsers(r.Config("gsg.visible-to")),
219 ShareMaxAge: parseShareMaxAge(r.Config("gsg.share-max-age")),
220 ShareRevokedAfter: parseUnixTime(r.Config("gsg.share-revoked-after")),
221 GPGKeyring: r.Config("gsg.gpgkeyring"),
222 CacheMaxAge: parseCacheMaxAge(r.Config("gsg.cache-max-age")),
223 }
224 }
225
226 // defaultShareMaxAge is the ceiling/default applied when a repo doesn't set
227 // gsg.share-max-age.
228 const defaultShareMaxAge = 30 * 24 * time.Hour
229
230 func parseShareMaxAge(v string) time.Duration {
231 if v == "" {
232 return defaultShareMaxAge
233 }
234 d, err := time.ParseDuration(v)
235 if err != nil || d <= 0 {
236 return defaultShareMaxAge
237 }
238 return d
239 }
240
241 // defaultAnonCacheMaxAge is the ceiling/default applied when a repo doesn't
242 // set gsg.cache-max-age.
243 const defaultAnonCacheMaxAge = 2 * time.Minute
244
245 func parseCacheMaxAge(v string) time.Duration {
246 if v == "" {
247 return defaultAnonCacheMaxAge
248 }
249 d, err := time.ParseDuration(v)
250 if err != nil || d <= 0 {
251 return defaultAnonCacheMaxAge
252 }
253 return d
254 }
255
256 func parseUnixTime(v string) time.Time {
257 if v == "" {
258 return time.Time{}
259 }
260 sec, err := strconv.ParseInt(v, 10, 64)
261 if err != nil {
262 return time.Time{}
263 }
264 return time.Unix(sec, 0)
265 }
266
267 // splitUsers parses a comma-separated gsg.visible-to value into trimmed,
268 // non-empty usernames.
269 func splitUsers(v string) []string {
270 if v == "" {
271 return nil
272 }
273 parts := strings.Split(v, ",")
274 out := make([]string, 0, len(parts))
275 for _, p := range parts {
276 if p = strings.TrimSpace(p); p != "" {
277 out = append(out, p)
278 }
279 }
280 return out
281 }
282
283 // AccessPolicy is the authorization-relevant subset of RepoInfo. The rules
284 // themselves live in internal/access so core, modules, share minting and
285 // share redemption cannot drift into slightly different answers.
286 func (info RepoInfo) AccessPolicy() access.Policy {
287 return access.Policy{
288 Slug: info.Slug,
289 Hidden: info.Hidden,
290 VisibleTo: info.VisibleTo,
291 ShareRevokedAfter: info.ShareRevokedAfter,
292 }
293 }
294
295 // ----------------------------------------------------------------- context
296
297 // Ctx is what a module is given: which repo, at which ref, at which path.
298 // The cache makes `mod` idempotent within a single page render, so two
299 // templates asking for the same module do not pay for it twice.
300 type Ctx struct {
301 Site *Site
302 Repo *gitx.Repo
303 Info RepoInfo
304 Ref string // the ref as the user spelled it ("main", "v0.1.0", a sha)
305 Path string
306 Vars map[string]string
307
308 // User is the authenticated username from the gsg_user session cookie,
309 // or "" for an anonymous request. Resolved once in Server.buildCtx.
310 // Never derived from a request header directly -- see internal/session.
311 User string
312
313 // SharedSlugs and SharedIssuedAt come from a verified gsg_share cookie,
314 // if one was present (SharedSlugs is nil otherwise). They are not yet
315 // filtered for revocation -- access.Policy applies gsg.share-revoked-after
316 // against SharedIssuedAt at the point of use, since revocation is per-repo
317 // and this Ctx may span several.
318 SharedSlugs []string
319 SharedIssuedAt time.Time
320
321 // Nav is the repo-scoped tab bar, built once in Server.serve from the
322 // route table (see nav.go). Empty for site-scoped routes.
323 Nav []NavItem
324
325 // Commit is Ref resolved to a full SHA. Everything a module computes
326 // at a given Commit is immutable, which is what makes the shared
327 // cache and ETags correct rather than merely convenient.
328 Commit string
329
330 // Immutable is true when this response is served with the year-long
331 // immutable cache policy (see Server.serve). Page chrome that can change
332 // without the commit moving must not be rendered into it, because the
333 // browser is told never to revalidate. Operator notices are the first
334 // such thing; this is deliberately the *computed* condition, not just
335 // Route.Immutable, so it follows whatever the caching rule becomes.
336 Immutable bool
337
338 // Store is the process-wide cache, nil in tests. Only consulted when
339 // Commit is set.
340 Store Store
341
342 // StaticTag is Server.StaticTag, threaded through so the `static`
343 // template func can build cache-busting asset URLs without a package
344 // import cycle back to Server.
345 StaticTag string
346
347 mu sync.Mutex
348 cache map[string]result
349 }
350
351 // Store is the cross-request cache. Implemented by internal/core.LRU.
352 type Store interface {
353 Get(key string) (any, bool)
354 Put(key string, v any)
355 }
356
357 type result struct {
358 val any
359 err error
360 }
361
362 func NewCtx(site *Site, r *gitx.Repo, info RepoInfo, ref string) *Ctx {
363 return &Ctx{Site: site, Repo: r, Info: info, Ref: ref,
364 Vars: map[string]string{}, cache: map[string]result{}}
365 }
366
367 // contentIdentity captures every input that can change what a route
368 // renders at a fixed commit -- the commit itself, plus anything that can
369 // change without the commit moving. Vars are part of it: /compare/a..b and
370 // /compare/a...b resolve to the same head commit, so without the base ref
371 // in here the second request was served the first one's diff. GPGKeyring
372 // is part of it for the same reason: watch.go's reconcile can change a
373 // repo's keyring under commits whose SHAs haven't moved, and a page
374 // fetched before that would otherwise cache "unsigned" forever.
375 // Site.Revision() is the site-level equivalent of the keyring: operator
376 // notices change without a commit moving, so a page cached before a notice
377 // edit would otherwise keep serving the old banners forever.
378 //
379 // CacheKey and the response ETag (server.go) are both built from this, so
380 // a new Var or a keyring change can't invalidate one without invalidating
381 // the other -- that used to be two hand-maintained lists that had already
382 // drifted (the ETag was missing GPGKeyring and any Var besides baseSHA).
383 func (c *Ctx) contentIdentity() string {
384 keys := make([]string, 0, len(c.Vars))
385 for k := range c.Vars {
386 keys = append(keys, k)
387 }
388 sort.Strings(keys)
389 var b strings.Builder
390 b.WriteString(c.Commit + "|" + c.Info.GPGKeyring + "|" + c.Site.Revision())
391 for _, k := range keys {
392 b.WriteString("|" + k + "=" + c.Vars[k])
393 }
394 return b.String()
395 }
396
397 // CacheKey identifies an immutable module result. Empty when the result
398 // cannot be cached (no resolved commit).
399 func (c *Ctx) CacheKey(name string, args []string) string {
400 if c.Commit == "" || c.Store == nil {
401 return ""
402 }
403 return c.Info.Slug + "|" + c.Path + "|" + name + "|" +
404 strings.Join(args, "\x00") + "|" + c.contentIdentity()
405 }
406
407 // Derive returns a shallow copy for a different path, with a fresh cache.
408 func (c *Ctx) Derive(path string, vars map[string]string) *Ctx {
409 n := NewCtx(c.Site, c.Repo, c.Info, c.Ref)
410 n.User = c.User
411 n.SharedSlugs = c.SharedSlugs
412 n.SharedIssuedAt = c.SharedIssuedAt
413 n.Path = path
414 for k, v := range c.Vars {
415 n.Vars[k] = v
416 }
417 for k, v := range vars {
418 n.Vars[k] = v
419 }
420 return n
421 }
422
423 func (c *Ctx) Var(k string) string { return c.Vars[k] }
424
425 // cacheMaxAge is how long an anonymous response for this repo may be
426 // reused before revalidating. Server.buildResponseHead (the browser-facing
427 // Cache-Control header) and Server.fillResponse (the server-side response
428 // cache's own TTL) both read this, so the two never disagree about
429 // staleness.
430 func (c *Ctx) cacheMaxAge() time.Duration {
431 if c.Info.CacheMaxAge > 0 {
432 return c.Info.CacheMaxAge
433 }
434 return defaultAnonCacheMaxAge
435 }
436
437 // ---------------------------------------------------------------- registry
438
439 // Module computes one piece of a view. Anything a template or an endpoint
440 // might want is a module: readme, tree, log, refs, graph, blame, stats.
441 type Module func(c *Ctx, args ...string) (any, error)
442
443 var (
444 regMu sync.RWMutex
445 registry = map[string]Module{}
446 )
447
448 // Register adds a module. Call it from an init() in the modules package.
449 func Register(name string, m Module) {
450 regMu.Lock()
451 defer regMu.Unlock()
452 if _, dup := registry[name]; dup {
453 panic("core: duplicate module " + name)
454 }
455 registry[name] = m
456 }
457
458 func Names() []string {
459 regMu.RLock()
460 defer regMu.RUnlock()
461 out := make([]string, 0, len(registry))
462 for n := range registry {
463 out = append(out, n)
464 }
465 return out
466 }
467
468 // Mod runs a module, memoized per Ctx. This is the single entry point
469 // used by both templates (via the funcmap) and JSON endpoints.
470 func (c *Ctx) Mod(name string, args ...string) (any, error) {
471 key := name + "\x00" + strings.Join(args, "\x00")
472
473 c.mu.Lock()
474 if c.cache == nil {
475 c.cache = map[string]result{}
476 }
477 if r, ok := c.cache[key]; ok {
478 c.mu.Unlock()
479 return r.val, r.err
480 }
481 c.mu.Unlock()
482
483 regMu.RLock()
484 m, ok := registry[name]
485 regMu.RUnlock()
486 if !ok {
487 return nil, fmt.Errorf("no such module %q (have: %s)", name, strings.Join(Names(), ", "))
488 }
489
490 // Cross-request cache: a module's output at a fixed commit SHA cannot
491 // change, so this is safe without any invalidation logic. Nothing here
492 // is keyed on a ref name -- refs move, SHAs do not.
493 shared := c.CacheKey(name, args)
494 if shared != "" {
495 if v, ok := c.Store.Get(shared); ok {
496 c.mu.Lock()
497 c.cache[key] = result{v, nil}
498 c.mu.Unlock()
499 return v, nil
500 }
501 }
502
503 val, err := m(c, args...)
504 if err == nil && shared != "" {
505 c.Store.Put(shared, val)
506 }
507
508 c.mu.Lock()
509 c.cache[key] = result{val, err}
510 c.mu.Unlock()
511 return val, err
512 }