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

internal/core/core.go

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