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

Compare

79436f09f5539aac9b6f836b5b9049bbbece48aa … main · 4 commits

merge base 79436f09f553…

Changed files

Commits

HashSubjectAuthorDate
554bcfc6 invalid var name and version bump Ryan Schanzenbacher
d658fcc4 merge caching trials back in and solve conflicts Ryan Schanzenbacher
cb5a7c7c add human readme Ryan Schanzenbacher
8d1bb5bf beta-1.3 caching trial Ryan Schanzenbacher
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 }}{{ . }} &middot; {{ 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;">&bull;</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 }}