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

README.md

4bda28df3ab3dbfcc789dcbbb184cabc8cac9398 · 20.6 KB · 499 lines raw

1 # gsg — a modular git browser
2
3 cgit's weight, Hugo's composability, served dynamically. A **module**
4 computes one piece of a view; a **route** binds a renderer to a URL pattern.
5 Adding a feature means adding a module and calling it from a template —
6 `internal/core` never changes.
7
8 One dependency (`goldmark`, which itself has none) plus the `git` binary. Go 1.22+ (uses `net/http`
9 pattern wildcards, so there is no router dependency either).
10
11 ```
12 go build -o gsg .
13 ./gsg -root /srv/git -addr :8080 -name "rschanz.org"
14 ./gsg -root /srv/git -templates ./templates # re-parse the theme per request
15 ./gsg -root /srv/git -notices /etc/gsg/notices.yaml -admin-addr 127.0.0.1:8081
16 ```
17
18 ## The idea
19
20 ```go
21 type Module func(c *Ctx, args ...string) (any, error)
22 ```
23
24 Templates reach modules through one funcmap entry:
25
26 ```gotmpl
27 {{ with mod "meta" }}<h1>{{ .Title }}</h1>{{ end }}
28 {{ $d := mod "diff" }}
29 {{ with modOr "readme" }}{{ .HTML }}{{ end }}
30 ```
31
32 - **Lazy.** A template that calls `mod "authors"` is what *causes* authors to
33 be computed. Views that don't ask don't pay.
34 - **Memoized twice.** Once per request (`Ctx.cache`), and once process-wide
35 against the resolved commit SHA (`core.LRU`).
36 - **No manifest.** Dependencies are declared by use. There is no module list
37 to keep in sync with the templates.
38
39 `internal/modules/example_authors.go` is a live demonstration: 30 lines, an
40 `init()`, and one `{{ with mod "authors" }}` block. Nothing else moved.
41
42 ## Pages and endpoints are the same mechanism
43
44 A route is `{pattern, renderer}`. `core.View` renders a template,
45 `core.JSON` serializes an endpoint. Both take a `*Ctx`, both call modules.
46
47 ```go
48 {Name: "commit", Immutable: true,
49 Pattern: "GET /{repo}/commit/{rev}",
50 Render: core.View{Template: "commit.html"}},
51
52 {Name: "api-commit", Immutable: true,
53 Pattern: "GET /{repo}/api/commit/{rev}",
54 Render: core.JSON{Build: api.CommitEndpoint}},
55 ```
56
57 ### Endpoints use DTOs, not module values
58
59 `internal/api` defines explicit structs and never marshals a module's return
60 value. Go marshals every exported field by default, so serializing a module
61 type means the day someone adds `Body` to `gitx.Commit`, commit messages are
62 public. The allowlist is one readable file.
63
64 ```
65 GET /api/repos.json site index
66 GET /{repo}/api/repo.json slug, title, description, owner, section,
67 clone_url, default_branch, last_commit
68 GET /{repo}/api/commit/{sha} sha, short, authored, parents, and
69 file/line counts — no subject, no body
70 ```
71
72 ## Routes
73
74 | Route | URL |
75 |---|---|
76 | index | `/` |
77 | repo | `/{repo}/` — default branch, last update, latest commit, README |
78 | files | `/{repo}/tree/{ref}/{path}` |
79 | file | `/{repo}/blob/{ref}/{path}` |
80 | **raw bytes** | `/{repo}/raw/{ref}/{path}` |
81 | log | `/{repo}/log/{ref}` |
82 | refs | `/{repo}/refs/` |
83 | commit | `/{repo}/commit/{sha}` |
84 | **compare** | `/{repo}/compare/{base}..{head}` or `{base}...{head}` |
85 | **branch graph** | `/{repo}/graph/` |
86
87 ### Compare
88
89 `a..b` is the literal endpoint difference. `a...b` diffs from the merge
90 base, which is what you want when reviewing a branch — it excludes changes
91 that landed on the base since the branch forked. Verified against
92 `git merge-base` on a diverged fixture: `topic..main` lists `topic.txt` as
93 removed, `topic...main` does not.
94
95 ### Raw files
96
97 A repository can contain an `.html` file with a `<script>` tag. Serving that
98 as `text/html` on your own origin turns any push into stored XSS against the
99 whole site — which is why GitHub puts raw content on
100 `raw.githubusercontent.com`, a separate origin. Same-host serving is only
101 safe if the response cannot be interpreted as a document, so `/raw/` sends
102 `text/plain` (or `application/octet-stream` for binaries, plus
103 `Content-Disposition: attachment`), with `X-Content-Type-Options: nosniff`
104 and `Content-Security-Policy: default-src \'none\'; sandbox` as a backstop.
105
106 The exception is a validated raster image (PNG, JPEG, GIF, WebP, BMP, ICO,
107 AVIF): when the extension and the magic bytes agree, `/raw/` declares the
108 real `image/*` type inline so the blob page can preview it. An extension by
109 itself is not enough — an `.png` whose contents are HTML still downloads as
110 `application/octet-stream`. SVG is deliberately excluded: it is an XML
111 document that can execute script, so it stays `text/plain`.
112
113 If you would rather have real isolation, serve `/raw/` from a second
114 hostname and the CSP becomes belt-and-braces rather than the only line.
115
116 ### Images and structured config files
117
118 An image blob shows an inline preview with a `download` link to the same
119 `/raw/` URL; images over 10 MiB skip the preview and offer the download only.
120
121 JSON and YAML blobs get per-level indent guides, fold controls, key/value
122 search, and YAML anchor/alias links. The search box also accepts a value
123 path: typing `obj1.property3.items[4].val` resolves it against the parsed
124 document and highlights that line and, for a container, its subtree. A
125 query containing `.` or `[` is tried as a path first and falls back to
126 plain substring search when it does not resolve, so dotted text stays
127 searchable. Paths quote keys that need it (`obj1["a.b"]`) and attach indices
128 directly; SVG is never previewed, and files past 512 KiB skip the parsing
129 tooling entirely.
130
131 ### Branch graph
132
133 `modules/graph.go` lays out the DAG in lanes in one pass over
134 `git log --all --topo-order`, which guarantees a child is emitted before its
135 parents. `active` holds, per lane, the SHA that lane is waiting to draw; a
136 commit claims the lane expecting it (or a fresh one), hands that lane to its
137 first parent, and opens a new lane per additional parent — which is what
138 makes a merge fork visually.
139
140 The module emits the per-row SVG because the geometry *is* the data, but
141 every stroke refers to a CSS custom property (`--lane-0` … `--lane-5`), so
142 the theme still owns the palette. Row height in the SVG must stay in sync
143 with `table.graph td { height }` in the stylesheet.
144
145 ## Caching, which is the whole reason this works dynamically
146
147 Every module result is a pure function of `(repo, commit SHA, path, args)`.
148 Refs move; SHAs do not. So the process-wide cache is keyed on the resolved
149 SHA and needs **no invalidation logic at all** — a stale entry is impossible
150 by construction.
151
152 `Ctx.Vars` are part of the cache key, not just the commit. They were not
153 originally, and the bug was exactly the kind this cache should be immune to:
154 `/compare/a..b` and `/compare/a...b` resolve to the same head commit, so the
155 second request was served the first one\'s diff. Anything that changes what a
156 module computes has to be in the key.
157
158 ETags are derived from the resolved SHA plus a digest of every template
159 file, *not* from the response body. A conditional request costs one ref
160 lookup instead of a render, and restyling the theme invalidates every ETag
161 at once (without the template digest, a restyled page keeps serving 304s).
162
163 Two URL classes, two policies:
164
165 | URL shape | Example | Cache-Control |
166 |---|---|---|
167 | SHA-addressed | `/demo/commit/64b90fff…` | `max-age=31536000, immutable` |
168 | ref-addressed | `/demo/tree/main` | `no-cache` (revalidate via ETag) |
169
170 ## Ref-vs-path ambiguity
171
172 `/{repo}/tree/{rest...}` fuses them, and `gitx.SplitRefPath` separates them
173 by **longest matching prefix against the repo's actual ref list**, so
174 `feature/x/src/main.go` resolves correctly. Splitting at the first `/` is
175 wrong because branch names contain slashes; cgit dodges this with `?h=`
176 query params, GitHub does what this does.
177
178 ## Serving is an attack surface; static generation was not
179
180 - **Ref input never reaches `git rev-parse`.** rev-parse accepts an
181 expression language — `:/regex` searches history, `@{...}` reads the
182 reflog — and git subcommands treat a leading `-` as an option.
183 `gitx.Resolve` accepts either a hex SHA that already exists or an exact
184 match against the repo's own ref list. Verified: `--upload-pack=…`,
185 `:/secret`, `HEAD@{0}` and `-n` all return 404 with no side effects.
186 - **Every git call is `exec.CommandContext`** with a per-request deadline
187 (`-timeout`, default 10s) and a scrubbed environment
188 (`GIT_CONFIG_NOSYSTEM=1`, no `HOME`, no terminal prompt).
189 - **Output is capped** at `MaxGit` (8 MiB) per command; a diff that exceeds
190 it renders truncated rather than eating the heap.
191
192 ## Layout
193
194 ```
195 main.go flags, repo discovery, embedded assets
196 internal/gitx/ the ONLY package that knows git exists
197 internal/core/core.go Ctx, RepoInfo, module registry, Store
198 internal/core/route.go Route, View, JSON
199 internal/core/server.go request -> Ctx, ETags, cache policy
200 internal/core/cache.go LRU keyed on commit SHAs
201 internal/core/funcs.go template funcmap (mod, modOr, dict, date, bytes)
202 internal/modules/ meta refs log tree readme repos commit diff blob notices
203 internal/notices/ notices YAML store + admin UI (imports no core)
204 internal/api/ JSON DTOs — the serialization allowlist
205 internal/site/routes.go the route table (the file you edit most)
206 templates/ layouts/ + views/
207 ```
208
209 `gitx` shells out to plumbing, one process per query, `git log` in a single
210 pass. Swapping in go-git means rewriting that one package.
211
212 ## Sections
213
214 `gsg.section` groups repositories on the index. The `sections` module builds
215 on `repos` rather than re-walking the repo set, so the index pays for its log
216 lookups once. When no repo sets a section, it returns a single unnamed group
217 and the template renders a flat list — no config, no headings.
218
219 ```sh
220 git --git-dir=/srv/git/demo.git config gsg.section "Infrastructure"
221 ```
222
223 ## Notices
224
225 Operator-authored banners at the top of every page, site-wide and per
226 repository. They live in one YAML file that is watched for changes, so
227 editing it never needs a restart or a rebuild:
228
229 ```yaml
230 global:
231 - type: critical # critical | warning | info | fix
232 title: Maintenance window
233 body: |
234 Saturday 02:00-04:00 UTC.
235 The site is read-only during the window.
236 link: https://status.example.org
237 link_text: status page
238 repos:
239 demo:
240 - type: warning
241 title: Migrating to a new remote
242 ```
243
244 ```sh
245 ./gsg -root /srv/git -notices /etc/gsg/notices.yaml
246 ```
247
248 Global notices render above every repository's; on the site index only the
249 global list appears. The file is re-read on write (atomic-rename saves from
250 an editor included); a parse error keeps the last good content rather than
251 blanking the banners.
252
253 ### Editing from the web
254
255 `-admin-addr` starts a second listener with a small HTML form to add, edit,
256 and delete notices. It is deliberately separate from the read-only server:
257 the admin routes are not reachable on the public port even if the reverse
258 proxy is misconfigured.
259
260 ```sh
261 ./gsg -root /srv/git -notices /etc/gsg/notices.yaml -admin-addr 127.0.0.1:8081
262 ```
263
264 Protect it with Caddy `forward_auth` and proxy it to the admin listener. gsg
265 trusts the network in front of it, so the admin port must not be published
266 directly. In Compose, put 8081 on the internal network only and pass
267 `-admin-allow-remote`, because a container address is not loopback. Note
268 that gsg performs no authentication itself -- the `forward_auth` block below
269 is the only thing between the internet and the admin form.
270
271 ```caddyfile
272 example.com {
273 # Admin: authenticate first, then proxy to the separate admin listener.
274 # handle, not handle_path: the admin mux matches the full /admin/...
275 # path, so stripping the prefix would 404 every request.
276 handle /admin/* {
277 forward_auth authentik:9000 {
278 uri /outpost.goauthentik.io/auth/caddy
279 copy_headers X-Authentik-Username
280 }
281 reverse_proxy gsg:8081
282 }
283
284 # Public, read-only.
285 handle {
286 reverse_proxy gsg:8080
287 }
288 }
289 ```
290
291 The `X-Authentik-Username` header is used only to attribute the mutation in
292 the log; it is never consulted for authorization. By default Authentik
293 decides who may reach the handler at all -- anyone forward_auth lets through
294 `/admin/*` can edit notices.
295
296 Mutations are POST-only, CSRF-protected (double-submit cookie plus
297 `Origin`/`Sec-Fetch-Site`), and logged with the `X-Authentik-Username`
298 header, which is used for audit only, never for authorization.
299
300 ### Gating notices editing to a group
301
302 `/admin/*` is also where [Login](#login) mints the `gsg_user` cookie, so
303 forward_auth for the whole prefix is usually left open to everyone you want
304 able to log in (for share links, hidden-repo visibility, etc). To let more
305 people log in without also handing them notices editing, add `-admin-group`:
306
307 ```sh
308 ./gsg -root /srv/git -notices /etc/gsg/notices.yaml \
309 -admin-addr 127.0.0.1:8081 -admin-group gsg-admins
310 ```
311
312 This checks the `X-Authentik-Groups` header (override with
313 `-admin-group-header`) for `gsg-admins` among its `|`-delimited entries,
314 Authentik's own format for that header, and answers `403` to any request
315 under `/admin/notices` from a caller that isn't a member -- before the
316 handler does anything else, including setting the CSRF cookie. Add the group
317 header to the same `forward_auth` block used above:
318
319 ```caddyfile
320 forward_auth authentik:9000 {
321 uri /outpost.goauthentik.io/auth/caddy
322 copy_headers X-Authentik-Username X-Authentik-Groups
323 }
324 ```
325
326 Leaving `-admin-group` unset keeps today's behavior: anyone forward_auth
327 admits to `/admin/*` can edit notices.
328
329 **Immutable commit pages carry no notices.** `/{repo}/commit/{rev}` is
330 served `Cache-Control: public, max-age=31536000, immutable`, so a banner
331 rendered into it would stay frozen in the reader's browser. Notices are
332 suppressed there by design; every other page picks them up on the next
333 request.
334
335 ## Login
336
337 Logging in reveals hidden repos whose `gsg.visible-to` list includes your
338 username; everyone else, logged in or not, sees exactly what an anonymous
339 visitor sees. It reuses the same Authentik `forward_auth` setup as the
340 notices admin UI above, and needs nothing extra beyond `-admin-addr`
341 (notices editing is not required — `-admin-addr` alone is enough to enable
342 login).
343
344 ```sh
345 ./gsg -root /srv/git -admin-addr 127.0.0.1:8081
346 ```
347
348 ```caddyfile
349 example.com {
350 # Login: same trust boundary as /admin/*, one hop mints the cookie
351 # and redirects back. This must be the exact path, not /auth/*: gsg
352 # also serves /auth/logout, but on the *public* listener, since
353 # logging out only clears gsg's own cookie and needs no Authentik
354 # round trip at all. Routing /auth/* here would send logout to the
355 # admin listener too, where no such handler exists, and gate it
356 # behind an Authentik check it doesn't need.
357 handle /auth/login {
358 forward_auth authentik:9000 {
359 uri /outpost.goauthentik.io/auth/caddy
360 copy_headers X-Authentik-Username
361 }
362 reverse_proxy gsg:8081
363 }
364
365 # Public, read-only (also handles /auth/logout, /share/new, /share/view/*).
366 handle {
367 reverse_proxy gsg:8080
368 }
369 }
370 ```
371
372 Visiting `/auth/login` (through Caddy, so `X-Authentik-Username` is set and
373 trustworthy by the time gsg sees the request) mints a signed, stateless
374 `gsg_user` cookie and redirects back to `/`. The cookie is verified on the
375 public listener with an in-memory key — nothing is written to disk, and a
376 process restart signs everyone out. `/auth/logout` clears it and needs no
377 round trip through Authentik, since it only has to forget gsg's own cookie.
378
379 **There is no "log in" link anywhere in the UI.** `/auth/login` is not
380 discoverable from the site; people who need it are told the URL out of
381 band. Once logged in, the nav bar shows who you are and a log-out link.
382
383 ## Share links
384
385 Once logged in, `/share/new` lets you pick from your own hidden repos and
386 mint a link that grants view access to exactly those repos to anyone who
387 has it — no login, no Authentik involvement. It's entirely stateless: the
388 link is a signed, self-contained token, and gsg keeps no record of what's
389 been shared. An optional password can gate it.
390
391 ```
392 git --git-dir=/srv/git/demo.git config gsg.share-max-age "720h"
393 ```
394
395 `gsg.share-max-age` (Go duration syntax, default 30 days if unset) is both
396 the ceiling no link's expiry may exceed and the default when the minting
397 form doesn't specify one — you can always ask for less, never more. Sharing
398 several repos at once uses the smallest ceiling among them.
399
400 **Passwords are never stored, not even hashed.** The password is folded
401 directly into the link's signature instead of being embedded in it: nobody
402 holding just the URL has anything to brute-force offline, and checking a
403 guess requires asking the server, which rate-limits attempts. This is a
404 soft gate appropriate for a small self-hosted tool, not a cryptographic
405 secret protecting high-value data.
406
407 **Revocation.** A stateless token can't be individually revoked — there's
408 no list of what's been issued. The one lever:
409
410 ```
411 git --git-dir=/srv/git/demo.git config gsg.share-revoked-after "$(date +%s)"
412 ```
413
414 Any share grant for that repo issued at or before this timestamp stops
415 working immediately, including in a browser that already redeemed the
416 link — not just future redemptions. Two things worth knowing before you
417 rely on this:
418
419 - There's no per-recipient state, so revoking means invalidating *every*
420 outstanding share of that repo, then re-sharing with whoever should still
421 have it.
422 - Removing a username from `gsg.visible-to` does **not** revoke a share
423 link that user already minted for that repo. The two mechanisms are
424 independent; only `gsg.share-revoked-after` invalidates outstanding
425 shares.
426
427 ## README rendering
428
429 `modules/markdown.go` uses goldmark with GFM, Typographer, and automatic
430 heading IDs — Hugo\'s own goldmark defaults, so a README renders identically
431 to the same file on your blog.
432
433 `WithUnsafe` is deliberately not set, which is also Hugo\'s default. Anyone
434 who can push can write a README, so raw HTML in markdown is untrusted input;
435 goldmark drops it. A `<script>` committed to a README comes out as
436 `<!-- raw HTML omitted -->`.
437
438 Relative links are rewritten against the file\'s own directory: links become
439 the blob view, images become `/raw/` (an `<img>` needs bytes, not an HTML
440 page). Absolute URLs, site-absolute paths, fragments and `mailto:` are left
441 alone, and anything resolving above the repo root is not rewritten.
442
443 ## Per-repository config
444
445 Read from each repo's own git config, cgit-style, with `gitweb.*` and the
446 legacy `description` file as fallbacks, so an existing cgit tree works as-is:
447
448 ```sh
449 git --git-dir=/srv/git/demo.git config gsg.title "Demo Project"
450 git --git-dir=/srv/git/demo.git config gsg.description "A demo repository"
451 git --git-dir=/srv/git/demo.git config gsg.clone "git://rschanz.org/demo"
452 git --git-dir=/srv/git/demo.git config gsg.hide true
453 git --git-dir=/srv/git/demo.git config gsg.visible-to "alice,bob"
454 git --git-dir=/srv/git/demo.git config gsg.share-max-age "720h"
455 git --git-dir=/srv/git/demo.git config gsg.share-revoked-after "$(date +%s)"
456 ```
457
458 Add a key by adding a field to `core.RepoInfo` and a line in `LoadRepoInfo`.
459
460 A repo's slug is its directory name (minus `.git`), and slugs share the
461 public listener's URL namespace with a few reserved top-level paths:
462 `static`, `auth`, and `share`. A repo named e.g. `share.git` would collide
463 with the share-link endpoints below and is not supported.
464
465 `gsg.visible-to` is a comma-separated allowlist of usernames (matched
466 case-insensitively against a logged-in session, see "Login" below) who may
467 see this repo even though `gsg.hide` is set. It's a separate key from
468 `gsg.owner` on purpose: `Owner` is a free-text display field that falls
469 back to `gitweb.owner` for cgit-migrated repos, so treating it as an access
470 list could silently grant visibility nobody intended.
471
472 ## Theme
473
474 Monospace-first: every value on these pages is an identifier, hash, path or
475 count, so the interface type is the same face as the code, and the sans face
476 appears only inside rendered README prose. A cool slate ramp, one indigo
477 accent, and a six-colour lane ramp shared by the commit graph and its branch
478 chips. Light and dark both defined via `prefers-color-scheme`.
479
480 Diffs mark the gutter rather than flooding whole rows with green and red —
481 the signal stays readable when a hunk is mostly additions.
482
483 No webfonts; system stacks only, so there is no third-party request. All
484 colour lives in `:root` custom properties in `static/style.css`.
485
486 ## Known gaps
487
488 - **No syntax highlighting.** `blob` returns numbered plain lines; Chroma
489 drops into the same module.
490 - **No pagination on `log` or `graph`.** They cap at 200 and 150 commits.
491 - **The graph loads all refs into memory** before laying out. Fine to a few
492 thousand commits; beyond that it needs windowing.
493 - **The LRU is bounded by entry count, not bytes.** A giant diff and a ref
494 list cost the same slot. The `Store` interface is the only thing a
495 size-aware replacement has to satisfy.
496 - **Repo discovery is one level deep** and happens once at startup; a repo
497 created later needs a restart.
498 - **Module errors surface at render time**, not load time. Add a CI pass
499 that renders every view against a fixture repo.