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

internal/core/core.go

4bda28df3ab3dbfcc789dcbbb184cabc8cac9398 · 15.4 KB · 498 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 //
154 // Set them with: git --git-dir=/srv/git/foo.git config gsg.title "Foo"
155 type RepoInfo struct {
156 Slug string
157 Title string
158 Description string
159 Owner string
160 Clone string
161 Section string
162 Hidden bool
163
164 // VisibleTo is the gsg.visible-to allowlist: usernames (matched against
165 // an authenticated session, case-insensitively) who may see this repo
166 // even though Hidden is set. A separate key from Owner deliberately --
167 // Owner is a free-text display field (and falls back to gitweb.owner
168 // for cgit-migrated repos), so reusing it as an ACL could silently
169 // grant access nobody intended.
170 VisibleTo []string
171
172 // ShareMaxAge is gsg.share-max-age: both the ceiling no share-link
173 // expiry for this repo may exceed, and the default when a mint request
174 // doesn't specify one. Falls back to defaultShareMaxAge when unset.
175 ShareMaxAge time.Duration
176
177 // ShareRevokedAfter is gsg.share-revoked-after: any share-link grant
178 // for this repo issued at or before this instant is rejected,
179 // regardless of the link's own expiry. It is the only revocation lever
180 // a stateless share token has -- see internal/share.
181 ShareRevokedAfter time.Time
182
183 // GPGKeyring is the configured path to a file of armored public keys
184 // (gsg.gpgkeyring), if any. Reading it here is just config; the actual
185 // import into an isolated keyring happens once at startup (see
186 // gitx.Repo.UseKeyring) and, on failure, this is cleared again so
187 // templates don't advertise a verification column that isn't backed
188 // by anything.
189 GPGKeyring string
190 }
191
192 func LoadRepoInfo(r *gitx.Repo) RepoInfo {
193 title := r.ConfigFirst("gsg.title")
194 if title == "" {
195 title = r.Slug
196 }
197 desc := r.ConfigFirst("gsg.description", "gitweb.description")
198 if desc == "" {
199 desc = r.DescriptionFile() // cgit-compatible fallback
200 }
201 return RepoInfo{
202 Slug: r.Slug,
203 Title: title,
204 Description: desc,
205 Owner: r.ConfigFirst("gsg.owner", "gitweb.owner"),
206 Clone: r.ConfigFirst("gsg.clone", "gitweb.url"),
207 Section: r.ConfigFirst("gsg.section", "gitweb.category"),
208 Hidden: strings.EqualFold(r.Config("gsg.hide"), "true"),
209 VisibleTo: splitUsers(r.Config("gsg.visible-to")),
210 ShareMaxAge: parseShareMaxAge(r.Config("gsg.share-max-age")),
211 ShareRevokedAfter: parseUnixTime(r.Config("gsg.share-revoked-after")),
212 GPGKeyring: r.Config("gsg.gpgkeyring"),
213 }
214 }
215
216 // defaultShareMaxAge is the ceiling/default applied when a repo doesn't set
217 // gsg.share-max-age.
218 const defaultShareMaxAge = 30 * 24 * time.Hour
219
220 func parseShareMaxAge(v string) time.Duration {
221 if v == "" {
222 return defaultShareMaxAge
223 }
224 d, err := time.ParseDuration(v)
225 if err != nil || d <= 0 {
226 return defaultShareMaxAge
227 }
228 return d
229 }
230
231 func parseUnixTime(v string) time.Time {
232 if v == "" {
233 return time.Time{}
234 }
235 sec, err := strconv.ParseInt(v, 10, 64)
236 if err != nil {
237 return time.Time{}
238 }
239 return time.Unix(sec, 0)
240 }
241
242 // splitUsers parses a comma-separated gsg.visible-to value into trimmed,
243 // non-empty usernames.
244 func splitUsers(v string) []string {
245 if v == "" {
246 return nil
247 }
248 parts := strings.Split(v, ",")
249 out := make([]string, 0, len(parts))
250 for _, p := range parts {
251 if p = strings.TrimSpace(p); p != "" {
252 out = append(out, p)
253 }
254 }
255 return out
256 }
257
258 // CanView reports whether user (an authenticated username, or "" for
259 // anonymous) may see this repo even if it's Hidden. Anonymous callers never
260 // match -- VisibleTo entries are login usernames, and "" would otherwise
261 // vacuously match a blank/misconfigured entry.
262 func (info RepoInfo) CanView(user string) bool {
263 if user == "" {
264 return false
265 }
266 for _, u := range info.VisibleTo {
267 if strings.EqualFold(u, user) {
268 return true
269 }
270 }
271 return false
272 }
273
274 // SharedVisible reports whether a verified gsg_share grant -- the slug set
275 // and issued-at time from its ShareClaims -- still unlocks this repo. It's
276 // not enough for slugs to just name this repo: if gsg.share-revoked-after
277 // has been bumped since the grant was issued, it's revoked, even though the
278 // signature on the browser's cookie is still perfectly valid. This is
279 // checked on every request (not just at redemption), which is what makes
280 // bumping the timestamp reach a browser that already redeemed the link.
281 func (info RepoInfo) SharedVisible(slugs []string, issuedAt time.Time) bool {
282 found := false
283 for _, s := range slugs {
284 if s == info.Slug {
285 found = true
286 break
287 }
288 }
289 if !found {
290 return false
291 }
292 if !info.ShareRevokedAfter.IsZero() && !issuedAt.After(info.ShareRevokedAfter) {
293 return false
294 }
295 return true
296 }
297
298 // ----------------------------------------------------------------- context
299
300 // Ctx is what a module is given: which repo, at which ref, at which path.
301 // The cache makes `mod` idempotent within a single page render, so two
302 // templates asking for the same module do not pay for it twice.
303 type Ctx struct {
304 Site *Site
305 Repo *gitx.Repo
306 Info RepoInfo
307 Ref string // the ref as the user spelled it ("main", "v0.1.0", a sha)
308 Path string
309 Vars map[string]string
310
311 // User is the authenticated username from the gsg_user session cookie,
312 // or "" for an anonymous request. Resolved once in Server.buildCtx.
313 // Never derived from a request header directly -- see internal/session.
314 User string
315
316 // SharedSlugs and SharedIssuedAt come from a verified gsg_share cookie,
317 // if one was present (SharedSlugs is nil otherwise). They are not yet
318 // filtered for revocation -- RepoInfo.SharedVisible applies
319 // gsg.share-revoked-after against SharedIssuedAt at the point of use,
320 // since revocation is per-repo and this Ctx may span several.
321 SharedSlugs []string
322 SharedIssuedAt time.Time
323
324 // Nav is the repo-scoped tab bar, built once in Server.serve from the
325 // route table (see nav.go). Empty for site-scoped routes.
326 Nav []NavItem
327
328 // Commit is Ref resolved to a full SHA. Everything a module computes
329 // at a given Commit is immutable, which is what makes the shared
330 // cache and ETags correct rather than merely convenient.
331 Commit string
332
333 // Immutable is true when this response is served with the year-long
334 // immutable cache policy (see Server.serve). Page chrome that can change
335 // without the commit moving must not be rendered into it, because the
336 // browser is told never to revalidate. Operator notices are the first
337 // such thing; this is deliberately the *computed* condition, not just
338 // Route.Immutable, so it follows whatever the caching rule becomes.
339 Immutable bool
340
341 // Store is the process-wide cache, nil in tests. Only consulted when
342 // Commit is set.
343 Store Store
344
345 mu sync.Mutex
346 cache map[string]result
347 }
348
349 // Store is the cross-request cache. Implemented by internal/core.LRU.
350 type Store interface {
351 Get(key string) (any, bool)
352 Put(key string, v any)
353 }
354
355 type result struct {
356 val any
357 err error
358 }
359
360 func NewCtx(site *Site, r *gitx.Repo, info RepoInfo, ref string) *Ctx {
361 return &Ctx{Site: site, Repo: r, Info: info, Ref: ref,
362 Vars: map[string]string{}, cache: map[string]result{}}
363 }
364
365 // contentIdentity captures every input that can change what a route
366 // renders at a fixed commit -- the commit itself, plus anything that can
367 // change without the commit moving. Vars are part of it: /compare/a..b and
368 // /compare/a...b resolve to the same head commit, so without the base ref
369 // in here the second request was served the first one's diff. GPGKeyring
370 // is part of it for the same reason: watch.go's reconcile can change a
371 // repo's keyring under commits whose SHAs haven't moved, and a page
372 // fetched before that would otherwise cache "unsigned" forever.
373 // Site.Revision() is the site-level equivalent of the keyring: operator
374 // notices change without a commit moving, so a page cached before a notice
375 // edit would otherwise keep serving the old banners forever.
376 //
377 // CacheKey and the response ETag (server.go) are both built from this, so
378 // a new Var or a keyring change can't invalidate one without invalidating
379 // the other -- that used to be two hand-maintained lists that had already
380 // drifted (the ETag was missing GPGKeyring and any Var besides baseSHA).
381 func (c *Ctx) contentIdentity() string {
382 keys := make([]string, 0, len(c.Vars))
383 for k := range c.Vars {
384 keys = append(keys, k)
385 }
386 sort.Strings(keys)
387 var b strings.Builder
388 b.WriteString(c.Commit + "|" + c.Info.GPGKeyring + "|" + c.Site.Revision())
389 for _, k := range keys {
390 b.WriteString("|" + k + "=" + c.Vars[k])
391 }
392 return b.String()
393 }
394
395 // CacheKey identifies an immutable module result. Empty when the result
396 // cannot be cached (no resolved commit).
397 func (c *Ctx) CacheKey(name string, args []string) string {
398 if c.Commit == "" || c.Store == nil {
399 return ""
400 }
401 return c.Info.Slug + "|" + c.Path + "|" + name + "|" +
402 strings.Join(args, "\x00") + "|" + c.contentIdentity()
403 }
404
405 // Derive returns a shallow copy for a different path, with a fresh cache.
406 func (c *Ctx) Derive(path string, vars map[string]string) *Ctx {
407 n := NewCtx(c.Site, c.Repo, c.Info, c.Ref)
408 n.User = c.User
409 n.SharedSlugs = c.SharedSlugs
410 n.SharedIssuedAt = c.SharedIssuedAt
411 n.Path = path
412 for k, v := range c.Vars {
413 n.Vars[k] = v
414 }
415 for k, v := range vars {
416 n.Vars[k] = v
417 }
418 return n
419 }
420
421 func (c *Ctx) Var(k string) string { return c.Vars[k] }
422
423 // ---------------------------------------------------------------- registry
424
425 // Module computes one piece of a view. Anything a template or an endpoint
426 // might want is a module: readme, tree, log, refs, graph, blame, stats.
427 type Module func(c *Ctx, args ...string) (any, error)
428
429 var (
430 regMu sync.RWMutex
431 registry = map[string]Module{}
432 )
433
434 // Register adds a module. Call it from an init() in the modules package.
435 func Register(name string, m Module) {
436 regMu.Lock()
437 defer regMu.Unlock()
438 if _, dup := registry[name]; dup {
439 panic("core: duplicate module " + name)
440 }
441 registry[name] = m
442 }
443
444 func Names() []string {
445 regMu.RLock()
446 defer regMu.RUnlock()
447 out := make([]string, 0, len(registry))
448 for n := range registry {
449 out = append(out, n)
450 }
451 return out
452 }
453
454 // Mod runs a module, memoized per Ctx. This is the single entry point
455 // used by both templates (via the funcmap) and JSON endpoints.
456 func (c *Ctx) Mod(name string, args ...string) (any, error) {
457 key := name + "\x00" + strings.Join(args, "\x00")
458
459 c.mu.Lock()
460 if c.cache == nil {
461 c.cache = map[string]result{}
462 }
463 if r, ok := c.cache[key]; ok {
464 c.mu.Unlock()
465 return r.val, r.err
466 }
467 c.mu.Unlock()
468
469 regMu.RLock()
470 m, ok := registry[name]
471 regMu.RUnlock()
472 if !ok {
473 return nil, fmt.Errorf("no such module %q (have: %s)", name, strings.Join(Names(), ", "))
474 }
475
476 // Cross-request cache: a module's output at a fixed commit SHA cannot
477 // change, so this is safe without any invalidation logic. Nothing here
478 // is keyed on a ref name -- refs move, SHAs do not.
479 shared := c.CacheKey(name, args)
480 if shared != "" {
481 if v, ok := c.Store.Get(shared); ok {
482 c.mu.Lock()
483 c.cache[key] = result{v, nil}
484 c.mu.Unlock()
485 return v, nil
486 }
487 }
488
489 val, err := m(c, args...)
490 if err == nil && shared != "" {
491 c.Store.Put(shared, val)
492 }
493
494 c.mu.Lock()
495 c.cache[key] = result{val, err}
496 c.mu.Unlock()
497 return val, err
498 }