diff --git a/README.md b/README.md
index 7758ed9..1d94c39 100644
--- a/README.md
+++ b/README.md
@@ -1,503 +1,52 @@
-# gsg — a modular git browser
-
-cgit's weight, Hugo's composability, served dynamically. A **module**
-computes one piece of a view; a **route** binds a renderer to a URL pattern.
-Adding a feature means adding a module and calling it from a template —
-`internal/core` never changes.
-
-One dependency (`goldmark`, which itself has none) plus the `git` binary. Go 1.22+ (uses `net/http`
-pattern wildcards, so there is no router dependency either).
-
-```
-go build -o gsg .
-./gsg -root /srv/git -addr :8080 -name "rschanz.org"
-./gsg -root /srv/git -templates ./templates # re-parse the theme per request
-./gsg -root /srv/git -notices /etc/gsg/notices.yaml -admin-addr 127.0.0.1:8081
-```
-
-## The idea
-
-```go
-type Module func(c *Ctx, args ...string) (any, error)
-```
-
-Templates reach modules through one funcmap entry:
-
-```gotmpl
-{{ with mod "meta" }}<h1>{{ .Title }}</h1>{{ end }}
-{{ $d := mod "diff" }}
-{{ with modOr "readme" }}{{ .HTML }}{{ end }}
-```
-
-- **Lazy.** A template that calls `mod "authors"` is what *causes* authors to
- be computed. Views that don't ask don't pay.
-- **Memoized twice.** Once per request (`Ctx.cache`), and once process-wide
- against the resolved commit SHA (`core.LRU`).
-- **No manifest.** Dependencies are declared by use. There is no module list
- to keep in sync with the templates.
-
-`internal/modules/example_authors.go` is a live demonstration: 30 lines, an
-`init()`, and one `{{ with mod "authors" }}` block. Nothing else moved.
-
-## Pages and endpoints are the same mechanism
-
-A route is `{pattern, renderer}`. `core.View` renders a template,
-`core.JSON` serializes an endpoint. Both take a `*Ctx`, both call modules.
-
-```go
-{Name: "commit", Immutable: true,
- Pattern: "GET /{repo}/commit/{rev}",
- Render: core.View{Template: "commit.html"}},
-
-{Name: "api-commit", Immutable: true,
- Pattern: "GET /{repo}/api/commit/{rev}",
- Render: core.JSON{Build: api.CommitEndpoint}},
-```
-
-### Endpoints use DTOs, not module values
-
-`internal/api` defines explicit structs and never marshals a module's return
-value. Go marshals every exported field by default, so serializing a module
-type means the day someone adds `Body` to `gitx.Commit`, commit messages are
-public. The allowlist is one readable file.
-
-```
-GET /api/repos.json site index
-GET /{repo}/api/repo.json slug, title, description, owner, section,
- clone_url, default_branch, last_commit
-GET /{repo}/api/commit/{sha} sha, short, authored, parents, and
- file/line counts — no subject, no body
-```
-
-## Routes
-
-| Route | URL |
-|---|---|
-| index | `/` |
-| repo | `/{repo}/` — default branch, last update, latest commit, README |
-| files | `/{repo}/tree/{ref}/{path}` |
-| file | `/{repo}/blob/{ref}/{path}` |
-| **raw bytes** | `/{repo}/raw/{ref}/{path}` |
-| log | `/{repo}/log/{ref}` |
-| refs | `/{repo}/refs/` |
-| commit | `/{repo}/commit/{sha}` |
-| **compare** | `/{repo}/compare/{base}..{head}` or `{base}...{head}` |
-| **branch graph** | `/{repo}/graph/` |
-
-### Compare
-
-`a..b` is the literal endpoint difference. `a...b` diffs from the merge
-base, which is what you want when reviewing a branch — it excludes changes
-that landed on the base since the branch forked. Verified against
-`git merge-base` on a diverged fixture: `topic..main` lists `topic.txt` as
-removed, `topic...main` does not.
-
-### Raw files
-
-A repository can contain an `.html` file with a `<script>` tag. Serving that
-as `text/html` on your own origin turns any push into stored XSS against the
-whole site — which is why GitHub puts raw content on
-`raw.githubusercontent.com`, a separate origin. Same-host serving is only
-safe if the response cannot be interpreted as a document, so `/raw/` sends
-`text/plain` (or `application/octet-stream` for binaries, plus
-`Content-Disposition: attachment`), with `X-Content-Type-Options: nosniff`
-and `Content-Security-Policy: default-src \'none\'; sandbox` as a backstop.
-
-The exception is a validated raster image (PNG, JPEG, GIF, WebP, BMP, ICO,
-AVIF): when the extension and the magic bytes agree, `/raw/` declares the
-real `image/*` type inline so the blob page can preview it. An extension by
-itself is not enough — an `.png` whose contents are HTML still downloads as
-`application/octet-stream`. SVG is deliberately excluded: it is an XML
-document that can execute script, so it stays `text/plain`.
-
-If you would rather have real isolation, serve `/raw/` from a second
-hostname and the CSP becomes belt-and-braces rather than the only line.
-
-### Images and structured config files
-
-An image blob shows an inline preview with a `download` link to the same
-`/raw/` URL; images over 10 MiB skip the preview and offer the download only.
-
-JSON and YAML blobs get per-level indent guides, fold controls, key/value
-search, and YAML anchor/alias links. The search box also accepts a value
-path: typing `obj1.property3.items[4].val` resolves it against the parsed
-document and highlights that line and, for a container, its subtree. A
-query containing `.` or `[` is tried as a path first and falls back to
-plain substring search when it does not resolve, so dotted text stays
-searchable. Paths quote keys that need it (`obj1["a.b"]`) and attach indices
-directly; SVG is never previewed, and files past 512 KiB skip the parsing
-tooling entirely.
-
-### Branch graph
-
-`modules/graph.go` lays out the DAG in lanes in one pass over
-`git log --all --topo-order`, which guarantees a child is emitted before its
-parents. `active` holds, per lane, the SHA that lane is waiting to draw; a
-commit claims the lane expecting it (or a fresh one), hands that lane to its
-first parent, and opens a new lane per additional parent — which is what
-makes a merge fork visually.
-
-The module emits the per-row SVG because the geometry *is* the data, but
-every stroke refers to a CSS custom property (`--lane-0` … `--lane-5`), so
-the theme still owns the palette. Row height in the SVG must stay in sync
-with `table.graph td { height }` in the stylesheet.
-
-## Caching, which is the whole reason this works dynamically
-
-Every module result is a pure function of `(repo, commit SHA, path, args)`.
-Refs move; SHAs do not. So the process-wide cache is keyed on the resolved
-SHA and needs **no invalidation logic at all** — a stale entry is impossible
-by construction.
-
-`Ctx.Vars` are part of the cache key, not just the commit. They were not
-originally, and the bug was exactly the kind this cache should be immune to:
-`/compare/a..b` and `/compare/a...b` resolve to the same head commit, so the
-second request was served the first one\'s diff. Anything that changes what a
-module computes has to be in the key.
-
-ETags are derived from the resolved SHA plus a digest of every template
-file, *not* from the response body. A conditional request costs one ref
-lookup instead of a render, and restyling the theme invalidates every ETag
-at once (without the template digest, a restyled page keeps serving 304s).
-
-Two URL classes, two policies:
-
-| URL shape | Example | Cache-Control |
-|---|---|---|
-| SHA-addressed | `/demo/commit/64b90fff…` | `max-age=31536000, immutable` |
-| ref-addressed | `/demo/tree/main` | `no-cache` (revalidate via ETag) |
-
-## Ref-vs-path ambiguity
-
-`/{repo}/tree/{rest...}` fuses them, and `gitx.SplitRefPath` separates them
-by **longest matching prefix against the repo's actual ref list**, so
-`feature/x/src/main.go` resolves correctly. Splitting at the first `/` is
-wrong because branch names contain slashes; cgit dodges this with `?h=`
-query params, GitHub does what this does.
-
-## Serving is an attack surface; static generation was not
-
-- **Ref input never reaches `git rev-parse`.** rev-parse accepts an
- expression language — `:/regex` searches history, `@{...}` reads the
- reflog — and git subcommands treat a leading `-` as an option.
- `gitx.Resolve` accepts either a hex SHA that already exists or an exact
- match against the repo's own ref list. Verified: `--upload-pack=…`,
- `:/secret`, `HEAD@{0}` and `-n` all return 404 with no side effects.
-- **Every git call is `exec.CommandContext`** with a per-request deadline
- (`-timeout`, default 10s) and a scrubbed environment
- (`GIT_CONFIG_NOSYSTEM=1`, no `HOME`, no terminal prompt).
-- **Output is capped** at `MaxGit` (8 MiB) per command; a diff that exceeds
- it renders truncated rather than eating the heap.
-
-## Layout
-
-```
-main.go flags, repo discovery, embedded assets
-internal/gitx/ the ONLY package that knows git exists
-internal/core/core.go Ctx, RepoInfo, module registry, Store
-internal/core/route.go Route, View, JSON, route request parsing
-internal/core/server.go request -> Ctx, ETags, cache policy
-internal/core/cache.go LRU keyed on commit SHAs
-internal/core/funcs.go template funcmap (mod, modOr, dict, date, bytes)
-internal/access/ visibility and share-grant policy (imports no core)
-internal/authn/ login/logout endpoints
-internal/httpx/ shared admin/share HTTP security helpers
-internal/modules/ meta refs log tree readme repos commit diff blob notices
-internal/notices/ notices YAML store + admin UI (imports no core)
-internal/api/ JSON DTOs — the serialization allowlist
-internal/site/routes.go the route table (the file you edit most)
-internal/site/request.go route-specific path parsers (compare ranges)
-templates/ layouts/ + views/
-```
-
-`gitx` shells out to plumbing, one process per query, `git log` in a single
-pass. Swapping in go-git means rewriting that one package.
-
-## Sections
-
-`gsg.section` groups repositories on the index. The `sections` module builds
-on `repos` rather than re-walking the repo set, so the index pays for its log
-lookups once. When no repo sets a section, it returns a single unnamed group
-and the template renders a flat list — no config, no headings.
-
-```sh
-git --git-dir=/srv/git/demo.git config gsg.section "Infrastructure"
-```
-
-## Notices
-
-Operator-authored banners at the top of every page, site-wide and per
-repository. They live in one YAML file that is watched for changes, so
-editing it never needs a restart or a rebuild:
-
-```yaml
-global:
- - type: critical # critical | warning | info | fix
- title: Maintenance window
- body: |
- Saturday 02:00-04:00 UTC.
- The site is read-only during the window.
- link: https://status.example.org
- link_text: status page
-repos:
- demo:
- - type: warning
- title: Migrating to a new remote
-```
-
-```sh
-./gsg -root /srv/git -notices /etc/gsg/notices.yaml
-```
-
-Global notices render above every repository's; on the site index only the
-global list appears. The file is re-read on write (atomic-rename saves from
-an editor included); a parse error keeps the last good content rather than
-blanking the banners.
-
-### Editing from the web
-
-`-admin-addr` starts a second listener with a small HTML form to add, edit,
-and delete notices. It is deliberately separate from the read-only server:
-the admin routes are not reachable on the public port even if the reverse
-proxy is misconfigured.
-
-```sh
-./gsg -root /srv/git -notices /etc/gsg/notices.yaml -admin-addr 127.0.0.1:8081
-```
-
-Protect it with Caddy `forward_auth` and proxy it to the admin listener. gsg
-trusts the network in front of it, so the admin port must not be published
-directly. In Compose, put 8081 on the internal network only and pass
-`-admin-allow-remote`, because a container address is not loopback. Note
-that gsg performs no authentication itself -- the `forward_auth` block below
-is the only thing between the internet and the admin form.
-
-```caddyfile
-example.com {
- # Admin: authenticate first, then proxy to the separate admin listener.
- # handle, not handle_path: the admin mux matches the full /admin/...
- # path, so stripping the prefix would 404 every request.
- handle /admin/* {
- forward_auth authentik:9000 {
- uri /outpost.goauthentik.io/auth/caddy
- copy_headers X-Authentik-Username
- }
- reverse_proxy gsg:8081
- }
-
- # Public, read-only.
- handle {
- reverse_proxy gsg:8080
- }
-}
-```
-
-The `X-Authentik-Username` header is used only to attribute the mutation in
-the log; it is never consulted for authorization. By default Authentik
-decides who may reach the handler at all -- anyone forward_auth lets through
-`/admin/*` can edit notices.
-
-Mutations are POST-only, CSRF-protected (double-submit cookie plus
-`Origin`/`Sec-Fetch-Site`), and logged with the `X-Authentik-Username`
-header, which is used for audit only, never for authorization.
-
-### Gating notices editing to a group
-
-`/admin/*` is also where [Login](#login) mints the `gsg_user` cookie, so
-forward_auth for the whole prefix is usually left open to everyone you want
-able to log in (for share links, hidden-repo visibility, etc). To let more
-people log in without also handing them notices editing, add `-admin-group`:
-
-```sh
-./gsg -root /srv/git -notices /etc/gsg/notices.yaml \
- -admin-addr 127.0.0.1:8081 -admin-group gsg-admins
-```
-
-This checks the `X-Authentik-Groups` header (override with
-`-admin-group-header`) for `gsg-admins` among its `|`-delimited entries,
-Authentik's own format for that header, and answers `403` to any request
-under `/admin/notices` from a caller that isn't a member -- before the
-handler does anything else, including setting the CSRF cookie. Add the group
-header to the same `forward_auth` block used above:
-
-```caddyfile
- forward_auth authentik:9000 {
- uri /outpost.goauthentik.io/auth/caddy
- copy_headers X-Authentik-Username X-Authentik-Groups
- }
-```
-
-Leaving `-admin-group` unset keeps today's behavior: anyone forward_auth
-admits to `/admin/*` can edit notices.
-
-**Immutable commit pages carry no notices.** `/{repo}/commit/{rev}` is
-served `Cache-Control: public, max-age=31536000, immutable`, so a banner
-rendered into it would stay frozen in the reader's browser. Notices are
-suppressed there by design; every other page picks them up on the next
-request.
-
-## Login
-
-Logging in reveals hidden repos whose `gsg.visible-to` list includes your
-username; everyone else, logged in or not, sees exactly what an anonymous
-visitor sees. It reuses the same Authentik `forward_auth` setup as the
-notices admin UI above, and needs nothing extra beyond `-admin-addr`
-(notices editing is not required — `-admin-addr` alone is enough to enable
-login).
-
-```sh
-./gsg -root /srv/git -admin-addr 127.0.0.1:8081
-```
-
-```caddyfile
-example.com {
- # Login: same trust boundary as /admin/*, one hop mints the cookie
- # and redirects back. This must be the exact path, not /auth/*: gsg
- # also serves /auth/logout, but on the *public* listener, since
- # logging out only clears gsg's own cookie and needs no Authentik
- # round trip at all. Routing /auth/* here would send logout to the
- # admin listener too, where no such handler exists, and gate it
- # behind an Authentik check it doesn't need.
- handle /auth/login {
- forward_auth authentik:9000 {
- uri /outpost.goauthentik.io/auth/caddy
- copy_headers X-Authentik-Username
- }
- reverse_proxy gsg:8081
- }
-
- # Public, read-only (also handles /auth/logout, /share/new, /share/view/*).
- handle {
- reverse_proxy gsg:8080
- }
-}
-```
-
-Visiting `/auth/login` (through Caddy, so `X-Authentik-Username` is set and
-trustworthy by the time gsg sees the request) mints a signed, stateless
-`gsg_user` cookie and redirects back to `/`. The cookie is verified on the
-public listener with an in-memory key — nothing is written to disk, and a
-process restart signs everyone out. `/auth/logout` clears it and needs no
-round trip through Authentik, since it only has to forget gsg's own cookie.
-
-**There is no "log in" link anywhere in the UI.** `/auth/login` is not
-discoverable from the site; people who need it are told the URL out of
-band. Once logged in, the nav bar shows who you are and a log-out link.
-
-## Share links
-
-Once logged in, `/share/new` lets you pick from your own hidden repos and
-mint a link that grants view access to exactly those repos to anyone who
-has it — no login, no Authentik involvement. It's entirely stateless: the
-link is a signed, self-contained token, and gsg keeps no record of what's
-been shared. An optional password can gate it.
-
-```
-git --git-dir=/srv/git/demo.git config gsg.share-max-age "720h"
-```
-
-`gsg.share-max-age` (Go duration syntax, default 30 days if unset) is both
-the ceiling no link's expiry may exceed and the default when the minting
-form doesn't specify one — you can always ask for less, never more. Sharing
-several repos at once uses the smallest ceiling among them.
-
-**Passwords are never stored, not even hashed.** The password is folded
-directly into the link's signature instead of being embedded in it: nobody
-holding just the URL has anything to brute-force offline, and checking a
-guess requires asking the server, which rate-limits attempts. This is a
-soft gate appropriate for a small self-hosted tool, not a cryptographic
-secret protecting high-value data.
-
-**Revocation.** A stateless token can't be individually revoked — there's
-no list of what's been issued. The one lever:
-
-```
-git --git-dir=/srv/git/demo.git config gsg.share-revoked-after "$(date +%s)"
-```
-
-Any share grant for that repo issued at or before this timestamp stops
-working immediately, including in a browser that already redeemed the
-link — not just future redemptions. Two things worth knowing before you
-rely on this:
-
-- There's no per-recipient state, so revoking means invalidating *every*
- outstanding share of that repo, then re-sharing with whoever should still
- have it.
-- Removing a username from `gsg.visible-to` does **not** revoke a share
- link that user already minted for that repo. The two mechanisms are
- independent; only `gsg.share-revoked-after` invalidates outstanding
- shares.
-
-## README rendering
-
-`modules/markdown.go` uses goldmark with GFM, Typographer, and automatic
-heading IDs — Hugo\'s own goldmark defaults, so a README renders identically
-to the same file on your blog.
-
-`WithUnsafe` is deliberately not set, which is also Hugo\'s default. Anyone
-who can push can write a README, so raw HTML in markdown is untrusted input;
-goldmark drops it. A `<script>` committed to a README comes out as
-`<!-- raw HTML omitted -->`.
-
-Relative links are rewritten against the file\'s own directory: links become
-the blob view, images become `/raw/` (an `<img>` needs bytes, not an HTML
-page). Absolute URLs, site-absolute paths, fragments and `mailto:` are left
-alone, and anything resolving above the repo root is not rewritten.
-
-## Per-repository config
-
-Read from each repo's own git config, cgit-style, with `gitweb.*` and the
-legacy `description` file as fallbacks, so an existing cgit tree works as-is:
-
-```sh
-git --git-dir=/srv/git/demo.git config gsg.title "Demo Project"
-git --git-dir=/srv/git/demo.git config gsg.description "A demo repository"
-git --git-dir=/srv/git/demo.git config gsg.clone "git://rschanz.org/demo"
-git --git-dir=/srv/git/demo.git config gsg.hide true
-git --git-dir=/srv/git/demo.git config gsg.visible-to "alice,bob"
-git --git-dir=/srv/git/demo.git config gsg.share-max-age "720h"
-git --git-dir=/srv/git/demo.git config gsg.share-revoked-after "$(date +%s)"
-```
-
-Add a key by adding a field to `core.RepoInfo` and a line in `LoadRepoInfo`.
-
-A repo's slug is its directory name (minus `.git`), and slugs share the
-public listener's URL namespace with a few reserved top-level paths:
-`static`, `auth`, and `share`. A repo named e.g. `share.git` would collide
-with the share-link endpoints below and is not supported.
-
-`gsg.visible-to` is a comma-separated allowlist of usernames (matched
-case-insensitively against a logged-in session, see "Login" below) who may
-see this repo even though `gsg.hide` is set. It's a separate key from
-`gsg.owner` on purpose: `Owner` is a free-text display field that falls
-back to `gitweb.owner` for cgit-migrated repos, so treating it as an access
-list could silently grant visibility nobody intended.
-
-## Theme
-
-Monospace-first: every value on these pages is an identifier, hash, path or
-count, so the interface type is the same face as the code, and the sans face
-appears only inside rendered README prose. A cool slate ramp, one indigo
-accent, and a six-colour lane ramp shared by the commit graph and its branch
-chips. Light and dark both defined via `prefers-color-scheme`.
-
-Diffs mark the gutter rather than flooding whole rows with green and red —
-the signal stays readable when a hunk is mostly additions.
-
-No webfonts; system stacks only, so there is no third-party request. All
-colour lives in `:root` custom properties in `static/style.css`.
-
-## Known gaps
-
-- **No syntax highlighting.** `blob` returns numbered plain lines; Chroma
- drops into the same module.
-- **No pagination on `log` or `graph`.** They cap at 200 and 150 commits.
-- **The graph loads all refs into memory** before laying out. Fine to a few
- thousand commits; beyond that it needs windowing.
-- **The LRU is bounded by entry count, not bytes.** A giant diff and a ref
- list cost the same slot. The `Store` interface is the only thing a
- size-aware replacement has to satisfy.
-- **Repo discovery is one level deep** and happens once at startup; a repo
- created later needs a restart.
-- **Module errors surface at render time**, not load time. Add a CI pass
- that renders every view against a fixture repo.
+# My git viewer
+
+### Why I did this
+
+So, the premise behind this project was a few different things:
+
+- I wanted a git viewer stack that, while modular, remained light.
+- I liked the idea of having reusable "templates" rather than generating the HTML dynamically in the code. This is much closer to how things like Hugo operate.
+- It needs to be easy to extend (modularity!). A core is responsible for loading a git repo's context and other request state, but all "real" functionality is implemented via modules. I could add/remove things without needing to worry (too much...) about breaking unrelated things.
+- I wanted something that was modern, but didn't require javascript to operate. All features on this site work without javascript, though your experience is improved with it.
+- I wanted to have a way to view private git repositories using systems I already have.
+ - I don't want to/have the capability to SSH clone my repos to show something off.
+ - Could I have done this in cgit? Perhaps, but it wasn't built for it.
+- And, probably most visibly, this was a trial in "vibe coding". While the idea around the design of this software was mine, I wrote very little of the code. Especially the UI, and this is for the better. I am not a graphics designer and whatever slop the LLM pumps out (with my input here and there) will be leagues better than whatever I do; for better or worse.
+
+The name "gsg" doesn't mean much (I don't name things). It're pretty much "git static generator". It was the placeholder when I was thinking of a system that was "kind of a combination of cgit's lightness and hugo's composability and templating".
+
+### The design
+
+First and foremost, the AI writeup is in ./llm_readme.md. Read that if you want all of the verbose details.
+
+Also, as alluded to above, no javascript is strictly required. The few places it is used it is just used to make the experience nicer, and a fallback always exists. For example, the JSON/YAML codepath has a JS module that allows you to search for values/keys, perform folding, etc. This is a core design tenant: JS always hydrates what exists already.
+
+(Not) Broadly speaking, as stated above, this project is split up into:
+
+- The core
+ - gitx - the module that actually interfaces with git. This transforms a context into a re-usable struct (should I ever want to use a real implementation)
+ - access - Consolidated checks for whether someone can see/not see a repo
+ - session - Owns all privileged access logic. It generates/verifies tokens that are used across the site. Everything is stateless once authenticated.
+ - authn - Route owner that defines login/logout flows. Uses session internally
+ - httpx - Shared helpers used when generating responses/evaling a req
+ - notices - Logic for me to post notices to the site or repos
+ - share - Logic for authenticated users to mint links to private repos they are allowed to. Uses same auth logic as users under the hood.
+ - api - Exposes repo details as a JSON endpoint, will be used by my main site later on
+ - core/site - All the main things needed to make this tick. Build req context, define routes and where they live, define module registration structs.
+- The modules
+ - blob - Responsible for returning files (raw or templated)
+ - commit - Render a commit
+ - compare - Render a diff between two commits/refs
+ - graph - Render commit log graph
+ - image - Image preview, defines compatible file types and max size
+ - markdown - Render markdown
+ - readme - Inherits markdown to render a readme (what you see here!)
+ - notices - Handles cache invalidation for repos that need one rendered
+ - log - View the git log
+ - repos - Aggregates the repos you may view based on session
+ - structured - Whether a file is a YAML or JSON. If so, it prepares data for JS in the frontend to perform folding and suchA
+ - tree - the directory-structure viewer
+
+As time goes on, I am sure I will find more things to add to this. This is really meant to be a project for me, not necessarily building "the next git viewer for everyone".
+
+I figured I would upload this here in case my tokens are useful for others. Enjoy!
diff --git a/go.mod b/go.mod
index 503bebb..96381fd 100644
--- a/go.mod
+++ b/go.mod
@@ -1,11 +1,12 @@
module gsg
-go 1.23
+go 1.23.0
require (
github.com/alecthomas/chroma/v2 v2.24.1
github.com/fsnotify/fsnotify v1.10.1
github.com/yuin/goldmark v1.8.5
+ golang.org/x/sync v0.14.0
gopkg.in/yaml.v3 v3.0.1
)
diff --git a/go.sum b/go.sum
index 1a01f6d..f3f96d6 100644
--- a/go.sum
+++ b/go.sum
@@ -12,6 +12,8 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
+golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
diff --git a/internal/core/core.go b/internal/core/core.go
index 6a9a336..8862350 100644
--- a/internal/core/core.go
+++ b/internal/core/core.go
@@ -151,6 +151,7 @@ type Handle struct {
// section = infra
// gpgkeyring = /path/to/keys.asc
// visible-to = alice,bob
+// cache-max-age = 2m
//
// Set them with: git --git-dir=/srv/git/foo.git config gsg.title "Foo"
type RepoInfo struct {
@@ -188,6 +189,13 @@ type RepoInfo struct {
// templates don't advertise a verification column that isn't backed
// by anything.
GPGKeyring string
+
+ // CacheMaxAge is gsg.cache-max-age: how long an anonymous (no session,
+ // no share grant) response for this repo may be reused -- both by the
+ // requesting browser (Cache-Control: public, max-age=...) and by gsg's
+ // own in-memory response cache (Server.respCache) -- before either has
+ // to ask again. Falls back to defaultAnonCacheMaxAge when unset.
+ CacheMaxAge time.Duration
}
func LoadRepoInfo(r *gitx.Repo) RepoInfo {
@@ -211,6 +219,7 @@ func LoadRepoInfo(r *gitx.Repo) RepoInfo {
ShareMaxAge: parseShareMaxAge(r.Config("gsg.share-max-age")),
ShareRevokedAfter: parseUnixTime(r.Config("gsg.share-revoked-after")),
GPGKeyring: r.Config("gsg.gpgkeyring"),
+ CacheMaxAge: parseCacheMaxAge(r.Config("gsg.cache-max-age")),
}
}
@@ -229,6 +238,21 @@ func parseShareMaxAge(v string) time.Duration {
return d
}
+// defaultAnonCacheMaxAge is the ceiling/default applied when a repo doesn't
+// set gsg.cache-max-age.
+const defaultAnonCacheMaxAge = 2 * time.Minute
+
+func parseCacheMaxAge(v string) time.Duration {
+ if v == "" {
+ return defaultAnonCacheMaxAge
+ }
+ d, err := time.ParseDuration(v)
+ if err != nil || d <= 0 {
+ return defaultAnonCacheMaxAge
+ }
+ return d
+}
+
func parseUnixTime(v string) time.Time {
if v == "" {
return time.Time{}
@@ -315,6 +339,11 @@ type Ctx struct {
// Commit is set.
Store Store
+ // StaticTag is Server.StaticTag, threaded through so the `static`
+ // template func can build cache-busting asset URLs without a package
+ // import cycle back to Server.
+ StaticTag string
+
mu sync.Mutex
cache map[string]result
}
@@ -393,6 +422,18 @@ func (c *Ctx) Derive(path string, vars map[string]string) *Ctx {
func (c *Ctx) Var(k string) string { return c.Vars[k] }
+// cacheMaxAge is how long an anonymous response for this repo may be
+// reused before revalidating. Server.buildResponseHead (the browser-facing
+// Cache-Control header) and Server.fillResponse (the server-side response
+// cache's own TTL) both read this, so the two never disagree about
+// staleness.
+func (c *Ctx) cacheMaxAge() time.Duration {
+ if c.Info.CacheMaxAge > 0 {
+ return c.Info.CacheMaxAge
+ }
+ return defaultAnonCacheMaxAge
+}
+
// ---------------------------------------------------------------- registry
// Module computes one piece of a view. Anything a template or an endpoint
diff --git a/internal/core/funcs.go b/internal/core/funcs.go
index 9db614c..7414c5f 100644
--- a/internal/core/funcs.go
+++ b/internal/core/funcs.go
@@ -31,6 +31,14 @@ func FuncMap(c *Ctx) template.FuncMap {
"url": func(parts ...string) string {
return "/" + strings.Trim(strings.Join(parts, "/"), "/")
},
+ // static builds a cache-busting asset URL, e.g. "css/base.css" ->
+ // "/static/css/base.css?v=<tag>". The `v` param is what lets
+ // main.go's static handler hand out a year-long immutable
+ // Cache-Control instead of a short one -- a URL without it is
+ // still served, just not frozen for a year.
+ "static": func(p string) string {
+ return "/static/" + p + "?v=" + c.StaticTag
+ },
"dict": dict,
"date": func(t time.Time) string {
if t.IsZero() {
@@ -43,7 +51,7 @@ func FuncMap(c *Ctx) template.FuncMap {
if len(s) <= n {
return s
}
- return s[:n] + "\u2026"
+ return s[:n] + "…"
},
"bytes": humanBytes,
"patch": renderPatch,
diff --git a/internal/core/route.go b/internal/core/route.go
index 6ffba36..539b8e7 100644
--- a/internal/core/route.go
+++ b/internal/core/route.go
@@ -80,6 +80,16 @@ type Route struct {
// URL -- anything addressed by a commit SHA. Those get a year-long
// Cache-Control; ref-addressed URLs get revalidation instead.
Immutable bool
+ // CacheableAnon opts a route into Server's in-memory response cache for
+ // anonymous, shareless requests (see Server.cacheEligible) -- a full
+ // rendered response is stored and replayed to the next matching
+ // request within Ctx.cacheMaxAge, without touching git at all. Reserved
+ // for the small-HTML page routes most likely to be a spike's hot page;
+ // left off routes whose bodies can be large or unboundedly numerous
+ // (blob, raw, compare -- see routes.go) and the JSON api-* endpoints,
+ // to keep this pass's scope and memory footprint bounded: respCache is
+ // an LRU bounded by entry count, not bytes.
+ CacheableAnon bool
// NavLabel, when set on a ScopeRepo route, adds a tab for it to the
// repo-scoped navigation (see nav.go) -- the top tab bar and the
// page-nav chips both derive from this instead of hardcoding links.
diff --git a/internal/core/server.go b/internal/core/server.go
index 031604c..4814a62 100644
--- a/internal/core/server.go
+++ b/internal/core/server.go
@@ -1,6 +1,7 @@
package core
import (
+ "context"
"crypto/sha256"
"encoding/hex"
"errors"
@@ -16,6 +17,8 @@ import (
"gsg/internal/access"
"gsg/internal/gitx"
"gsg/internal/session"
+
+ "golang.org/x/sync/singleflight"
)
type Server struct {
@@ -31,23 +34,41 @@ type Server struct {
Sessions *session.Manager
// TemplateDir, when set, re-parses templates on every request. Theme
- // iteration without a restart; leave empty in production.
+ // iteration without a restart; leave empty in production. Also disables
+ // the response cache below, so a live-edited template is never masked
+ // by a stale cached response.
TemplateDir string
TemplateFS fs.FS
+ // StaticTag is a content hash of the embedded static assets (set once
+ // at startup in main.go via HashFS), threaded onto every Ctx so the
+ // `static` template func can build cache-busting asset URLs.
+ StaticTag string
+
mu sync.RWMutex
templates TemplateSet
tmplTag string // changes when templates change; part of every ETag
cache Store
+
+ // respCache holds full rendered responses for CacheableAnon routes,
+ // keyed on the request (route+path+query, see cacheEligible) rather
+ // than content identity. sf collapses every request that arrives while
+ // a key is cold into one fill instead of one per request -- the thing
+ // that actually matters during a traffic spike is many distinct
+ // anonymous visitors hitting the same hot page, not just requests that
+ // happen to land in the same instant.
+ respCache *LRU
+ sf singleflight.Group
}
func NewServer(site *Site, routes []Route, tfs fs.FS, cacheSize int) (*Server, error) {
s := &Server{
Site: site, Routes: routes, TemplateFS: tfs,
- Log: slog.Default(),
- Timeout: 10 * time.Second,
- MaxGit: 16 << 20, // headroom above modules.maxPreviewSize (10 MiB)
- cache: NewLRU(cacheSize),
+ Log: slog.Default(),
+ Timeout: 10 * time.Second,
+ MaxGit: 16 << 20, // headroom above modules.maxPreviewSize (10 MiB)
+ cache: NewLRU(cacheSize),
+ respCache: NewLRU(cacheSize),
}
if err := s.loadTemplates(); err != nil {
return nil, err
@@ -61,7 +82,7 @@ func (s *Server) loadTemplates() error {
if err != nil {
return err
}
- tag, err := hashFS(fsys)
+ tag, err := HashFS(fsys)
if err != nil {
return err
}
@@ -71,9 +92,13 @@ func (s *Server) loadTemplates() error {
return nil
}
-// hashFS digests every template so that editing the theme invalidates every
-// ETag at once. Without this, a restyled page keeps serving 304s.
-func hashFS(fsys fs.FS) (string, error) {
+// HashFS digests every file in fsys, path and contents both, into a short
+// stable tag. Used for the template set (so editing the theme invalidates
+// every ETag at once -- without this a restyled page keeps serving 304s)
+// and, via main.go, for the embedded static assets, so a CSS/JS edit mints
+// a new cache-busting URL instead of being masked by a year-long immutable
+// Cache-Control.
+func HashFS(fsys fs.FS) (string, error) {
h := sha256.New()
err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
@@ -100,6 +125,15 @@ func (s *Server) Handler() http.Handler {
return mux
}
+// cachedResponse is a full rendered response, header and body both, as
+// stored in respCache. expiresAt is only meaningful for entries actually
+// stored there.
+type cachedResponse struct {
+ header http.Header
+ body []byte
+ expiresAt time.Time
+}
+
func (s *Server) serve(rt Route, w http.ResponseWriter, r *http.Request) {
if s.TemplateDir != "" {
if err := s.loadTemplates(); err != nil {
@@ -108,21 +142,160 @@ func (s *Server) serve(rt Route, w http.ResponseWriter, r *http.Request) {
}
}
+ if key, ok := s.cacheEligible(rt, r); ok {
+ if cr, ok := s.respCacheGet(key); ok {
+ writeResponse(w, r, cr)
+ return
+ }
+ // singleflight collapses every request that arrives while this key
+ // is cold into one fill. That fill runs on a context detached from
+ // whichever request happened to trigger it: without that, the
+ // first caller to disconnect or time out would kill the shared
+ // git/render work and fail every other request that coalesced onto
+ // it too, even ones with their own deadline still well in the
+ // future. Bounded by s.Timeout so a genuinely stuck fill still
+ // gets reaped.
+ //
+ // The closure below captures r, the *triggering* (leader) request
+ // -- every follower that coalesces onto this Do gets a response
+ // built from the leader's request, not their own. That's only safe
+ // because cacheEligible already pinned every request-varying input
+ // that matters (user == "", no share cookie) and the key covers
+ // path+query, so leader and followers are asking an identical
+ // question. A future CacheableAnon route whose render depends on
+ // anything else request-specific (a header, say) would silently
+ // break this.
+ v, err, _ := s.sf.Do(key, func() (any, error) {
+ fillCtx, cancel := context.WithTimeout(context.Background(), s.Timeout)
+ defer cancel()
+ return s.fillResponse(reqCtx{fillCtx}, rt, r)
+ })
+ if err != nil {
+ s.fail(w, r, rt, err)
+ return
+ }
+ cr := v.(*cachedResponse)
+ s.respCache.Put(key, cr)
+ writeResponse(w, r, cr)
+ return
+ }
+
ctx, cancel := contextWithTimeout(r, s.Timeout)
defer cancel()
- c, err := s.buildCtx(ctx, rt, r)
+ c, header, err := s.buildResponseHead(ctx, rt, r)
if err != nil {
s.fail(w, r, rt, err)
return
}
+ copyHeader(w.Header(), header)
+ if match := r.Header.Get("If-None-Match"); match != "" && strings.Contains(match, header.Get("ETag")) {
+ w.WriteHeader(http.StatusNotModified)
+ return
+ }
+
+ s.mu.RLock()
+ ts := s.templates
+ s.mu.RUnlock()
+ body, err := rt.Render.Render(c, ts)
+ if err != nil {
+ s.fail(w, r, rt, err)
+ return
+ }
+ w.Header().Set("Content-Type", rt.Render.ContentType())
+ if hs, ok := rt.Render.(HeaderSetter); ok {
+ hs.Headers(c, w.Header())
+ }
+ w.Write(body)
+}
+
+// cacheEligible reports whether rt/r may be served from, and stored in,
+// the shared response cache, and if so, the key to use. Eligibility is
+// decided entirely from cheap, git-free signals -- cookies and
+// already-loaded repo config -- so a cache hit costs nothing beyond a map
+// lookup, and a miss doesn't cost anything extra either before falling
+// through to the real render.
+//
+// Two independent conditions gate it, deliberately redundant: the request
+// must carry neither a session nor a share cookie (the same
+// anonymous-and-shareless condition the Cache-Control split in
+// buildResponseHead already trusts), AND, for a repo-scoped route, that
+// repo must not be Hidden -- checked directly from RepoInfo, with no
+// reference to any cookie at all. A cache entry is shared state replayed
+// to whoever asks next, so a false negative here has a bigger blast
+// radius than a single mis-issued header would: the Hidden check means a
+// bug in cookie detection alone can never leak a hidden repo's content,
+// since that repo's responses are never eligible for the shared cache in
+// the first place.
+func (s *Server) cacheEligible(rt Route, r *http.Request) (string, bool) {
+ if !rt.CacheableAnon || s.TemplateDir != "" {
+ return "", false
+ }
+ if s.Sessions.Verify(r) != "" {
+ return "", false
+ }
+ if _, ok := s.Sessions.VerifyShareCookie(r); ok {
+ return "", false
+ }
+ if rt.Scope == ScopeRepo {
+ h, ok := s.Site.Lookup(r.PathValue("repo"))
+ if !ok || h.Info.Hidden {
+ return "", false
+ }
+ }
+ return rt.Name + "\x00" + r.URL.Path + "\x00" + r.URL.RawQuery, true
+}
+
+func (s *Server) respCacheGet(key string) (*cachedResponse, bool) {
+ v, ok := s.respCache.Get(key)
+ if !ok {
+ return nil, false
+ }
+ cr := v.(*cachedResponse)
+ if time.Now().After(cr.expiresAt) {
+ return nil, false
+ }
+ return cr, true
+}
+
+// fillResponse builds a full response for storage in respCache: header and
+// body both, since an entry has to be complete before anyone else can be
+// served from it.
+func (s *Server) fillResponse(ctx contexter, rt Route, r *http.Request) (*cachedResponse, error) {
+ c, header, err := s.buildResponseHead(ctx, rt, r)
+ if err != nil {
+ return nil, err
+ }
+ s.mu.RLock()
+ ts := s.templates
+ s.mu.RUnlock()
+ body, err := rt.Render.Render(c, ts)
+ if err != nil {
+ return nil, err
+ }
+ header.Set("Content-Type", rt.Render.ContentType())
+ if hs, ok := rt.Render.(HeaderSetter); ok {
+ hs.Headers(c, header)
+ }
+ return &cachedResponse{header: header, body: body, expiresAt: time.Now().Add(c.cacheMaxAge())}, nil
+}
+
+// buildResponseHead resolves the Ctx and computes the ETag/Cache-Control
+// header, without executing the template -- split out so a matching
+// If-None-Match can skip rendering the body entirely.
+//
+// ETag is derived from the resolved commit SHA and every other input that
+// can change what's rendered, not the response body, so a conditional
+// request costs one ref lookup instead of a render.
+func (s *Server) buildResponseHead(ctx contexter, rt Route, r *http.Request) (*Ctx, http.Header, error) {
+ c, err := s.buildCtx(ctx, rt, r)
+ if err != nil {
+ return nil, nil, err
+ }
c.Nav = BuildNav(s.Routes, rt.Name, c.Info.Slug, c.Ref)
- // ETag is derived from the resolved commit SHA, not the response body,
- // so a conditional request costs one ref lookup instead of a render.
s.mu.RLock()
tag := s.tmplTag
- ts := s.templates
s.mu.RUnlock()
// contentIdentity carries the commit, keyring, and every Var (e.g.
@@ -144,48 +317,64 @@ func (s *Server) serve(rt Route, w http.ResponseWriter, r *http.Request) {
// share-holder's listing could be served to another. The cookie's own
// signature bytes already uniquely identify the grant, and folding them
// into a hash never echoes them back in a header.
+ //
+ // s.StaticTag is folded in too: a CSS/JS-only edit changes every
+ // rendered `static "..."` URL, so page ETags have to move with it.
shareCookie := ""
if ck, err := r.Cookie(session.ShareCookieName); err == nil {
shareCookie = ck.Value
}
- etag := `"` + shortHash(rt.Name, r.URL.Path, r.URL.RawQuery, tag, c.contentIdentity(), c.User, shareCookie) + `"`
- w.Header().Set("ETag", etag)
+ etag := `"` + shortHash(rt.Name, r.URL.Path, r.URL.RawQuery, tag, s.StaticTag, c.contentIdentity(), c.User, shareCookie) + `"`
+
+ header := http.Header{}
+ header.Set("ETag", etag)
if c.Immutable {
- w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+ header.Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
// Vary: Cookie applies to every response here, not just personalized
// ones -- the *absence* of gsg_user is also a cache dimension. Without
// it on the anonymous branch too, a shared cache could store an
// anonymous response keyed on URL alone and later serve it back to a
// request that does carry a session cookie.
- w.Header().Add("Vary", "Cookie")
+ header.Add("Vary", "Cookie")
if c.User != "" || len(c.SharedSlugs) > 0 {
- // No shared/intermediary cache (proxy, CDN) may ever store this
- // response, only the requesting browser -- otherwise a
- // misbehaving shared cache could serve one user's hidden-repo
- // listing to a different visitor. Vary: Cookie above is defense
- // in depth for any cache that honors Vary but not
- // Cache-Control: private.
- w.Header().Set("Cache-Control", "private, no-cache")
+ // No shared/intermediary cache (proxy, CDN, or gsg's own
+ // respCache) may ever store this response, only the requesting
+ // browser -- otherwise a misbehaving shared cache could serve
+ // one user's hidden-repo listing to a different visitor. Vary:
+ // Cookie above is defense in depth for any cache that honors
+ // Vary but not Cache-Control: private.
+ header.Set("Cache-Control", "private, no-cache")
} else {
- w.Header().Set("Cache-Control", "no-cache")
+ // public, with a real max-age rather than no-cache: the ETag
+ // already folds in c.User and the share cookie (above), so the
+ // only failure mode for a shared cache that mishandles Vary is
+ // serving this anonymous view to a logged-in or share-holding
+ // visitor within the TTL -- they see fewer repos, or a 404 on a
+ // hidden one. A UX papercut, not a disclosure: the dangerous
+ // direction (private content served to an anonymous visitor)
+ // can't happen here, since that's the separate branch above.
+ header.Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(c.cacheMaxAge().Seconds())))
}
}
- if match := r.Header.Get("If-None-Match"); match != "" && strings.Contains(match, etag) {
- w.WriteHeader(http.StatusNotModified)
- return
+ return c, header, nil
+}
+
+func copyHeader(dst, src http.Header) {
+ for k, vs := range src {
+ for _, v := range vs {
+ dst.Add(k, v)
+ }
}
+}
- body, err := rt.Render.Render(c, ts)
- if err != nil {
- s.fail(w, r, rt, err)
+func writeResponse(w http.ResponseWriter, r *http.Request, cr *cachedResponse) {
+ copyHeader(w.Header(), cr.header)
+ if match := r.Header.Get("If-None-Match"); match != "" && strings.Contains(match, cr.header.Get("ETag")) {
+ w.WriteHeader(http.StatusNotModified)
return
}
- w.Header().Set("Content-Type", rt.Render.ContentType())
- if hs, ok := rt.Render.(HeaderSetter); ok {
- hs.Headers(c, w.Header())
- }
- w.Write(body)
+ w.Write(cr.body)
}
func (s *Server) buildCtx(ctx contexter, rt Route, r *http.Request) (*Ctx, error) {
@@ -202,6 +391,7 @@ func (s *Server) buildCtx(ctx contexter, rt Route, r *http.Request) (*Ctx, error
c.Store = s.cache
c.User = identity.User
c.SharedSlugs, c.SharedIssuedAt = identity.SharedSlugs, identity.SharedIssuedAt
+ c.StaticTag = s.StaticTag
return c, nil
}
@@ -230,6 +420,7 @@ func (s *Server) buildCtx(ctx contexter, rt Route, r *http.Request) (*Ctx, error
c.Store = s.cache
c.User = identity.User
c.SharedSlugs, c.SharedIssuedAt = identity.SharedSlugs, identity.SharedIssuedAt
+ c.StaticTag = s.StaticTag
if !repo.Empty() {
sha, err := repo.Resolve(ref)
@@ -273,9 +464,9 @@ func (s *Server) buildCtx(ctx contexter, rt Route, r *http.Request) (*Ctx, error
}
}
// One source of truth for the immutable policy: the cache header in
- // serve() reads the same field, so page chrome that must not be frozen
- // into a year-long response is suppressed by exactly the condition that
- // makes the response year-long.
+ // buildResponseHead reads the same field, so page chrome that must not
+ // be frozen into a year-long response is suppressed by exactly the
+ // condition that makes the response year-long.
c.Immutable = rt.Immutable && c.Commit != ""
return c, nil
}
@@ -309,9 +500,14 @@ func (s *Server) fail(w http.ResponseWriter, r *http.Request, rt Route, err erro
s.Log.Info("request rejected", "route", rt.Name, "path", r.URL.Path, "status", code, "err", err)
}
- // serve() sets ETag/Cache-Control before Render runs, assuming success.
- // An error response must never inherit those, especially the
- // year-long immutable Cache-Control on commit routes.
+ // The cached-response and cache-fill paths in serve() only ever reach
+ // fail() before anything is written to w, so for those there is
+ // nothing to strip. But the plain (non-cacheable) path copies
+ // buildResponseHead's ETag/Cache-Control onto w *before* calling
+ // Render, on the assumption that Render will succeed -- if it doesn't,
+ // this Del/Set is still what prevents a failed response from going out
+ // with a stale ETag or, worse, the year-long immutable Cache-Control
+ // from a commit route.
w.Header().Del("ETag")
w.Header().Set("Cache-Control", "no-store")
diff --git a/internal/site/routes.go b/internal/site/routes.go
index 9d62e3d..7bfc97c 100644
--- a/internal/site/routes.go
+++ b/internal/site/routes.go
@@ -10,7 +10,7 @@ import (
func Routes() []core.Route {
return []core.Route{
// ---- site-scoped -------------------------------------------------
- {Name: "index", Scope: core.ScopeSite,
+ {Name: "index", Scope: core.ScopeSite, CacheableAnon: true,
Pattern: "GET /{$}",
Render: core.View{Template: "index.html"}},
@@ -21,7 +21,7 @@ func Routes() []core.Route {
// ---- per repository ----------------------------------------------
// NavLabel order below is the order tabs appear in the repo-scoped
// nav (top bar and page-nav chips) -- see core.BuildNav.
- {Name: "repo",
+ {Name: "repo", CacheableAnon: true,
Pattern: "GET /{repo}/{$}",
Render: core.View{Template: "tree.html"}},
@@ -31,11 +31,11 @@ func Routes() []core.Route {
// {rest...} fuses ref and path; SplitRefPath separates them by
// longest-matching ref, so feature/x/src/main.go works.
- {Name: "tree", NavLabel: "files",
+ {Name: "tree", NavLabel: "files", CacheableAnon: true,
Pattern: "GET /{repo}/tree/{rest...}",
Render: core.View{Template: "tree.html"}},
- {Name: "log", NavLabel: "log",
+ {Name: "log", NavLabel: "log", CacheableAnon: true,
Pattern: "GET /{repo}/log/{rest...}",
Render: core.View{Template: "log.html"},
PageSize: core.LogPageSize},
@@ -52,16 +52,22 @@ func Routes() []core.Route {
// The DAG across every ref. {rest...} carries only a ref (never a
// path, there's nothing to browse into) -- it exists purely so the
// selected branch survives navigating here and back out again.
- {Name: "graph", NavLabel: "graph",
+ {Name: "graph", NavLabel: "graph", CacheableAnon: true,
Pattern: "GET /{repo}/graph/{rest...}",
Render: core.View{Template: "graph.html"},
PageSize: core.GraphPageSize},
- {Name: "refs", NavLabel: "refs",
+ {Name: "refs", NavLabel: "refs", CacheableAnon: true,
Pattern: "GET /{repo}/refs/{rest...}",
Render: core.View{Template: "refs.html"}},
- // Arbitrary two-commit diff: /demo/compare/main...feature
+ // Arbitrary two-commit diff: /demo/compare/main...feature. Not
+ // CacheableAnon: a diff body is bounded only by MaxGit (16 MiB,
+ // larger than a blob preview), and every valid ref pair is a
+ // distinct key -- a repo with N refs has ~N^2 reachable ones. The
+ // response cache has no size-aware eviction, so this route would
+ // risk filling it with a few huge entries for no real hit-rate
+ // benefit (see internal/core/route.go's CacheableAnon doc).
{Name: "compare",
Pattern: "GET /{repo}/compare/{range}",
Render: core.View{Template: "compare.html"},
diff --git a/llm_readme.md b/llm_readme.md
new file mode 100644
index 0000000..7758ed9
--- /dev/null
+++ b/llm_readme.md
@@ -0,0 +1,503 @@
+# gsg — a modular git browser
+
+cgit's weight, Hugo's composability, served dynamically. A **module**
+computes one piece of a view; a **route** binds a renderer to a URL pattern.
+Adding a feature means adding a module and calling it from a template —
+`internal/core` never changes.
+
+One dependency (`goldmark`, which itself has none) plus the `git` binary. Go 1.22+ (uses `net/http`
+pattern wildcards, so there is no router dependency either).
+
+```
+go build -o gsg .
+./gsg -root /srv/git -addr :8080 -name "rschanz.org"
+./gsg -root /srv/git -templates ./templates # re-parse the theme per request
+./gsg -root /srv/git -notices /etc/gsg/notices.yaml -admin-addr 127.0.0.1:8081
+```
+
+## The idea
+
+```go
+type Module func(c *Ctx, args ...string) (any, error)
+```
+
+Templates reach modules through one funcmap entry:
+
+```gotmpl
+{{ with mod "meta" }}<h1>{{ .Title }}</h1>{{ end }}
+{{ $d := mod "diff" }}
+{{ with modOr "readme" }}{{ .HTML }}{{ end }}
+```
+
+- **Lazy.** A template that calls `mod "authors"` is what *causes* authors to
+ be computed. Views that don't ask don't pay.
+- **Memoized twice.** Once per request (`Ctx.cache`), and once process-wide
+ against the resolved commit SHA (`core.LRU`).
+- **No manifest.** Dependencies are declared by use. There is no module list
+ to keep in sync with the templates.
+
+`internal/modules/example_authors.go` is a live demonstration: 30 lines, an
+`init()`, and one `{{ with mod "authors" }}` block. Nothing else moved.
+
+## Pages and endpoints are the same mechanism
+
+A route is `{pattern, renderer}`. `core.View` renders a template,
+`core.JSON` serializes an endpoint. Both take a `*Ctx`, both call modules.
+
+```go
+{Name: "commit", Immutable: true,
+ Pattern: "GET /{repo}/commit/{rev}",
+ Render: core.View{Template: "commit.html"}},
+
+{Name: "api-commit", Immutable: true,
+ Pattern: "GET /{repo}/api/commit/{rev}",
+ Render: core.JSON{Build: api.CommitEndpoint}},
+```
+
+### Endpoints use DTOs, not module values
+
+`internal/api` defines explicit structs and never marshals a module's return
+value. Go marshals every exported field by default, so serializing a module
+type means the day someone adds `Body` to `gitx.Commit`, commit messages are
+public. The allowlist is one readable file.
+
+```
+GET /api/repos.json site index
+GET /{repo}/api/repo.json slug, title, description, owner, section,
+ clone_url, default_branch, last_commit
+GET /{repo}/api/commit/{sha} sha, short, authored, parents, and
+ file/line counts — no subject, no body
+```
+
+## Routes
+
+| Route | URL |
+|---|---|
+| index | `/` |
+| repo | `/{repo}/` — default branch, last update, latest commit, README |
+| files | `/{repo}/tree/{ref}/{path}` |
+| file | `/{repo}/blob/{ref}/{path}` |
+| **raw bytes** | `/{repo}/raw/{ref}/{path}` |
+| log | `/{repo}/log/{ref}` |
+| refs | `/{repo}/refs/` |
+| commit | `/{repo}/commit/{sha}` |
+| **compare** | `/{repo}/compare/{base}..{head}` or `{base}...{head}` |
+| **branch graph** | `/{repo}/graph/` |
+
+### Compare
+
+`a..b` is the literal endpoint difference. `a...b` diffs from the merge
+base, which is what you want when reviewing a branch — it excludes changes
+that landed on the base since the branch forked. Verified against
+`git merge-base` on a diverged fixture: `topic..main` lists `topic.txt` as
+removed, `topic...main` does not.
+
+### Raw files
+
+A repository can contain an `.html` file with a `<script>` tag. Serving that
+as `text/html` on your own origin turns any push into stored XSS against the
+whole site — which is why GitHub puts raw content on
+`raw.githubusercontent.com`, a separate origin. Same-host serving is only
+safe if the response cannot be interpreted as a document, so `/raw/` sends
+`text/plain` (or `application/octet-stream` for binaries, plus
+`Content-Disposition: attachment`), with `X-Content-Type-Options: nosniff`
+and `Content-Security-Policy: default-src \'none\'; sandbox` as a backstop.
+
+The exception is a validated raster image (PNG, JPEG, GIF, WebP, BMP, ICO,
+AVIF): when the extension and the magic bytes agree, `/raw/` declares the
+real `image/*` type inline so the blob page can preview it. An extension by
+itself is not enough — an `.png` whose contents are HTML still downloads as
+`application/octet-stream`. SVG is deliberately excluded: it is an XML
+document that can execute script, so it stays `text/plain`.
+
+If you would rather have real isolation, serve `/raw/` from a second
+hostname and the CSP becomes belt-and-braces rather than the only line.
+
+### Images and structured config files
+
+An image blob shows an inline preview with a `download` link to the same
+`/raw/` URL; images over 10 MiB skip the preview and offer the download only.
+
+JSON and YAML blobs get per-level indent guides, fold controls, key/value
+search, and YAML anchor/alias links. The search box also accepts a value
+path: typing `obj1.property3.items[4].val` resolves it against the parsed
+document and highlights that line and, for a container, its subtree. A
+query containing `.` or `[` is tried as a path first and falls back to
+plain substring search when it does not resolve, so dotted text stays
+searchable. Paths quote keys that need it (`obj1["a.b"]`) and attach indices
+directly; SVG is never previewed, and files past 512 KiB skip the parsing
+tooling entirely.
+
+### Branch graph
+
+`modules/graph.go` lays out the DAG in lanes in one pass over
+`git log --all --topo-order`, which guarantees a child is emitted before its
+parents. `active` holds, per lane, the SHA that lane is waiting to draw; a
+commit claims the lane expecting it (or a fresh one), hands that lane to its
+first parent, and opens a new lane per additional parent — which is what
+makes a merge fork visually.
+
+The module emits the per-row SVG because the geometry *is* the data, but
+every stroke refers to a CSS custom property (`--lane-0` … `--lane-5`), so
+the theme still owns the palette. Row height in the SVG must stay in sync
+with `table.graph td { height }` in the stylesheet.
+
+## Caching, which is the whole reason this works dynamically
+
+Every module result is a pure function of `(repo, commit SHA, path, args)`.
+Refs move; SHAs do not. So the process-wide cache is keyed on the resolved
+SHA and needs **no invalidation logic at all** — a stale entry is impossible
+by construction.
+
+`Ctx.Vars` are part of the cache key, not just the commit. They were not
+originally, and the bug was exactly the kind this cache should be immune to:
+`/compare/a..b` and `/compare/a...b` resolve to the same head commit, so the
+second request was served the first one\'s diff. Anything that changes what a
+module computes has to be in the key.
+
+ETags are derived from the resolved SHA plus a digest of every template
+file, *not* from the response body. A conditional request costs one ref
+lookup instead of a render, and restyling the theme invalidates every ETag
+at once (without the template digest, a restyled page keeps serving 304s).
+
+Two URL classes, two policies:
+
+| URL shape | Example | Cache-Control |
+|---|---|---|
+| SHA-addressed | `/demo/commit/64b90fff…` | `max-age=31536000, immutable` |
+| ref-addressed | `/demo/tree/main` | `no-cache` (revalidate via ETag) |
+
+## Ref-vs-path ambiguity
+
+`/{repo}/tree/{rest...}` fuses them, and `gitx.SplitRefPath` separates them
+by **longest matching prefix against the repo's actual ref list**, so
+`feature/x/src/main.go` resolves correctly. Splitting at the first `/` is
+wrong because branch names contain slashes; cgit dodges this with `?h=`
+query params, GitHub does what this does.
+
+## Serving is an attack surface; static generation was not
+
+- **Ref input never reaches `git rev-parse`.** rev-parse accepts an
+ expression language — `:/regex` searches history, `@{...}` reads the
+ reflog — and git subcommands treat a leading `-` as an option.
+ `gitx.Resolve` accepts either a hex SHA that already exists or an exact
+ match against the repo's own ref list. Verified: `--upload-pack=…`,
+ `:/secret`, `HEAD@{0}` and `-n` all return 404 with no side effects.
+- **Every git call is `exec.CommandContext`** with a per-request deadline
+ (`-timeout`, default 10s) and a scrubbed environment
+ (`GIT_CONFIG_NOSYSTEM=1`, no `HOME`, no terminal prompt).
+- **Output is capped** at `MaxGit` (8 MiB) per command; a diff that exceeds
+ it renders truncated rather than eating the heap.
+
+## Layout
+
+```
+main.go flags, repo discovery, embedded assets
+internal/gitx/ the ONLY package that knows git exists
+internal/core/core.go Ctx, RepoInfo, module registry, Store
+internal/core/route.go Route, View, JSON, route request parsing
+internal/core/server.go request -> Ctx, ETags, cache policy
+internal/core/cache.go LRU keyed on commit SHAs
+internal/core/funcs.go template funcmap (mod, modOr, dict, date, bytes)
+internal/access/ visibility and share-grant policy (imports no core)
+internal/authn/ login/logout endpoints
+internal/httpx/ shared admin/share HTTP security helpers
+internal/modules/ meta refs log tree readme repos commit diff blob notices
+internal/notices/ notices YAML store + admin UI (imports no core)
+internal/api/ JSON DTOs — the serialization allowlist
+internal/site/routes.go the route table (the file you edit most)
+internal/site/request.go route-specific path parsers (compare ranges)
+templates/ layouts/ + views/
+```
+
+`gitx` shells out to plumbing, one process per query, `git log` in a single
+pass. Swapping in go-git means rewriting that one package.
+
+## Sections
+
+`gsg.section` groups repositories on the index. The `sections` module builds
+on `repos` rather than re-walking the repo set, so the index pays for its log
+lookups once. When no repo sets a section, it returns a single unnamed group
+and the template renders a flat list — no config, no headings.
+
+```sh
+git --git-dir=/srv/git/demo.git config gsg.section "Infrastructure"
+```
+
+## Notices
+
+Operator-authored banners at the top of every page, site-wide and per
+repository. They live in one YAML file that is watched for changes, so
+editing it never needs a restart or a rebuild:
+
+```yaml
+global:
+ - type: critical # critical | warning | info | fix
+ title: Maintenance window
+ body: |
+ Saturday 02:00-04:00 UTC.
+ The site is read-only during the window.
+ link: https://status.example.org
+ link_text: status page
+repos:
+ demo:
+ - type: warning
+ title: Migrating to a new remote
+```
+
+```sh
+./gsg -root /srv/git -notices /etc/gsg/notices.yaml
+```
+
+Global notices render above every repository's; on the site index only the
+global list appears. The file is re-read on write (atomic-rename saves from
+an editor included); a parse error keeps the last good content rather than
+blanking the banners.
+
+### Editing from the web
+
+`-admin-addr` starts a second listener with a small HTML form to add, edit,
+and delete notices. It is deliberately separate from the read-only server:
+the admin routes are not reachable on the public port even if the reverse
+proxy is misconfigured.
+
+```sh
+./gsg -root /srv/git -notices /etc/gsg/notices.yaml -admin-addr 127.0.0.1:8081
+```
+
+Protect it with Caddy `forward_auth` and proxy it to the admin listener. gsg
+trusts the network in front of it, so the admin port must not be published
+directly. In Compose, put 8081 on the internal network only and pass
+`-admin-allow-remote`, because a container address is not loopback. Note
+that gsg performs no authentication itself -- the `forward_auth` block below
+is the only thing between the internet and the admin form.
+
+```caddyfile
+example.com {
+ # Admin: authenticate first, then proxy to the separate admin listener.
+ # handle, not handle_path: the admin mux matches the full /admin/...
+ # path, so stripping the prefix would 404 every request.
+ handle /admin/* {
+ forward_auth authentik:9000 {
+ uri /outpost.goauthentik.io/auth/caddy
+ copy_headers X-Authentik-Username
+ }
+ reverse_proxy gsg:8081
+ }
+
+ # Public, read-only.
+ handle {
+ reverse_proxy gsg:8080
+ }
+}
+```
+
+The `X-Authentik-Username` header is used only to attribute the mutation in
+the log; it is never consulted for authorization. By default Authentik
+decides who may reach the handler at all -- anyone forward_auth lets through
+`/admin/*` can edit notices.
+
+Mutations are POST-only, CSRF-protected (double-submit cookie plus
+`Origin`/`Sec-Fetch-Site`), and logged with the `X-Authentik-Username`
+header, which is used for audit only, never for authorization.
+
+### Gating notices editing to a group
+
+`/admin/*` is also where [Login](#login) mints the `gsg_user` cookie, so
+forward_auth for the whole prefix is usually left open to everyone you want
+able to log in (for share links, hidden-repo visibility, etc). To let more
+people log in without also handing them notices editing, add `-admin-group`:
+
+```sh
+./gsg -root /srv/git -notices /etc/gsg/notices.yaml \
+ -admin-addr 127.0.0.1:8081 -admin-group gsg-admins
+```
+
+This checks the `X-Authentik-Groups` header (override with
+`-admin-group-header`) for `gsg-admins` among its `|`-delimited entries,
+Authentik's own format for that header, and answers `403` to any request
+under `/admin/notices` from a caller that isn't a member -- before the
+handler does anything else, including setting the CSRF cookie. Add the group
+header to the same `forward_auth` block used above:
+
+```caddyfile
+ forward_auth authentik:9000 {
+ uri /outpost.goauthentik.io/auth/caddy
+ copy_headers X-Authentik-Username X-Authentik-Groups
+ }
+```
+
+Leaving `-admin-group` unset keeps today's behavior: anyone forward_auth
+admits to `/admin/*` can edit notices.
+
+**Immutable commit pages carry no notices.** `/{repo}/commit/{rev}` is
+served `Cache-Control: public, max-age=31536000, immutable`, so a banner
+rendered into it would stay frozen in the reader's browser. Notices are
+suppressed there by design; every other page picks them up on the next
+request.
+
+## Login
+
+Logging in reveals hidden repos whose `gsg.visible-to` list includes your
+username; everyone else, logged in or not, sees exactly what an anonymous
+visitor sees. It reuses the same Authentik `forward_auth` setup as the
+notices admin UI above, and needs nothing extra beyond `-admin-addr`
+(notices editing is not required — `-admin-addr` alone is enough to enable
+login).
+
+```sh
+./gsg -root /srv/git -admin-addr 127.0.0.1:8081
+```
+
+```caddyfile
+example.com {
+ # Login: same trust boundary as /admin/*, one hop mints the cookie
+ # and redirects back. This must be the exact path, not /auth/*: gsg
+ # also serves /auth/logout, but on the *public* listener, since
+ # logging out only clears gsg's own cookie and needs no Authentik
+ # round trip at all. Routing /auth/* here would send logout to the
+ # admin listener too, where no such handler exists, and gate it
+ # behind an Authentik check it doesn't need.
+ handle /auth/login {
+ forward_auth authentik:9000 {
+ uri /outpost.goauthentik.io/auth/caddy
+ copy_headers X-Authentik-Username
+ }
+ reverse_proxy gsg:8081
+ }
+
+ # Public, read-only (also handles /auth/logout, /share/new, /share/view/*).
+ handle {
+ reverse_proxy gsg:8080
+ }
+}
+```
+
+Visiting `/auth/login` (through Caddy, so `X-Authentik-Username` is set and
+trustworthy by the time gsg sees the request) mints a signed, stateless
+`gsg_user` cookie and redirects back to `/`. The cookie is verified on the
+public listener with an in-memory key — nothing is written to disk, and a
+process restart signs everyone out. `/auth/logout` clears it and needs no
+round trip through Authentik, since it only has to forget gsg's own cookie.
+
+**There is no "log in" link anywhere in the UI.** `/auth/login` is not
+discoverable from the site; people who need it are told the URL out of
+band. Once logged in, the nav bar shows who you are and a log-out link.
+
+## Share links
+
+Once logged in, `/share/new` lets you pick from your own hidden repos and
+mint a link that grants view access to exactly those repos to anyone who
+has it — no login, no Authentik involvement. It's entirely stateless: the
+link is a signed, self-contained token, and gsg keeps no record of what's
+been shared. An optional password can gate it.
+
+```
+git --git-dir=/srv/git/demo.git config gsg.share-max-age "720h"
+```
+
+`gsg.share-max-age` (Go duration syntax, default 30 days if unset) is both
+the ceiling no link's expiry may exceed and the default when the minting
+form doesn't specify one — you can always ask for less, never more. Sharing
+several repos at once uses the smallest ceiling among them.
+
+**Passwords are never stored, not even hashed.** The password is folded
+directly into the link's signature instead of being embedded in it: nobody
+holding just the URL has anything to brute-force offline, and checking a
+guess requires asking the server, which rate-limits attempts. This is a
+soft gate appropriate for a small self-hosted tool, not a cryptographic
+secret protecting high-value data.
+
+**Revocation.** A stateless token can't be individually revoked — there's
+no list of what's been issued. The one lever:
+
+```
+git --git-dir=/srv/git/demo.git config gsg.share-revoked-after "$(date +%s)"
+```
+
+Any share grant for that repo issued at or before this timestamp stops
+working immediately, including in a browser that already redeemed the
+link — not just future redemptions. Two things worth knowing before you
+rely on this:
+
+- There's no per-recipient state, so revoking means invalidating *every*
+ outstanding share of that repo, then re-sharing with whoever should still
+ have it.
+- Removing a username from `gsg.visible-to` does **not** revoke a share
+ link that user already minted for that repo. The two mechanisms are
+ independent; only `gsg.share-revoked-after` invalidates outstanding
+ shares.
+
+## README rendering
+
+`modules/markdown.go` uses goldmark with GFM, Typographer, and automatic
+heading IDs — Hugo\'s own goldmark defaults, so a README renders identically
+to the same file on your blog.
+
+`WithUnsafe` is deliberately not set, which is also Hugo\'s default. Anyone
+who can push can write a README, so raw HTML in markdown is untrusted input;
+goldmark drops it. A `<script>` committed to a README comes out as
+`<!-- raw HTML omitted -->`.
+
+Relative links are rewritten against the file\'s own directory: links become
+the blob view, images become `/raw/` (an `<img>` needs bytes, not an HTML
+page). Absolute URLs, site-absolute paths, fragments and `mailto:` are left
+alone, and anything resolving above the repo root is not rewritten.
+
+## Per-repository config
+
+Read from each repo's own git config, cgit-style, with `gitweb.*` and the
+legacy `description` file as fallbacks, so an existing cgit tree works as-is:
+
+```sh
+git --git-dir=/srv/git/demo.git config gsg.title "Demo Project"
+git --git-dir=/srv/git/demo.git config gsg.description "A demo repository"
+git --git-dir=/srv/git/demo.git config gsg.clone "git://rschanz.org/demo"
+git --git-dir=/srv/git/demo.git config gsg.hide true
+git --git-dir=/srv/git/demo.git config gsg.visible-to "alice,bob"
+git --git-dir=/srv/git/demo.git config gsg.share-max-age "720h"
+git --git-dir=/srv/git/demo.git config gsg.share-revoked-after "$(date +%s)"
+```
+
+Add a key by adding a field to `core.RepoInfo` and a line in `LoadRepoInfo`.
+
+A repo's slug is its directory name (minus `.git`), and slugs share the
+public listener's URL namespace with a few reserved top-level paths:
+`static`, `auth`, and `share`. A repo named e.g. `share.git` would collide
+with the share-link endpoints below and is not supported.
+
+`gsg.visible-to` is a comma-separated allowlist of usernames (matched
+case-insensitively against a logged-in session, see "Login" below) who may
+see this repo even though `gsg.hide` is set. It's a separate key from
+`gsg.owner` on purpose: `Owner` is a free-text display field that falls
+back to `gitweb.owner` for cgit-migrated repos, so treating it as an access
+list could silently grant visibility nobody intended.
+
+## Theme
+
+Monospace-first: every value on these pages is an identifier, hash, path or
+count, so the interface type is the same face as the code, and the sans face
+appears only inside rendered README prose. A cool slate ramp, one indigo
+accent, and a six-colour lane ramp shared by the commit graph and its branch
+chips. Light and dark both defined via `prefers-color-scheme`.
+
+Diffs mark the gutter rather than flooding whole rows with green and red —
+the signal stays readable when a hunk is mostly additions.
+
+No webfonts; system stacks only, so there is no third-party request. All
+colour lives in `:root` custom properties in `static/style.css`.
+
+## Known gaps
+
+- **No syntax highlighting.** `blob` returns numbered plain lines; Chroma
+ drops into the same module.
+- **No pagination on `log` or `graph`.** They cap at 200 and 150 commits.
+- **The graph loads all refs into memory** before laying out. Fine to a few
+ thousand commits; beyond that it needs windowing.
+- **The LRU is bounded by entry count, not bytes.** A giant diff and a ref
+ list cost the same slot. The `Store` interface is the only thing a
+ size-aware replacement has to satisfy.
+- **Repo discovery is one level deep** and happens once at startup; a repo
+ created later needs a restart.
+- **Module errors surface at render time**, not load time. Add a CI pass
+ that renders every view against a fixture repo.
diff --git a/main.go b/main.go
index 7f45c48..f5818ee 100644
--- a/main.go
+++ b/main.go
@@ -63,7 +63,7 @@ func main() {
flag.BoolVar(&o.adminAllowRemote, "admin-allow-remote", false, "permit -admin-addr to bind a non-loopback address (required in containers)")
flag.StringVar(&o.adminGroup, "admin-group", "", "group required (via -admin-group-header) to reach the notices admin UI; empty allows anyone the reverse proxy lets through")
flag.StringVar(&o.adminGroupHeader, "admin-group-header", "X-Authentik-Groups", "header carrying the caller's \"|\"-delimited groups, checked against -admin-group")
- flag.IntVar(&o.cacheSize, "cache", 4096, "module cache entries")
+ flag.IntVar(&o.cacheSize, "cache", 4096, "entries per cache (module results and the anonymous response cache each get their own, sized identically)")
flag.DurationVar(&o.timeout, "timeout", 10*time.Second, "per-request git budget")
flag.Parse()
@@ -125,9 +125,18 @@ func run(o options) error {
if err != nil {
return err
}
+ // Content hash of the embedded assets, not a build timestamp, so it's
+ // stable across restarts that don't change any file and only changes
+ // when one actually does -- templates use it (via the `static` funcmap
+ // entry) to mint cache-busting URLs.
+ staticTag, err := core.HashFS(static)
+ if err != nil {
+ return err
+ }
+ srv.StaticTag = staticTag
mux := http.NewServeMux()
- mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
+ mux.Handle("/static/", cachedStatic(static))
// Logout only clears gsg's own cookie -- it needs no round trip through
// Authentik, so it lives on the public listener, not behind
// forward_auth like /auth/login. It's hand-mounted here rather than
@@ -192,7 +201,7 @@ func run(o options) error {
// is same-origin and its /static/ requests go to the public listener,
// but hitting the admin port directly (dev, curl) would otherwise 404
// every stylesheet. Static assets are public either way.
- amux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
+ amux.Handle("/static/", cachedStatic(static))
// /auth/login: reachable only through the same Caddy forward_auth
// block as /admin/*, so by the time a request lands here,
@@ -248,6 +257,27 @@ func run(o options) error {
return <-errc
}
+// cachedStatic serves fsys with a Cache-Control that depends on whether the
+// request carries the `v` cache-busting query param the `static` template
+// func mints (see internal/core/funcs.go). Requests that carry it are
+// addressed by content hash, so they're frozen for a year; everything else
+// -- share/notices/error-page assets, which don't go through that funcmap
+// and so never get a `v` -- still gets a real improvement over no
+// Cache-Control at all, just a short one, in case a template edit changes
+// what a bare URL should serve.
+func cachedStatic(fsys fs.FS) http.Handler {
+ fileServer := http.FileServer(http.FS(fsys))
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Query().Get("v") != "" {
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+ } else {
+ w.Header().Set("Cache-Control", "public, max-age=600")
+ }
+ fileServer.ServeHTTP(w, r)
+ })
+ return http.StripPrefix("/static/", h)
+}
+
// isLoopbackAddr is a blunt guard, not a security boundary: it stops the
// admin listener from silently binding 0.0.0.0 outside a deliberate opt-in.
func isLoopbackAddr(addr string) bool {
diff --git a/templates/layouts/base.html b/templates/layouts/base.html
index 031ca7b..36a7382 100644
--- a/templates/layouts/base.html
+++ b/templates/layouts/base.html
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ block "title" . }}{{ with .Info.Title }}{{ . }} · {{ end }}{{ .Site.Name }}{{ end }}</title>
-<link rel="stylesheet" href="/static/css/base.css">
+<link rel="stylesheet" href="{{ static "css/base.css" }}">
{{ block "styles" . }}{{ end }}
</head>
<body>
@@ -18,7 +18,7 @@
<div class="footer-right">
<!--<span>served from bare repositories</span>
<span style="color:var(--line);margin:0 .5rem;">•</span>-->
- <span>{{ .Site.Name }} v1.3</span>
+ <span>{{ .Site.Name }} v1.4</span>
</div>
</footer>
{{ block "scripts" . }}{{ end }}
diff --git a/templates/views/blob.html b/templates/views/blob.html
index 93aeef9..22b89f9 100644
--- a/templates/views/blob.html
+++ b/templates/views/blob.html
@@ -1,5 +1,5 @@
{{ define "blob.html" }}{{ template "base" . }}{{ end }}
-{{ define "styles" }}<link rel="stylesheet" href="/static/css/blob.css">{{ end }}
+{{ define "styles" }}<link rel="stylesheet" href="{{ static "css/blob.css" }}">{{ end }}
{{ define "main" }}
{{ $slug := print "/" .Info.Slug }}
{{ $b := mod "blob" }}
@@ -66,4 +66,4 @@
{{ end }}
{{ end }}
-{{ define "scripts" }}{{ if (mod "blob").Structured }}<script src="/static/js/structured.js" defer></script>{{ end }}{{ end }}
+{{ define "scripts" }}{{ if (mod "blob").Structured }}<script src="{{ static "js/structured.js" }}" defer></script>{{ end }}{{ end }}
diff --git a/templates/views/commit.html b/templates/views/commit.html
index 9522094..2a1f97b 100644
--- a/templates/views/commit.html
+++ b/templates/views/commit.html
@@ -1,7 +1,7 @@
{{ define "commit.html" }}{{ template "base" . }}{{ end }}
{{ define "styles" }}
-<link rel="stylesheet" href="/static/css/diff.css">
-<link rel="stylesheet" href="/static/css/readme.css">
+<link rel="stylesheet" href="{{ static "css/diff.css" }}">
+<link rel="stylesheet" href="{{ static "css/readme.css" }}">
{{ end }}
{{ define "main" }}
{{ $slug := print "/" .Info.Slug }}
diff --git a/templates/views/compare.html b/templates/views/compare.html
index 8bdc698..8630cd0 100644
--- a/templates/views/compare.html
+++ b/templates/views/compare.html
@@ -1,5 +1,5 @@
{{ define "compare.html" }}{{ template "base" . }}{{ end }}
-{{ define "styles" }}<link rel="stylesheet" href="/static/css/diff.css">{{ end }}
+{{ define "styles" }}<link rel="stylesheet" href="{{ static "css/diff.css" }}">{{ end }}
{{ define "main" }}
{{ $slug := print "/" .Info.Slug }}
{{ $c := mod "compare" }}
diff --git a/templates/views/graph.html b/templates/views/graph.html
index 57deca2..c52dd0d 100644
--- a/templates/views/graph.html
+++ b/templates/views/graph.html
@@ -1,5 +1,5 @@
{{ define "graph.html" }}{{ template "base" . }}{{ end }}
-{{ define "styles" }}<link rel="stylesheet" href="/static/css/graph.css">{{ end }}
+{{ define "styles" }}<link rel="stylesheet" href="{{ static "css/graph.css" }}">{{ end }}
{{ define "main" }}
{{ $slug := print "/" .Info.Slug }}
{{/* "150" here must match core.GraphPageSize -- buildCtx uses that
diff --git a/templates/views/index.html b/templates/views/index.html
index 8f26a42..6c31162 100644
--- a/templates/views/index.html
+++ b/templates/views/index.html
@@ -1,5 +1,5 @@
{{ define "index.html" }}{{ template "base" . }}{{ end }}
-{{ define "styles" }}<link rel="stylesheet" href="/static/css/repo-list.css">{{ end }}
+{{ define "styles" }}<link rel="stylesheet" href="{{ static "css/repo-list.css" }}">{{ end }}
{{ define "main" }}
{{ $secs := mod "sections" }}
diff --git a/templates/views/tree.html b/templates/views/tree.html
index c50b83f..181d8bb 100644
--- a/templates/views/tree.html
+++ b/templates/views/tree.html
@@ -1,7 +1,7 @@
{{ define "tree.html" }}{{ template "base" . }}{{ end }}
{{ define "styles" }}
-<link rel="stylesheet" href="/static/css/repo.css">
-<link rel="stylesheet" href="/static/css/readme.css">
+<link rel="stylesheet" href="{{ static "css/repo.css" }}">
+<link rel="stylesheet" href="{{ static "css/readme.css" }}">
{{ end }}
{{ define "main" }}
{{ $slug := print "/" .Info.Slug }}