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

internal/core/core.go

cb5a7c7c656de5a381e0234208c552c0856e43e3 · 14.5 KB · 471 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 //
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
193 func LoadRepoInfo(r *gitx.Repo) RepoInfo {
194 title := r.ConfigFirst("gsg.title")
195 if title == "" {
196 title = r.Slug
197 }
198 desc := r.ConfigFirst("gsg.description", "gitweb.description")
199 if desc == "" {
200 desc = r.DescriptionFile() // cgit-compatible fallback
201 }
202 return RepoInfo{
203 Slug: r.Slug,
204 Title: title,
205 Description: desc,
206 Owner: r.ConfigFirst("gsg.owner", "gitweb.owner"),
207 Clone: r.ConfigFirst("gsg.clone", "gitweb.url"),
208 Section: r.ConfigFirst("gsg.section", "gitweb.category"),
209 Hidden: strings.EqualFold(r.Config("gsg.hide"), "true"),
210 VisibleTo: splitUsers(r.Config("gsg.visible-to")),
211 ShareMaxAge: parseShareMaxAge(r.Config("gsg.share-max-age")),
212 ShareRevokedAfter: parseUnixTime(r.Config("gsg.share-revoked-after")),
213 GPGKeyring: r.Config("gsg.gpgkeyring"),
214 }
215 }
216
217 // defaultShareMaxAge is the ceiling/default applied when a repo doesn't set
218 // gsg.share-max-age.
219 const defaultShareMaxAge = 30 * 24 * time.Hour
220
221 func parseShareMaxAge(v string) time.Duration {
222 if v == "" {
223 return defaultShareMaxAge
224 }
225 d, err := time.ParseDuration(v)
226 if err != nil || d <= 0 {
227 return defaultShareMaxAge
228 }
229 return d
230 }
231
232 func parseUnixTime(v string) time.Time {
233 if v == "" {
234 return time.Time{}
235 }
236 sec, err := strconv.ParseInt(v, 10, 64)
237 if err != nil {
238 return time.Time{}
239 }
240 return time.Unix(sec, 0)
241 }
242
243 // splitUsers parses a comma-separated gsg.visible-to value into trimmed,
244 // non-empty usernames.
245 func splitUsers(v string) []string {
246 if v == "" {
247 return nil
248 }
249 parts := strings.Split(v, ",")
250 out := make([]string, 0, len(parts))
251 for _, p := range parts {
252 if p = strings.TrimSpace(p); p != "" {
253 out = append(out, p)
254 }
255 }
256 return out
257 }
258
259 // AccessPolicy is the authorization-relevant subset of RepoInfo. The rules
260 // themselves live in internal/access so core, modules, share minting and
261 // share redemption cannot drift into slightly different answers.
262 func (info RepoInfo) AccessPolicy() access.Policy {
263 return access.Policy{
264 Slug: info.Slug,
265 Hidden: info.Hidden,
266 VisibleTo: info.VisibleTo,
267 ShareRevokedAfter: info.ShareRevokedAfter,
268 }
269 }
270
271 // ----------------------------------------------------------------- context
272
273 // Ctx is what a module is given: which repo, at which ref, at which path.
274 // The cache makes `mod` idempotent within a single page render, so two
275 // templates asking for the same module do not pay for it twice.
276 type Ctx struct {
277 Site *Site
278 Repo *gitx.Repo
279 Info RepoInfo
280 Ref string // the ref as the user spelled it ("main", "v0.1.0", a sha)
281 Path string
282 Vars map[string]string
283
284 // User is the authenticated username from the gsg_user session cookie,
285 // or "" for an anonymous request. Resolved once in Server.buildCtx.
286 // Never derived from a request header directly -- see internal/session.
287 User string
288
289 // SharedSlugs and SharedIssuedAt come from a verified gsg_share cookie,
290 // if one was present (SharedSlugs is nil otherwise). They are not yet
291 // filtered for revocation -- access.Policy applies gsg.share-revoked-after
292 // against SharedIssuedAt at the point of use, since revocation is per-repo
293 // and this Ctx may span several.
294 SharedSlugs []string
295 SharedIssuedAt time.Time
296
297 // Nav is the repo-scoped tab bar, built once in Server.serve from the
298 // route table (see nav.go). Empty for site-scoped routes.
299 Nav []NavItem
300
301 // Commit is Ref resolved to a full SHA. Everything a module computes
302 // at a given Commit is immutable, which is what makes the shared
303 // cache and ETags correct rather than merely convenient.
304 Commit string
305
306 // Immutable is true when this response is served with the year-long
307 // immutable cache policy (see Server.serve). Page chrome that can change
308 // without the commit moving must not be rendered into it, because the
309 // browser is told never to revalidate. Operator notices are the first
310 // such thing; this is deliberately the *computed* condition, not just
311 // Route.Immutable, so it follows whatever the caching rule becomes.
312 Immutable bool
313
314 // Store is the process-wide cache, nil in tests. Only consulted when
315 // Commit is set.
316 Store Store
317
318 mu sync.Mutex
319 cache map[string]result
320 }
321
322 // Store is the cross-request cache. Implemented by internal/core.LRU.
323 type Store interface {
324 Get(key string) (any, bool)
325 Put(key string, v any)
326 }
327
328 type result struct {
329 val any
330 err error
331 }
332
333 func NewCtx(site *Site, r *gitx.Repo, info RepoInfo, ref string) *Ctx {
334 return &Ctx{Site: site, Repo: r, Info: info, Ref: ref,
335 Vars: map[string]string{}, cache: map[string]result{}}
336 }
337
338 // contentIdentity captures every input that can change what a route
339 // renders at a fixed commit -- the commit itself, plus anything that can
340 // change without the commit moving. Vars are part of it: /compare/a..b and
341 // /compare/a...b resolve to the same head commit, so without the base ref
342 // in here the second request was served the first one's diff. GPGKeyring
343 // is part of it for the same reason: watch.go's reconcile can change a
344 // repo's keyring under commits whose SHAs haven't moved, and a page
345 // fetched before that would otherwise cache "unsigned" forever.
346 // Site.Revision() is the site-level equivalent of the keyring: operator
347 // notices change without a commit moving, so a page cached before a notice
348 // edit would otherwise keep serving the old banners forever.
349 //
350 // CacheKey and the response ETag (server.go) are both built from this, so
351 // a new Var or a keyring change can't invalidate one without invalidating
352 // the other -- that used to be two hand-maintained lists that had already
353 // drifted (the ETag was missing GPGKeyring and any Var besides baseSHA).
354 func (c *Ctx) contentIdentity() string {
355 keys := make([]string, 0, len(c.Vars))
356 for k := range c.Vars {
357 keys = append(keys, k)
358 }
359 sort.Strings(keys)
360 var b strings.Builder
361 b.WriteString(c.Commit + "|" + c.Info.GPGKeyring + "|" + c.Site.Revision())
362 for _, k := range keys {
363 b.WriteString("|" + k + "=" + c.Vars[k])
364 }
365 return b.String()
366 }
367
368 // CacheKey identifies an immutable module result. Empty when the result
369 // cannot be cached (no resolved commit).
370 func (c *Ctx) CacheKey(name string, args []string) string {
371 if c.Commit == "" || c.Store == nil {
372 return ""
373 }
374 return c.Info.Slug + "|" + c.Path + "|" + name + "|" +
375 strings.Join(args, "\x00") + "|" + c.contentIdentity()
376 }
377
378 // Derive returns a shallow copy for a different path, with a fresh cache.
379 func (c *Ctx) Derive(path string, vars map[string]string) *Ctx {
380 n := NewCtx(c.Site, c.Repo, c.Info, c.Ref)
381 n.User = c.User
382 n.SharedSlugs = c.SharedSlugs
383 n.SharedIssuedAt = c.SharedIssuedAt
384 n.Path = path
385 for k, v := range c.Vars {
386 n.Vars[k] = v
387 }
388 for k, v := range vars {
389 n.Vars[k] = v
390 }
391 return n
392 }
393
394 func (c *Ctx) Var(k string) string { return c.Vars[k] }
395
396 // ---------------------------------------------------------------- registry
397
398 // Module computes one piece of a view. Anything a template or an endpoint
399 // might want is a module: readme, tree, log, refs, graph, blame, stats.
400 type Module func(c *Ctx, args ...string) (any, error)
401
402 var (
403 regMu sync.RWMutex
404 registry = map[string]Module{}
405 )
406
407 // Register adds a module. Call it from an init() in the modules package.
408 func Register(name string, m Module) {
409 regMu.Lock()
410 defer regMu.Unlock()
411 if _, dup := registry[name]; dup {
412 panic("core: duplicate module " + name)
413 }
414 registry[name] = m
415 }
416
417 func Names() []string {
418 regMu.RLock()
419 defer regMu.RUnlock()
420 out := make([]string, 0, len(registry))
421 for n := range registry {
422 out = append(out, n)
423 }
424 return out
425 }
426
427 // Mod runs a module, memoized per Ctx. This is the single entry point
428 // used by both templates (via the funcmap) and JSON endpoints.
429 func (c *Ctx) Mod(name string, args ...string) (any, error) {
430 key := name + "\x00" + strings.Join(args, "\x00")
431
432 c.mu.Lock()
433 if c.cache == nil {
434 c.cache = map[string]result{}
435 }
436 if r, ok := c.cache[key]; ok {
437 c.mu.Unlock()
438 return r.val, r.err
439 }
440 c.mu.Unlock()
441
442 regMu.RLock()
443 m, ok := registry[name]
444 regMu.RUnlock()
445 if !ok {
446 return nil, fmt.Errorf("no such module %q (have: %s)", name, strings.Join(Names(), ", "))
447 }
448
449 // Cross-request cache: a module's output at a fixed commit SHA cannot
450 // change, so this is safe without any invalidation logic. Nothing here
451 // is keyed on a ref name -- refs move, SHAs do not.
452 shared := c.CacheKey(name, args)
453 if shared != "" {
454 if v, ok := c.Store.Get(shared); ok {
455 c.mu.Lock()
456 c.cache[key] = result{v, nil}
457 c.mu.Unlock()
458 return v, nil
459 }
460 }
461
462 val, err := m(c, args...)
463 if err == nil && shared != "" {
464 c.Store.Put(shared, val)
465 }
466
467 c.mu.Lock()
468 c.cache[key] = result{val, err}
469 c.mu.Unlock()
470 return val, err
471 }