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

Compare

3a50036d96eb14462e96598fd169d1c79264ac4b … caching_trials · 5 commits

merge base 3a50036d96eb…

Changed files

FileChanges
AGENTS.md +37 −1 raw
Dockerfile +16 −0 raw
README.md +246 −3 raw
go.mod +13 −2 raw
go.sum +20 −0 raw
gpgverif +12 −0 raw
internal/core/core.go +330 −24 raw
internal/core/core_test.go +27 −0 raw
internal/core/errorpage.go +65 −0 raw
internal/core/funcs.go +9 −1 raw
internal/core/nav.go +70 −0 raw
internal/core/route.go +47 −2 raw
internal/core/server.go +331 −39 raw
internal/core/watch.go +196 −0 raw
internal/gitx/git.go +302 −16 raw
internal/gitx/git_test.go +157 −0 raw
internal/modules/blob.go +87 −9 raw
internal/modules/blob_test.go +121 −0 raw
internal/modules/commit.go +1 −1 raw
internal/modules/compare.go +1 −1 raw
internal/modules/example_authors.go +1 −1 raw
internal/modules/graph.go +39 −3 raw
internal/modules/highlight.go +79 −0 raw
internal/modules/image.go +72 −0 raw
internal/modules/image_test.go +99 −0 raw
internal/modules/log.go +38 −3 raw
internal/modules/meta.go +1 −1 raw
internal/modules/notices.go +29 −0 raw
internal/modules/notices_test.go +88 −0 raw
internal/modules/readme.go +8 −2 raw
internal/modules/repos.go +86 −9 raw
internal/modules/structured.go +516 −0 raw
internal/modules/structured_test.go +274 −0 raw
internal/notices/admin.html +64 −0 raw
internal/notices/handler.go +316 −0 raw
internal/notices/handler_test.go +114 −0 raw
internal/notices/notices.go +221 −0 raw
internal/notices/store.go +230 −0 raw
internal/notices/store_test.go +150 −0 raw
internal/session/session.go +280 −0 raw
internal/share/new.html +64 −0 raw
internal/share/share.go +435 −0 raw
internal/share/view.html +30 −0 raw
internal/site/routes.go +26 −16 raw
main.go +243 −51 raw
static/css/admin.css +60 −0 raw
static/css/base.css +542 −0 raw
static/css/blob.css +168 −0 raw
static/css/diff.css +33 −0 raw
static/css/graph.css +30 −0 raw
static/css/readme.css +66 −0 raw
static/css/repo-list.css +121 −0 raw
static/css/repo.css +46 −0 raw
static/css/share.css +63 −0 raw
static/js/admin.js +60 −0 raw
static/js/structured.js +346 −0 raw
static/style.css +0 −348 raw
templates/layouts/base.html +56 −26 raw
templates/layouts/notices.html +24 −0 raw
templates/layouts/partials.html +36 −21 raw
templates/views/blob.html +64 −16 raw
templates/views/commit.html +49 −16 raw
templates/views/compare.html +41 −18 raw
templates/views/graph.html +46 −19 raw
templates/views/index.html +28 −9 raw
templates/views/log.html +66 −5 raw
templates/views/refs.html +54 −28 raw
templates/views/repo.html +0 −41 raw
templates/views/tree.html +84 −9 raw

Commits

HashSubjectAuthorDate
8d1bb5bf beta-1.3 caching trial Ryan Schanzenbacher
4bda28df bump version Ryan Schanzenbacher
83aa601b Added share plumbing, some admin changes, bump max image preview Ryan Schanzenbacher
38fa4e6c many many changes Ryan Schanzenbacher
c0427d5f various changes Ryan Schanzenbacher
diff --git a/AGENTS.md b/AGENTS.md
index 676ef83..ee02972 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -51,6 +51,7 @@ main.go flags, repo discovery, embedded assets
internal/gitx/ the ONLY package that knows git exists
internal/core/ Ctx, module registry, routes, server, cache, funcmap
internal/modules/ the extension point: one module per concern
+internal/notices/ operator notices: YAML store + admin UI (imports no core)
internal/api/ JSON DTOs -- the serialization allowlist
internal/site/ the route table
templates/ layouts/ + views/
@@ -127,6 +128,17 @@ The cache needs no invalidation logic because it is keyed on immutable SHAs.
Do not add TTLs or manual eviction on the assumption that entries go stale —
if an entry can go stale, the key is wrong.
+`Ctx.contentIdentity()` also folds in inputs that change *without* the commit
+moving: `RepoInfo.GPGKeyring` and `Site.Revision()`. Site revision is the
+site-level analogue of the keyring; operator notices use it so a banner edit
+invalidates both the module cache and every ETag. Anything else with that
+shape belongs there too.
+
+`Ctx.Immutable` marks responses served with the year-long immutable cache
+(see `Server.serve`). Commit-independent chrome must not be rendered into
+them, because the browser is told never to revalidate; the notices partial
+skips those pages for exactly this reason.
+
### 4. JSON endpoints use DTOs, never module values
`internal/api` defines explicit structs with json tags. Go marshals every
@@ -143,10 +155,18 @@ A repository can contain an `.html` file with a `<script>` tag. Serving it as
`text/html` on this origin turns any push into stored XSS against the whole
site — which is why GitHub puts raw content on a separate hostname. Raw
responses are `text/plain` (or `application/octet-stream` plus
-`Content-Disposition: attachment` for binaries), with
+`Content-Disposition: attachment` for binaries) with
`X-Content-Type-Options: nosniff` and
`Content-Security-Policy: default-src 'none'; sandbox`.
+The one deliberate exception is a validated raster image (PNG, JPEG, GIF,
+WebP, BMP, ICO, AVIF): `modules/image.go` requires the extension and the
+magic bytes to agree before `/raw/` may declare `image/*`, so an `.png`
+holding HTML is not relabelled. SVG is never in that list — it is an XML
+document that can carry a script, so it stays `text/plain`. A raster image
+is safe to serve inline because it cannot execute and `nosniff` stops the
+declared type from being re-sniffed; the blob page needs those bytes on the
+same origin for its `<img>` preview, and the same URL is the download link.
If raw content is ever moved to its own hostname, keep these headers anyway.
### 6. goldmark runs without `WithUnsafe`
@@ -237,6 +257,18 @@ A config key that nothing reads is a bug. `gsg.section` sat unused for a
while; if you add a key, wire it into a template or an endpoint in the same
change.
+### Add an operator notice source
+
+Notices (global and per-repo banners) live in `internal/notices`: a
+file-backed store watched for changes, plus the admin listener. The
+`notices` module merges the global list with the current repo's, and
+`templates/layouts/notices.html` renders them — but only when
+`Ctx.Immutable` is false. `main.go` wires the store to `Site.RevFn` so a
+change invalidates caches. `internal/notices` deliberately does not import
+`internal/core`; keep it that way so the module and the admin handler can
+both reach it without a cycle. The admin listener is the one deliberate
+write path, and it writes a config file, never a repository.
+
## Building and checking
```sh
@@ -304,3 +336,7 @@ Accurate as of now; update this list when you close one.
lookup per link.
- Module errors surface at render time, not load time. A CI pass that renders
every view against a fixture repository would catch them earlier.
+- Path queries in the structured JSON/YAML search store the deepest path
+ found for a line. Flow style (`{"a": 1, "b": 2}`) puts several values on
+ one row and keeps only the last path; a container query still reaches the
+ others through the prefix match, but a sibling's exact path does not.
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..37babad
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,16 @@
+FROM --platform=$BUILDPLATFORM golang:1.23-alpine AS build
+WORKDIR /src
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+ARG TARGETOS=linux
+ARG TARGETARCH=amd64
+RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/gsg .
+
+FROM alpine:3.20
+RUN apk add --no-cache git gnupg
+COPY --from=build /out/gsg /usr/local/bin/gsg
+
+EXPOSE 8080
+ENTRYPOINT ["gsg"]
+CMD ["-root", "/srv/git", "-addr", ":8080"]
diff --git a/README.md b/README.md
index 4754120..597fa40 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,7 @@ 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
@@ -97,14 +98,36 @@ 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/` always
-sends `text/plain` (or `application/octet-stream` for binaries, plus
+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
@@ -176,7 +199,8 @@ internal/core/route.go Route, View, JSON
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/modules/ meta refs log tree readme repos commit diff blob
+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)
templates/ layouts/ + views/
@@ -196,6 +220,210 @@ and the template renders a flat list — no config, no headings.
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
@@ -222,10 +450,25 @@ 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
diff --git a/go.mod b/go.mod
index 8a61730..96381fd 100644
--- a/go.mod
+++ b/go.mod
@@ -1,5 +1,16 @@
module gsg
-go 1.22
+go 1.23.0
-require github.com/yuin/goldmark v1.8.5
+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
+)
+
+require (
+ github.com/dlclark/regexp2 v1.12.0 // indirect
+ golang.org/x/sys v0.13.0 // indirect
+)
diff --git a/go.sum b/go.sum
index 98fbe0d..f3f96d6 100644
--- a/go.sum
+++ b/go.sum
@@ -1,2 +1,22 @@
+github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
+github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
+github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM=
+github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI=
+github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
+github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
+github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
+github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
+github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
+github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
+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=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/gpgverif b/gpgverif
new file mode 100644
index 0000000..362bb5d
--- /dev/null
+++ b/gpgverif
@@ -0,0 +1,12 @@
+Config (per-repo, cgit-style, matching the existing pattern): git --git-dir=/srv/git/foo.git config gsg.gpgkeyring /path/to/keys.asc, where the file is one or more ASCII-armored public keys.
+
+How it works:
+- At startup, if a repo has gsg.gpgkeyring set, Repo.UseKeyring builds an isolated GNUPGHOME (under /tmp, not os.TempDir() — macOS's longer default temp path pushes gpg-agent's Unix socket past its ~104-byte limit and silently breaks) and imports only that file's keys into it.
+- On import failure, it logs a warning and disables verification for that repo rather than failing the whole server.
+- Repo.Log (used by the log module, n=20) now optionally asks git for %G? %GS %GK per commit — gated on both the caller wanting it and the repo actually having a keyring, so no wasted gpg forks. LogAll (the unbounded graph walk) deliberately never requests it — verifying every signed commit across full history would risk blowing the per-request timeout.
+- A commit is "verified" if %G? is G or U — a signature valid against an imported key gets U, not G, since a bare keyring has no ownertrust database. Trust here comes from the operator putting the key in the file, not GPG's web-of-trust.
+- Signed-but-unknown-key commits (%G? = E, the common case on a partial keyring) render as a neutral "unknown key" badge, not a red failure — confirmed this distinction empirically since it's easy to get backwards.
+
+UI: log.html gains a "Signature" column, shown only when the repo has verification configured. Green verified / neutral unknown key / red bad signature (and expired/revoked variants) / dash for unsigned.
+
+Tested end-to-end against real signed/unsigned/foreign-key commits across three repo configs (verified, plain, bad-keyring-path), including the misconfiguration path.
diff --git a/internal/core/core.go b/internal/core/core.go
index d76f058..04b1fc2 100644
--- a/internal/core/core.go
+++ b/internal/core/core.go
@@ -6,8 +6,11 @@ package core
import (
"fmt"
"sort"
+ "strconv"
"strings"
"sync"
+ "sync/atomic"
+ "time"
"gsg/internal/gitx"
)
@@ -19,9 +22,114 @@ type Site struct {
BaseURL string
Root string // directory containing bare repos
- // Repos is every discovered repository. Site-scoped routes (the
- // index page, the index endpoint) read it through the `repos` module.
- Repos []Handle
+ // RevFn, when set, returns a token that changes whenever site-level
+ // configuration changes without a commit moving -- operator notices,
+ // for example. Ctx.contentIdentity() folds it into both the module
+ // cache key and the response ETag, so a module reading that config
+ // cannot serve a result that predates the change. Set once at startup,
+ // before the server begins serving.
+ RevFn func() string
+
+ idx atomic.Pointer[repoIndex]
+ mu sync.Mutex // serializes upsert/remove; RepoList/Lookup never block
+
+ // configRev counts every upsert/remove, i.e. every time a repo's own
+ // git config or existence changes (watch.go's reconcile calls mutate
+ // for exactly this). Folded into Revision() so that ETags and the
+ // module cache invalidate when a repo's config changes -- gsg.hide,
+ // gsg.visible-to, gsg.share-revoked-after -- not just when notices do.
+ // Without this, a browser could revalidate a stale, cached page whose
+ // ETag predates a config change that was supposed to take effect
+ // immediately, such as a share-link revocation.
+ configRev atomic.Uint64
+}
+
+// repoIndex is an immutable snapshot, swapped in whole by upsert/remove so
+// readers (RepoList, Lookup) never need a lock.
+type repoIndex struct {
+ list []Handle // every known repo, hidden or not -- repos.go filters Hidden itself
+ bySlug map[string]Handle // every known repo, hidden or not -- Server.buildCtx enforces visibility
+}
+
+// RepoList is every discovered repository. Site-scoped routes (the index
+// page, the index endpoint) read it through the `repos` module.
+func (s *Site) RepoList() []Handle {
+ if idx := s.idx.Load(); idx != nil {
+ return idx.list
+ }
+ return nil
+}
+
+// Lookup resolves a repo by slug for per-repo routes, hidden or not -- the
+// caller (Server.buildCtx) is responsible for enforcing RepoInfo.Hidden /
+// CanView before using the result.
+func (s *Site) Lookup(slug string) (Handle, bool) {
+ idx := s.idx.Load()
+ if idx == nil {
+ return Handle{}, false
+ }
+ h, ok := idx.bySlug[slug]
+ return h, ok
+}
+
+// Revision is the site-level analogue of RepoInfo.GPGKeyring: a token for
+// inputs that change outside a commit -- notices, and any repo's own git
+// config (gsg.hide, gsg.visible-to, gsg.share-revoked-after, ...). Nil-safe
+// because tests construct bare *Ctx values and LoadTemplates parses with
+// &Ctx{}.
+func (s *Site) Revision() string {
+ if s == nil {
+ return ""
+ }
+ rev := strconv.FormatUint(s.configRev.Load(), 10)
+ if s.RevFn != nil {
+ rev += "|" + s.RevFn()
+ }
+ return rev
+}
+
+// upsert and remove are the only writers of the repo index, both called
+// exclusively from watch.go's reconcile -- at startup and at runtime alike,
+// through the same code path.
+func (s *Site) upsert(h Handle) {
+ s.mutate(func(list []Handle) []Handle {
+ for i, e := range list {
+ if e.Info.Slug == h.Info.Slug {
+ list[i] = h
+ return list
+ }
+ }
+ return append(list, h)
+ })
+}
+
+func (s *Site) remove(slug string) {
+ s.mutate(func(list []Handle) []Handle {
+ for i, e := range list {
+ if e.Info.Slug == slug {
+ return append(list[:i], list[i+1:]...)
+ }
+ }
+ return list
+ })
+}
+
+func (s *Site) mutate(f func([]Handle) []Handle) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ var cur []Handle
+ if idx := s.idx.Load(); idx != nil {
+ cur = append([]Handle(nil), idx.list...)
+ }
+ next := f(cur)
+
+ bySlug := make(map[string]Handle, len(next))
+ for _, h := range next {
+ bySlug[h.Info.Slug] = h
+ }
+ s.idx.Store(&repoIndex{list: next, bySlug: bySlug})
+ s.configRev.Add(1)
}
// Handle pairs an open repository with its configuration.
@@ -40,6 +148,9 @@ type Handle struct {
// clone = git://...
// hide = true
// 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 {
@@ -50,6 +161,40 @@ type RepoInfo struct {
Clone string
Section string
Hidden bool
+
+ // VisibleTo is the gsg.visible-to allowlist: usernames (matched against
+ // an authenticated session, case-insensitively) who may see this repo
+ // even though Hidden is set. A separate key from Owner deliberately --
+ // Owner is a free-text display field (and falls back to gitweb.owner
+ // for cgit-migrated repos), so reusing it as an ACL could silently
+ // grant access nobody intended.
+ VisibleTo []string
+
+ // ShareMaxAge is gsg.share-max-age: both the ceiling no share-link
+ // expiry for this repo may exceed, and the default when a mint request
+ // doesn't specify one. Falls back to defaultShareMaxAge when unset.
+ ShareMaxAge time.Duration
+
+ // ShareRevokedAfter is gsg.share-revoked-after: any share-link grant
+ // for this repo issued at or before this instant is rejected,
+ // regardless of the link's own expiry. It is the only revocation lever
+ // a stateless share token has -- see internal/share.
+ ShareRevokedAfter time.Time
+
+ // GPGKeyring is the configured path to a file of armored public keys
+ // (gsg.gpgkeyring), if any. Reading it here is just config; the actual
+ // import into an isolated keyring happens once at startup (see
+ // gitx.Repo.UseKeyring) and, on failure, this is cleared again so
+ // 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 {
@@ -62,14 +207,116 @@ func LoadRepoInfo(r *gitx.Repo) RepoInfo {
desc = r.DescriptionFile() // cgit-compatible fallback
}
return RepoInfo{
- Slug: r.Slug,
- Title: title,
- Description: desc,
- Owner: r.ConfigFirst("gsg.owner", "gitweb.owner"),
- Clone: r.ConfigFirst("gsg.clone", "gitweb.url"),
- Section: r.ConfigFirst("gsg.section", "gitweb.category"),
- Hidden: strings.EqualFold(r.Config("gsg.hide"), "true"),
+ Slug: r.Slug,
+ Title: title,
+ Description: desc,
+ Owner: r.ConfigFirst("gsg.owner", "gitweb.owner"),
+ Clone: r.ConfigFirst("gsg.clone", "gitweb.url"),
+ Section: r.ConfigFirst("gsg.section", "gitweb.category"),
+ Hidden: strings.EqualFold(r.Config("gsg.hide"), "true"),
+ VisibleTo: splitUsers(r.Config("gsg.visible-to")),
+ 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")),
+ }
+}
+
+// defaultShareMaxAge is the ceiling/default applied when a repo doesn't set
+// gsg.share-max-age.
+const defaultShareMaxAge = 30 * 24 * time.Hour
+
+func parseShareMaxAge(v string) time.Duration {
+ if v == "" {
+ return defaultShareMaxAge
}
+ d, err := time.ParseDuration(v)
+ if err != nil || d <= 0 {
+ return defaultShareMaxAge
+ }
+ 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{}
+ }
+ sec, err := strconv.ParseInt(v, 10, 64)
+ if err != nil {
+ return time.Time{}
+ }
+ return time.Unix(sec, 0)
+}
+
+// splitUsers parses a comma-separated gsg.visible-to value into trimmed,
+// non-empty usernames.
+func splitUsers(v string) []string {
+ if v == "" {
+ return nil
+ }
+ parts := strings.Split(v, ",")
+ out := make([]string, 0, len(parts))
+ for _, p := range parts {
+ if p = strings.TrimSpace(p); p != "" {
+ out = append(out, p)
+ }
+ }
+ return out
+}
+
+// CanView reports whether user (an authenticated username, or "" for
+// anonymous) may see this repo even if it's Hidden. Anonymous callers never
+// match -- VisibleTo entries are login usernames, and "" would otherwise
+// vacuously match a blank/misconfigured entry.
+func (info RepoInfo) CanView(user string) bool {
+ if user == "" {
+ return false
+ }
+ for _, u := range info.VisibleTo {
+ if strings.EqualFold(u, user) {
+ return true
+ }
+ }
+ return false
+}
+
+// SharedVisible reports whether a verified gsg_share grant -- the slug set
+// and issued-at time from its ShareClaims -- still unlocks this repo. It's
+// not enough for slugs to just name this repo: if gsg.share-revoked-after
+// has been bumped since the grant was issued, it's revoked, even though the
+// signature on the browser's cookie is still perfectly valid. This is
+// checked on every request (not just at redemption), which is what makes
+// bumping the timestamp reach a browser that already redeemed the link.
+func (info RepoInfo) SharedVisible(slugs []string, issuedAt time.Time) bool {
+ found := false
+ for _, s := range slugs {
+ if s == info.Slug {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return false
+ }
+ if !info.ShareRevokedAfter.IsZero() && !issuedAt.After(info.ShareRevokedAfter) {
+ return false
+ }
+ return true
}
// ----------------------------------------------------------------- context
@@ -85,15 +332,45 @@ type Ctx struct {
Path string
Vars map[string]string
+ // User is the authenticated username from the gsg_user session cookie,
+ // or "" for an anonymous request. Resolved once in Server.buildCtx.
+ // Never derived from a request header directly -- see internal/session.
+ User string
+
+ // SharedSlugs and SharedIssuedAt come from a verified gsg_share cookie,
+ // if one was present (SharedSlugs is nil otherwise). They are not yet
+ // filtered for revocation -- RepoInfo.SharedVisible applies
+ // gsg.share-revoked-after against SharedIssuedAt at the point of use,
+ // since revocation is per-repo and this Ctx may span several.
+ SharedSlugs []string
+ SharedIssuedAt time.Time
+
+ // Nav is the repo-scoped tab bar, built once in Server.serve from the
+ // route table (see nav.go). Empty for site-scoped routes.
+ Nav []NavItem
+
// Commit is Ref resolved to a full SHA. Everything a module computes
// at a given Commit is immutable, which is what makes the shared
// cache and ETags correct rather than merely convenient.
Commit string
+ // Immutable is true when this response is served with the year-long
+ // immutable cache policy (see Server.serve). Page chrome that can change
+ // without the commit moving must not be rendered into it, because the
+ // browser is told never to revalidate. Operator notices are the first
+ // such thing; this is deliberately the *computed* condition, not just
+ // Route.Immutable, so it follows whatever the caching rule becomes.
+ Immutable bool
+
// Store is the process-wide cache, nil in tests. Only consulted when
// 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
}
@@ -114,35 +391,52 @@ func NewCtx(site *Site, r *gitx.Repo, info RepoInfo, ref string) *Ctx {
Vars: map[string]string{}, cache: map[string]result{}}
}
-// CacheKey identifies an immutable module result. Empty when the result
-// cannot be cached (no resolved commit).
+// contentIdentity captures every input that can change what a route
+// renders at a fixed commit -- the commit itself, plus anything that can
+// change without the commit moving. Vars are part of it: /compare/a..b and
+// /compare/a...b resolve to the same head commit, so without the base ref
+// in here the second request was served the first one's diff. GPGKeyring
+// is part of it for the same reason: watch.go's reconcile can change a
+// repo's keyring under commits whose SHAs haven't moved, and a page
+// fetched before that would otherwise cache "unsigned" forever.
+// Site.Revision() is the site-level equivalent of the keyring: operator
+// notices change without a commit moving, so a page cached before a notice
+// edit would otherwise keep serving the old banners forever.
//
-// Vars are part of the key. They were not, originally, and the bug was
-// exactly the kind this cache is supposed to 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, not just the commit it hangs off.
-func (c *Ctx) CacheKey(name string, args []string) string {
- if c.Commit == "" || c.Store == nil {
- return ""
- }
- var b strings.Builder
- b.WriteString(c.Info.Slug + "|" + c.Commit + "|" + c.Path + "|" + name + "|")
- b.WriteString(strings.Join(args, "\x00"))
+// CacheKey and the response ETag (server.go) are both built from this, so
+// a new Var or a keyring change can't invalidate one without invalidating
+// the other -- that used to be two hand-maintained lists that had already
+// drifted (the ETag was missing GPGKeyring and any Var besides baseSHA).
+func (c *Ctx) contentIdentity() string {
keys := make([]string, 0, len(c.Vars))
for k := range c.Vars {
keys = append(keys, k)
}
sort.Strings(keys)
+ var b strings.Builder
+ b.WriteString(c.Commit + "|" + c.Info.GPGKeyring + "|" + c.Site.Revision())
for _, k := range keys {
b.WriteString("|" + k + "=" + c.Vars[k])
}
return b.String()
}
+// CacheKey identifies an immutable module result. Empty when the result
+// cannot be cached (no resolved commit).
+func (c *Ctx) CacheKey(name string, args []string) string {
+ if c.Commit == "" || c.Store == nil {
+ return ""
+ }
+ return c.Info.Slug + "|" + c.Path + "|" + name + "|" +
+ strings.Join(args, "\x00") + "|" + c.contentIdentity()
+}
+
// Derive returns a shallow copy for a different path, with a fresh cache.
func (c *Ctx) Derive(path string, vars map[string]string) *Ctx {
n := NewCtx(c.Site, c.Repo, c.Info, c.Ref)
+ n.User = c.User
+ n.SharedSlugs = c.SharedSlugs
+ n.SharedIssuedAt = c.SharedIssuedAt
n.Path = path
for k, v := range c.Vars {
n.Vars[k] = v
@@ -155,6 +449,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/core_test.go b/internal/core/core_test.go
new file mode 100644
index 0000000..9ad819a
--- /dev/null
+++ b/internal/core/core_test.go
@@ -0,0 +1,27 @@
+package core
+
+import (
+ "strings"
+ "testing"
+)
+
+// The site revision is the site-level analogue of RepoInfo.GPGKeyring: it
+// must be part of contentIdentity or a notice change cannot invalidate the
+// module cache or the response ETag.
+func TestContentIdentityIncludesSiteRevision(t *testing.T) {
+ a := NewCtx(&Site{RevFn: func() string { return "rev-a" }}, nil, RepoInfo{}, "main")
+ b := NewCtx(&Site{RevFn: func() string { return "rev-b" }}, nil, RepoInfo{}, "main")
+ a.Commit, b.Commit = "sha", "sha"
+ if a.contentIdentity() == b.contentIdentity() {
+ t.Fatal("site revision is not part of contentIdentity")
+ }
+}
+
+// Tests construct bare *Ctx values (see blob_test.go) and LoadTemplates
+// parses with &Ctx{}; contentIdentity must tolerate a nil Site.
+func TestContentIdentityNilSite(t *testing.T) {
+ got := (&Ctx{}).contentIdentity()
+ if !strings.HasPrefix(got, "|") {
+ t.Fatalf("contentIdentity() = %q", got)
+ }
+}
diff --git a/internal/core/errorpage.go b/internal/core/errorpage.go
new file mode 100644
index 0000000..1e47baa
--- /dev/null
+++ b/internal/core/errorpage.go
@@ -0,0 +1,65 @@
+package core
+
+import (
+ "bytes"
+ "fmt"
+ "html/template"
+ "net/http"
+)
+
+// errorTmpl is compiled from a literal, not from the theme's template
+// files -- a broken view template is exactly the situation this page has
+// to survive, so it cannot depend on LoadTemplates succeeding.
+var errorTmpl = template.Must(template.New("error").Parse(`<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{.Code}} {{.Status}} &middot; {{.Site}}</title>
+<link rel="stylesheet" href="/static/css/base.css">
+</head>
+<body>
+<main class="wrap error-page">
+ <p class="error-code">{{.Code}}</p>
+ <h1 class="page-title">{{.Status}}</h1>
+ <p class="page-subtitle">{{.Message}}</p>
+ <p><a href="/">&larr; back to {{.Site}}</a></p>
+</main>
+</body>
+</html>
+`))
+
+// renderErrorPage never returns an error: if the template somehow fails to
+// execute, it falls back to a plain-text body rather than propagating.
+func renderErrorPage(code int, site string) (body []byte, contentType string) {
+ data := struct {
+ Code int
+ Status, Message, Site string
+ }{
+ Code: code,
+ Status: http.StatusText(code),
+ Message: friendlyMessage(code),
+ Site: site,
+ }
+ var buf bytes.Buffer
+ if err := errorTmpl.Execute(&buf, data); err != nil {
+ return []byte(fmt.Sprintf("%d %s\n", code, http.StatusText(code))), "text/plain; charset=utf-8"
+ }
+ return buf.Bytes(), "text/html; charset=utf-8"
+}
+
+// friendlyMessage keeps the diagnostic detail out of the response body --
+// that stays in the server log -- while still telling a human what class
+// of problem they hit.
+func friendlyMessage(code int) string {
+ switch code {
+ case http.StatusNotFound:
+ return "We couldn't find that repository, ref, or path."
+ case http.StatusBadRequest:
+ return "That request doesn't look right."
+ case http.StatusRequestEntityTooLarge:
+ return "That response is too large to display in full."
+ default:
+ return "Something went wrong rendering this page. The details were logged."
+ }
+}
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/nav.go b/internal/core/nav.go
new file mode 100644
index 0000000..8f6692c
--- /dev/null
+++ b/internal/core/nav.go
@@ -0,0 +1,70 @@
+package core
+
+import "strings"
+
+// NavItem is one entry in the repo-scoped navigation -- the top tab bar
+// and the page-nav chips both render the same slice, built from the route
+// table so a new tab is a one-line addition to site/routes.go, not a
+// second place to remember.
+type NavItem struct {
+ Label string
+ Href string
+ Active bool
+}
+
+// BuildNav derives the repo-scoped nav from every route whose NavLabel is
+// set, in route-table order. current is the name of the route being
+// served, used to mark the matching tab active.
+func BuildNav(routes []Route, current, slug, ref string) []NavItem {
+ if slug == "" {
+ return nil
+ }
+ var items []NavItem
+ for _, rt := range routes {
+ if rt.Scope != ScopeRepo || rt.NavLabel == "" {
+ continue
+ }
+ items = append(items, NavItem{
+ Label: rt.NavLabel,
+ Href: navHref(rt, slug, ref),
+ // The "repo" route (bare /{repo}/) renders tree.html at its
+ // root, same as "tree" does at path "" -- so the files tab
+ // lights up on both, even though "repo" carries no NavLabel
+ // of its own and never appears in this loop as a tab.
+ Active: rt.Name == current || (rt.Name == "tree" && current == "repo"),
+ })
+ }
+ return items
+}
+
+// navHref builds the href for a nav tab from its route pattern: the fixed
+// segment right after {repo}, plus the ref if the route is ref-addressed
+// ({rest...} or {rev}). Routes addressed only by {$} (graph, refs) link to
+// their bare index instead.
+func navHref(rt Route, slug, ref string) string {
+ seg := navSegment(rt.Pattern)
+ href := "/" + slug + "/"
+ if seg != "" {
+ href += seg + "/"
+ }
+ if strings.Contains(rt.Pattern, "{rest...}") || strings.Contains(rt.Pattern, "{rev}") {
+ href += ref
+ }
+ return href
+}
+
+// navSegment extracts the literal path element between {repo} and the
+// next wildcard, e.g. "GET /{repo}/tree/{rest...}" -> "tree". Returns ""
+// for patterns with no such segment (e.g. "GET /{repo}/{$}").
+func navSegment(pattern string) string {
+ p := pattern
+ if i := strings.IndexByte(p, ' '); i >= 0 {
+ p = p[i+1:]
+ }
+ p = strings.TrimPrefix(p, "/{repo}/")
+ seg, _, _ := strings.Cut(p, "/")
+ if strings.HasPrefix(seg, "{") {
+ return ""
+ }
+ return seg
+}
diff --git a/internal/core/route.go b/internal/core/route.go
index cbaa382..25ca889 100644
--- a/internal/core/route.go
+++ b/internal/core/route.go
@@ -80,6 +80,20 @@ 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.
+ NavLabel string
}
type Scope int
@@ -89,6 +103,15 @@ const (
ScopeSite // no repository in the URL
)
+// Page sizes for routes that paginate via ?ofs=. Kept in core (not
+// modules) so buildCtx can quantize an incoming ofs to a real page
+// boundary without an import cycle. Must match the literal args the
+// log.html/graph.html templates pass to `mod "log"`/`mod "graph"`.
+const (
+ LogPageSize = 20
+ GraphPageSize = 150
+)
+
// httpError lets a module or endpoint choose the status code.
type httpError struct {
Code int
@@ -123,8 +146,11 @@ type HeaderSetter interface {
// XSS against the whole site -- which is exactly why GitHub puts raw content
// on raw.githubusercontent.com, a separate origin. Serving it from the same
// host is only safe if the response cannot be interpreted as a document:
-// text/plain or octet-stream, nosniff so the browser does not second-guess
-// that, and a sandbox CSP as a backstop.
+// text/plain or octet-stream for everything the module did not validate,
+// nosniff so the browser does not second-guess that, and a sandbox CSP as a
+// backstop. The one exception is InlineContentTyper: a module that has
+// checked the bytes may name a raster image type, which cannot execute and
+// is still covered by nosniff.
type Raw struct{ Module string }
func (Raw) ContentType() string { return "text/plain; charset=utf-8" }
@@ -150,8 +176,27 @@ func (r Raw) Headers(c *Ctx, h http.Header) {
if err != nil {
return
}
+ // A module may declare a validated inline MIME (raster images). It is
+ // checked before IsBinary so an image is served as image/*, not
+ // octet-stream; only modules that vetted the bytes may return one, and
+ // the nosniff above keeps the declared type from being second-guessed.
+ if ct, ok := v.(InlineContentTyper); ok {
+ if s := ct.InlineContentType(); s != "" {
+ h.Set("Content-Type", s)
+ return
+ }
+ }
if bin, ok := v.(interface{ IsBinary() bool }); ok && bin.IsBinary() {
h.Set("Content-Type", "application/octet-stream")
h.Set("Content-Disposition", "attachment")
}
}
+
+// InlineContentTyper lets a raw-serving module declare a MIME type that is
+// safe to render inline on this origin. It exists so core stays ignorant of
+// formats: the module that knows how to validate image bytes is the module
+// that names the type. An empty return keeps the text/plain or
+// octet-stream-attachment default.
+type InlineContentTyper interface {
+ InlineContentType() string
+}
diff --git a/internal/core/server.go b/internal/core/server.go
index 93ce421..352bea7 100644
--- a/internal/core/server.go
+++ b/internal/core/server.go
@@ -1,6 +1,7 @@
package core
import (
+ "context"
"crypto/sha256"
"encoding/hex"
"errors"
@@ -8,11 +9,15 @@ import (
"io/fs"
"log/slog"
"net/http"
+ "strconv"
"strings"
"sync"
"time"
+ "golang.org/x/sync/singleflight"
+
"gsg/internal/gitx"
+ "gsg/internal/session"
)
type Server struct {
@@ -22,31 +27,47 @@ type Server struct {
Timeout time.Duration // per-request git budget
MaxGit int64 // per-command output cap, bytes
+ // Sessions verifies the gsg_user login cookie. Nil-safe: an unset
+ // Sessions makes every request resolve as anonymous, i.e. today's
+ // behavior.
+ 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
- repos map[string]Handle
+
+ // 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: 8 << 20,
- cache: NewLRU(cacheSize),
- repos: map[string]Handle{},
- }
- for _, h := range site.Repos {
- if !h.Info.Hidden {
- s.repos[h.Info.Slug] = h
- }
+ 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
@@ -60,7 +81,7 @@ func (s *Server) loadTemplates() error {
if err != nil {
return err
}
- tag, err := hashFS(fsys)
+ tag, err := HashFS(fsys)
if err != nil {
return err
}
@@ -70,9 +91,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() {
@@ -99,6 +124,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 {
@@ -107,37 +141,61 @@ 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
}
-
- // 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()
-
- // Vars carry the second endpoint of a compare, so a moving base ref
- // still changes the ETag.
- etag := `"` + shortHash(rt.Name, r.URL.Path, r.URL.RawQuery,
- c.Commit, c.Vars["baseSHA"], tag) + `"`
- w.Header().Set("ETag", etag)
- if rt.Immutable && c.Commit != "" {
- w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
- } else {
- w.Header().Set("Cache-Control", "no-cache")
- }
- if match := r.Header.Get("If-None-Match"); match != "" && strings.Contains(match, etag) {
+ 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)
@@ -150,18 +208,205 @@ func (s *Server) serve(rt Route, w http.ResponseWriter, r *http.Request) {
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)
+
+ s.mu.RLock()
+ tag := s.tmplTag
+ s.mu.RUnlock()
+
+ // contentIdentity carries the commit, keyring, and every Var (e.g.
+ // compare's base ref) -- the same fragment module results are keyed
+ // on, so this can't drift out of sync with what CacheKey covers.
+ //
+ // c.User and the raw gsg_share cookie value are folded in here,
+ // deliberately not into contentIdentity itself: contentIdentity also
+ // drives Ctx.CacheKey, the cross-request module Store key, and module
+ // results (tree/blob/log/...) aren't user-dependent -- authorization
+ // happens in buildCtx's Lookup gate, not inside a module. Keying the
+ // shared LRU per grant would fragment it for no benefit. The ETag,
+ // unlike the module cache, must vary by identity: without c.User,
+ // logging in or out keeps the same ETag as the anonymous response and a
+ // browser's stale If-None-Match earns a 304 with the wrong body. The
+ // share cookie needs the same treatment for the same reason: two
+ // anonymous visitors holding different share grants both have
+ // c.User == "", so without it they'd collide on one ETag and one
+ // 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, s.StaticTag, c.contentIdentity(), c.User, shareCookie) + `"`
+
+ header := http.Header{}
+ header.Set("ETag", etag)
+ if c.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.
+ header.Add("Vary", "Cookie")
+ if c.User != "" || len(c.SharedSlugs) > 0 {
+ // 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 {
+ // 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())))
+ }
+ }
+ return c, header, nil
+}
+
+func copyHeader(dst, src http.Header) {
+ for k, vs := range src {
+ for _, v := range vs {
+ dst.Add(k, v)
+ }
+ }
+}
+
+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.Write(cr.body)
+}
+
func (s *Server) buildCtx(ctx contexter, rt Route, r *http.Request) (*Ctx, error) {
+ // Resolved once, before the scope branch, so the site-scoped early
+ // return (the index page -- the main surface a login or share link is
+ // meant to affect) doesn't skip it.
+ user := s.Sessions.Verify(r)
+ var sharedSlugs []string
+ var sharedIssuedAt time.Time
+ if claims, ok := s.Sessions.VerifyShareCookie(r); ok {
+ sharedSlugs, sharedIssuedAt = claims.Slugs, claims.IssuedAt()
+ }
+
if rt.Scope == ScopeSite {
c := NewCtx(s.Site, nil, RepoInfo{}, "")
c.Store = s.cache
+ c.User = user
+ c.SharedSlugs, c.SharedIssuedAt = sharedSlugs, sharedIssuedAt
+ c.StaticTag = s.StaticTag
return c, nil
}
slug := r.PathValue("repo")
- h, ok := s.repos[slug]
+ h, ok := s.Site.Lookup(slug)
if !ok {
return nil, Errorf(http.StatusNotFound, "no such repository %q", slug)
}
+ // Hidden repos are 404, indistinguishable from nonexistent, unless the
+ // resolved identity is explicitly allowed to see this one, either as an
+ // owner (gsg.visible-to) or via an unrevoked share grant.
+ if h.Info.Hidden && !h.Info.CanView(user) && !h.Info.SharedVisible(sharedSlugs, sharedIssuedAt) {
+ return nil, Errorf(http.StatusNotFound, "no such repository %q", slug)
+ }
repo := h.Repo.WithContext(ctx.Ctx(), s.MaxGit)
// {rest...} carries ref and path fused together; {rev} is a bare ref;
@@ -193,6 +438,9 @@ func (s *Server) buildCtx(ctx contexter, rt Route, r *http.Request) (*Ctx, error
c := NewCtx(s.Site, repo, h.Info, ref)
c.Path = path
c.Store = s.cache
+ c.User = user
+ c.SharedSlugs, c.SharedIssuedAt = sharedSlugs, sharedIssuedAt
+ c.StaticTag = s.StaticTag
if !repo.Empty() {
sha, err := repo.Resolve(ref)
@@ -206,10 +454,39 @@ func (s *Server) buildCtx(ctx contexter, rt Route, r *http.Request) (*Ctx, error
if err != nil {
return nil, Errorf(http.StatusNotFound, "%v", err)
}
+ headSHA := c.Commit
+ // Two-dot compares are undirected as far as the caller is concerned:
+ // whichever ref is the ancestor should read as "base", regardless of
+ // which side of ".." it was typed on. Three-dot is left alone --
+ // its merge-base semantics are direction-sensitive by design.
+ if dots == "2" && baseSHA != headSHA {
+ if anc, err := repo.IsAncestor(headSHA, baseSHA); err == nil && anc {
+ base, ref = ref, base
+ baseSHA, headSHA = headSHA, baseSHA
+ }
+ }
+ c.Ref, c.Commit = ref, headSHA
c.Vars["base"], c.Vars["head"] = base, ref
- c.Vars["baseSHA"], c.Vars["headSHA"] = baseSHA, c.Commit
+ c.Vars["baseSHA"], c.Vars["headSHA"] = baseSHA, headSHA
c.Vars["dots"] = dots
}
+ if rt.Name == "log" || rt.Name == "graph" {
+ pageSize := LogPageSize
+ if rt.Name == "graph" {
+ pageSize = GraphPageSize
+ }
+ if v := r.URL.Query().Get("ofs"); v != "" {
+ if ofs, err := strconv.Atoi(v); err == nil && ofs > 0 {
+ ofs -= ofs % pageSize // snap to a page boundary before it can become a cache key
+ c.Vars["ofs"] = strconv.Itoa(ofs)
+ }
+ }
+ }
+ // One source of truth for the immutable policy: the cache header in
+ // 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
}
@@ -223,7 +500,22 @@ func (s *Server) fail(w http.ResponseWriter, r *http.Request, rt Route, err erro
} else {
s.Log.Info("request rejected", "route", rt.Name, "path", r.URL.Path, "status", code, "err", err)
}
- http.Error(w, http.StatusText(code), code)
+
+ // 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")
+
+ body, cType := renderErrorPage(code, s.Site.Name)
+ w.Header().Set("Content-Type", cType)
+ w.WriteHeader(code)
+ w.Write(body)
}
func shortHash(parts ...string) string {
diff --git a/internal/core/watch.go b/internal/core/watch.go
new file mode 100644
index 0000000..48e4beb
--- /dev/null
+++ b/internal/core/watch.go
@@ -0,0 +1,196 @@
+package core
+
+import (
+ "fmt"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+
+ "github.com/fsnotify/fsnotify"
+
+ "gsg/internal/gitx"
+)
+
+// discoverConcurrency bounds how many repositories are opened at once during
+// the initial scan. Each one is a handful of git forks plus, for
+// keyring-bearing repos, a gpg import -- unbounded parallelism would fork
+// thousands of processes at once on a large install.
+const discoverConcurrency = 16
+
+// watcher owns every repo directly under root: which ones are currently
+// open, and the fsnotify watch that tells it when to look again. reconcile
+// is the single entry point for loading, reloading, or dropping a repo --
+// used identically by the initial scan and by every later filesystem event.
+type watcher struct {
+ site *Site
+ root string
+ fsw *fsnotify.Watcher
+
+ mu sync.Mutex
+ repos map[string]*gitx.Repo // open repos, by directory name -- reused across reconciles
+ dirName map[string]string // git-dir path -> directory name, for event dispatch
+
+ debMu sync.Mutex
+ debounce map[string]*time.Timer
+}
+
+// Start discovers every repository under site.Root, then watches root and
+// each repo's git-dir for changes for the life of the process. It returns
+// once the initial scan completes, the same as the old discover() did.
+func Start(site *Site) error {
+ fsw, err := fsnotify.NewWatcher()
+ if err != nil {
+ return fmt.Errorf("config watch: %w", err)
+ }
+ if err := fsw.Add(site.Root); err != nil {
+ fsw.Close()
+ return fmt.Errorf("config watch: %w", err)
+ }
+
+ w := &watcher{
+ site: site,
+ root: site.Root,
+ fsw: fsw,
+ repos: map[string]*gitx.Repo{},
+ dirName: map[string]string{},
+ debounce: map[string]*time.Timer{},
+ }
+
+ entries, err := os.ReadDir(site.Root)
+ if err != nil {
+ fsw.Close()
+ return err
+ }
+
+ sem := make(chan struct{}, discoverConcurrency)
+ var wg sync.WaitGroup
+ for _, e := range entries {
+ if !e.IsDir() {
+ continue
+ }
+ wg.Add(1)
+ go func(name string) {
+ defer wg.Done()
+ sem <- struct{}{}
+ defer func() { <-sem }()
+ w.reconcile(name)
+ }(e.Name())
+ }
+ wg.Wait()
+
+ if len(site.RepoList()) == 0 {
+ fsw.Close()
+ return fmt.Errorf("no repositories found under %s", site.Root)
+ }
+
+ go w.run()
+ return nil
+}
+
+// reconcile loads, reloads, or drops exactly one repo, named by its
+// directory under root. It is existence-driven rather than event-driven --
+// it doesn't ask what kind of filesystem event caused this call, only
+// whether root/name currently exists and is a valid repo -- which is what
+// lets the same function serve both the initial concurrent scan and the
+// one-at-a-time runtime event loop with no special-casing between them.
+func (w *watcher) reconcile(name string) {
+ dir := filepath.Join(w.root, name)
+
+ w.mu.Lock()
+ r, known := w.repos[name]
+ w.mu.Unlock()
+
+ if _, err := os.Stat(dir); err != nil {
+ if known {
+ w.mu.Lock()
+ delete(w.repos, name)
+ w.fsw.Remove(r.Dir)
+ delete(w.dirName, r.Dir)
+ w.mu.Unlock()
+ w.site.remove(r.Slug)
+ }
+ return
+ }
+
+ if !known {
+ var err error
+ if r, err = gitx.Open(dir); err != nil {
+ return // not a repo (or a bare clone still being populated)
+ }
+ w.mu.Lock()
+ w.repos[name] = r
+ w.fsw.Add(r.Dir) // catches this repo's own config/description writes from here on
+ w.dirName[r.Dir] = name
+ w.mu.Unlock()
+ }
+
+ info := LoadRepoInfo(r)
+
+ // Converge the keyring to whatever's configured now, on this call just
+ // like any other -- no "only import the first time" special case.
+ if info.GPGKeyring != r.KeyringPath() {
+ if info.GPGKeyring == "" {
+ r.ClearKeyring()
+ } else if err := r.UseKeyring(info.GPGKeyring); err != nil {
+ slog.Warn("gpg keyring: verification disabled", "repo", info.Slug, "err", err)
+ info.GPGKeyring = ""
+ }
+ }
+
+ w.site.upsert(Handle{Repo: r, Info: info})
+}
+
+// run consumes filesystem events for the life of the process. A repo dir
+// appearing, disappearing, or being renamed under root is dispatched by its
+// (new) name; a write to a known repo's config or description file is
+// dispatched by that repo's name.
+func (w *watcher) run() {
+ for {
+ select {
+ case ev, ok := <-w.fsw.Events:
+ if !ok {
+ return
+ }
+ if filepath.Dir(ev.Name) == w.root {
+ w.scheduleReconcile(filepath.Base(ev.Name))
+ continue
+ }
+ if base := filepath.Base(ev.Name); base == "config" || base == "description" {
+ w.mu.Lock()
+ name := w.dirName[filepath.Dir(ev.Name)]
+ w.mu.Unlock()
+ if name != "" {
+ w.scheduleReconcile(name)
+ }
+ }
+ case err, ok := <-w.fsw.Errors:
+ if !ok {
+ return
+ }
+ slog.Warn("config watch error", "err", err)
+ }
+ }
+}
+
+// scheduleReconcile coalesces bursts of events for the same repo -- a single
+// `git config` write is a lockfile create plus a rename, i.e. at least two
+// raw events for one logical change -- into a single reconcile call.
+func (w *watcher) scheduleReconcile(name string) {
+ const debounceDelay = 200 * time.Millisecond
+
+ w.debMu.Lock()
+ defer w.debMu.Unlock()
+
+ if t, ok := w.debounce[name]; ok {
+ t.Reset(debounceDelay)
+ return
+ }
+ w.debounce[name] = time.AfterFunc(debounceDelay, func() {
+ w.debMu.Lock()
+ delete(w.debounce, name)
+ w.debMu.Unlock()
+ w.reconcile(name)
+ })
+}
diff --git a/internal/gitx/git.go b/internal/gitx/git.go
index 85e491f..48512b5 100644
--- a/internal/gitx/git.go
+++ b/internal/gitx/git.go
@@ -6,6 +6,7 @@ package gitx
import (
"bytes"
"context"
+ "errors"
"fmt"
"os"
"os/exec"
@@ -13,6 +14,7 @@ import (
"regexp"
"strconv"
"strings"
+ "sync"
"time"
)
@@ -35,6 +37,34 @@ type Repo struct {
ctx context.Context
maxBytes int64
+
+ // gpgHome, once set by UseKeyring, is an isolated GNUPGHOME containing
+ // only the keys the operator imported for this repo. Its presence is
+ // also the signal to Log/LogAll to ask git for signature status at all.
+ gpgHome string
+
+ // keyringPath is the gsg.gpgkeyring value that produced gpgHome, so a
+ // caller can tell whether the configured keyring has changed without
+ // tracking that separately. Empty when no keyring is imported.
+ keyringPath string
+
+ // memo caches HeadRef/Refs for the lifetime of one request. It is a
+ // pointer, allocated fresh by WithContext, rather than a plain field:
+ // Repo is copied by value (see WithContext), so a mutex or map field
+ // here directly would either trip go vet's copylocks check or -- worse
+ // -- share its map header with the copy, leaking one request's cache
+ // into the long-lived Repo every Handle keeps between requests. nil on
+ // that long-lived Repo and in any Repo not built via WithContext (e.g.
+ // tests): HeadRef/Refs just skip the cache in that case.
+ memo *repoMemo
+}
+
+type repoMemo struct {
+ mu sync.Mutex
+ headRef *string
+ haveRefs bool
+ refs []Ref
+ refsErr error
}
// WithContext returns a view of the repo bound to a request context and an
@@ -45,6 +75,7 @@ func (r *Repo) WithContext(ctx context.Context, maxBytes int64) *Repo {
c := *r
c.ctx = ctx
c.maxBytes = maxBytes
+ c.memo = &repoMemo{}
return &c
}
@@ -78,10 +109,20 @@ func (r *Repo) run(args ...string) ([]byte, error) {
if ctx == nil {
ctx = context.Background()
}
- full := append([]string{"--git-dir", r.Dir}, args...)
+ // safe.directory=* bypasses git's dubious-ownership check (CVE-2022-24765).
+ // gsg runs against operator-chosen repo roots, commonly bind-mounted into
+ // a container where the mount ends up owned by a different uid than the
+ // process -- a trust boundary git can't see, since HOME=/nonexistent
+ // below means there's no config file left to set this in from outside.
+ full := append([]string{"--git-dir", r.Dir, "-c", "safe.directory=*"}, args...)
cmd := exec.CommandContext(ctx, "git", full...)
// Refuse to inherit anything that alters git's behaviour.
cmd.Env = []string{"GIT_CONFIG_NOSYSTEM=1", "HOME=/nonexistent", "GIT_TERMINAL_PROMPT=0", "PATH=" + os.Getenv("PATH")}
+ if r.gpgHome != "" {
+ // git shells out to gpg to check %G?; gpg picks this up from its
+ // own environment, which it inherits from git's.
+ cmd.Env = append(cmd.Env, "GNUPGHOME="+r.gpgHome)
+ }
var out, errb bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &errb
@@ -122,6 +163,62 @@ func (r *Repo) ConfigFirst(keys ...string) string {
return ""
}
+// -------------------------------------------------------------------- gpg
+
+// UseKeyring points every subsequent git invocation on this Repo (via
+// GNUPGHOME, see run) at an isolated homedir containing only the keys
+// imported from path. Without this, %G? signature checks would fall back
+// to the host's default keyring -- whatever happens to be trusted there,
+// not what this repo's operator vetted.
+//
+// The homedir is created directly under /tmp rather than os.TempDir():
+// gpg-agent's control socket is a Unix domain socket, capped at ~104
+// bytes, and a long TMPDIR (macOS's is typically 50+ characters) pushes
+// the full path over that limit and breaks key generation/import in a way
+// that just prints "no agent running" with no useful exit code to catch.
+func (r *Repo) UseKeyring(path string) error {
+ dir, err := os.MkdirTemp("/tmp", "gsg-gpg-")
+ if err != nil {
+ return fmt.Errorf("gpg keyring: %w", err)
+ }
+ if err := os.Chmod(dir, 0700); err != nil {
+ return fmt.Errorf("gpg keyring: %w", err)
+ }
+ cmd := exec.Command("gpg", "--batch", "--quiet", "--import", path)
+ cmd.Env = []string{"GNUPGHOME=" + dir, "HOME=/nonexistent", "PATH=" + os.Getenv("PATH")}
+ var errb bytes.Buffer
+ cmd.Stderr = &errb
+ if err := cmd.Run(); err != nil {
+ os.RemoveAll(dir)
+ return fmt.Errorf("gpg keyring: import %s: %w: %s", path, err, strings.TrimSpace(errb.String()))
+ }
+
+ // Only swap the new homedir in -- and clean up the old one -- once the
+ // import has actually succeeded, so a bad reconfigure logs a warning and
+ // leaves the previously-working keyring active rather than breaking it.
+ old := r.gpgHome
+ r.gpgHome, r.keyringPath = dir, path
+ if old != "" {
+ os.RemoveAll(old)
+ }
+ return nil
+}
+
+// KeyringPath is the gsg.gpgkeyring value currently imported, or "" if no
+// keyring is active. Callers use this to detect when the configured keyring
+// has changed, on any call -- the first or the hundredth -- without needing
+// to track that separately.
+func (r *Repo) KeyringPath() string { return r.keyringPath }
+
+// ClearKeyring reverts to unverified signatures, removing any imported
+// keyring's isolated homedir.
+func (r *Repo) ClearKeyring() {
+ if r.gpgHome != "" {
+ os.RemoveAll(r.gpgHome)
+ }
+ r.gpgHome, r.keyringPath = "", ""
+}
+
const placeholderDesc = "Unnamed repository; edit this file 'description' to name the repository."
// DescriptionFile reads the legacy `description` file, ignoring git's placeholder.
@@ -148,6 +245,27 @@ type Ref struct {
}
func (r *Repo) Refs() ([]Ref, error) {
+ if r.memo != nil {
+ r.memo.mu.Lock()
+ if r.memo.haveRefs {
+ refs, err := r.memo.refs, r.memo.refsErr
+ r.memo.mu.Unlock()
+ return refs, err
+ }
+ r.memo.mu.Unlock()
+ }
+
+ refs, err := r.refs()
+
+ if r.memo != nil {
+ r.memo.mu.Lock()
+ r.memo.refs, r.memo.refsErr, r.memo.haveRefs = refs, err, true
+ r.memo.mu.Unlock()
+ }
+ return refs, err
+}
+
+func (r *Repo) refs() ([]Ref, error) {
const f = "%(refname:short)" + escF + "%(objectname)" + escF +
"%(creatordate:iso-strict)" + escF + "%(subject)" + escF + "%(refname)"
out, err := r.run("for-each-ref", "--format="+f, "refs/heads", "refs/tags")
@@ -190,12 +308,9 @@ func (r *Repo) Resolve(ref string) (string, error) {
return "", fmt.Errorf("invalid ref %q", ref)
}
if shaRE.MatchString(ref) {
- if _, err := r.run("cat-file", "-e", ref+"^{commit}"); err != nil {
- return "", fmt.Errorf("unknown commit %q", ref)
- }
- out, err := r.run("rev-parse", "--verify", ref+"^{commit}")
+ out, err := r.run("rev-parse", "--verify", "--quiet", ref+"^{commit}")
if err != nil {
- return "", err
+ return "", fmt.Errorf("unknown commit %q", ref)
}
return strings.TrimSpace(string(out)), nil
}
@@ -244,6 +359,26 @@ func (r *Repo) SplitRefPath(rest string) (ref, path string) {
// HeadRef resolves the repo's default branch, rather than guessing main/master.
func (r *Repo) HeadRef() string {
+ if r.memo != nil {
+ r.memo.mu.Lock()
+ cached := r.memo.headRef
+ r.memo.mu.Unlock()
+ if cached != nil {
+ return *cached
+ }
+ }
+
+ head := r.headRef()
+
+ if r.memo != nil {
+ r.memo.mu.Lock()
+ r.memo.headRef = &head
+ r.memo.mu.Unlock()
+ }
+ return head
+}
+
+func (r *Repo) headRef() string {
out, err := r.run("symbolic-ref", "--short", "HEAD")
if err != nil {
return "HEAD"
@@ -262,41 +397,109 @@ type Commit struct {
Subject string
Body string
Parents []string
+
+ // Signature is only populated when the caller asked for it (Log, when
+ // the repo has a keyring -- see Repo.UseKeyring). SigStatus is git's
+ // raw %G? code: G/U good, B bad, X/Y expired, R revoked, E unchecked,
+ // N unsigned or "" when signature checks weren't requested at all.
+ SigStatus string
+ Signer string
+ KeyID string
+}
+
+// Verified reports a good signature from a key in the repo's configured
+// keyring. A bare gpg homedir has no ownertrust database, so a genuinely
+// valid signature from a freshly-imported key comes back "U" (valid, trust
+// unknown), not "G" -- trust here comes from the key being in the
+// operator's keyring file, not from GPG's own web-of-trust bookkeeping.
+func (c Commit) Verified() bool { return c.SigStatus == "G" || c.SigStatus == "U" }
+
+// Signed reports whether the commit carries a signature at all, good or
+// not -- lets a template tell "bad signature" apart from "no signature".
+func (c Commit) Signed() bool { return c.SigStatus != "" && c.SigStatus != "N" }
+
+// Unchecked reports a signature git could not evaluate against the
+// keyring at all -- almost always because the signing key just isn't in
+// it. That is not evidence of tampering the way B/X/Y/R are, so it should
+// read as "we don't know", not as a failure.
+func (c Commit) Unchecked() bool { return c.SigStatus == "E" }
+
+// SigLabel is a short human-readable rendering of SigStatus, for the log
+// view's badge.
+func (c Commit) SigLabel() string {
+ switch c.SigStatus {
+ case "G", "U":
+ return "verified"
+ case "B":
+ return "bad signature"
+ case "X":
+ return "expired signature"
+ case "Y":
+ return "expired key"
+ case "R":
+ return "revoked key"
+ case "E":
+ return "signature not checked"
+ default:
+ return "unsigned"
+ }
}
// Log walks history from rev in ONE git process. n <= 0 means unbounded.
// Never call this once per commit; fork overhead dominates at scale.
-func (r *Repo) Log(rev string, n int) ([]Commit, error) {
+// When verify is true, each commit also carries its signature status --
+// only pass true for small, bounded logs: git forks gpg once per signed
+// commit to check it.
+func (r *Repo) Log(rev string, n, skip int, verify bool) ([]Commit, error) {
if r.empty {
return nil, nil
}
- format := strings.Join([]string{"%H", "%an", "%ae", "%aI", "%P", "%s", "%b"}, escLogF)
+ verify = verify && r.gpgHome != "" // no keyring configured: nothing to check against
+ fields := []string{"%H", "%an", "%ae", "%aI", "%P", "%s"}
+ if verify {
+ fields = append(fields, "%G?", "%GS", "%GK")
+ }
+ fields = append(fields, "%b")
+ format := strings.Join(fields, escLogF)
args := []string{"log", "--format=" + escLogR + format}
if n > 0 {
args = append(args, "-n", strconv.Itoa(n))
}
+ if skip > 0 {
+ args = append(args, "--skip", strconv.Itoa(skip))
+ }
args = append(args, rev)
- return r.parseLog(r.run(args...))
+ out, err := r.run(args...)
+ return r.parseLog(out, err, verify)
}
-func (r *Repo) parseLog(out []byte, err error) ([]Commit, error) {
+func (r *Repo) parseLog(out []byte, err error, verify bool) ([]Commit, error) {
if err != nil {
return nil, err
}
+ nfields := 7
+ if verify {
+ nfields = 10
+ }
var commits []Commit
for _, rec := range strings.Split(string(out), sepR) {
rec = strings.TrimRight(rec, "\n")
if rec == "" {
continue
}
- p := strings.SplitN(rec, sepF, 7)
- if len(p) < 7 {
+ p := strings.SplitN(rec, sepF, nfields)
+ if len(p) < nfields {
continue
}
t, _ := time.Parse(time.RFC3339, p[3])
c := Commit{
SHA: p[0], Author: p[1], Email: p[2], When: t,
- Subject: p[5], Body: strings.TrimSpace(p[6]),
+ Subject: p[5],
+ }
+ if verify {
+ c.SigStatus, c.Signer, c.KeyID, c.Body = p[6], p[7], p[8], strings.TrimSpace(p[9])
+ } else {
+ c.Body = strings.TrimSpace(p[6])
}
if len(c.SHA) >= 8 {
c.Short = c.SHA[:8]
@@ -318,6 +521,20 @@ func (r *Repo) MergeBase(a, b string) (string, error) {
return strings.TrimSpace(string(out)), nil
}
+// IsAncestor reports whether a is an ancestor of b (true, in particular,
+// when a == b).
+func (r *Repo) IsAncestor(a, b string) (bool, error) {
+ _, err := r.run("merge-base", "--is-ancestor", a, b)
+ if err == nil {
+ return true, nil
+ }
+ var exitErr *exec.ExitError
+ if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
+ return false, nil
+ }
+ return false, err
+}
+
// LogAll walks every ref in topological order. This is the input to the
// branch graph: --topo-order guarantees a child always precedes its
// parents, which the lane assignment depends on.
@@ -326,8 +543,12 @@ func (r *Repo) LogAll(n int) ([]Commit, error) {
return nil, nil
}
format := strings.Join([]string{"%H", "%an", "%ae", "%aI", "%P", "%s", "%b"}, escLogF)
- return r.parseLog(r.run("log", "--all", "--topo-order", "--date-order",
- "-n", strconv.Itoa(n), "--format="+escLogR+format))
+ // Never request signature status here: this walks every ref unbounded
+ // for the graph view, and git forks gpg per signed commit -- fine at
+ // Log's n=20, not fine here.
+ out, err := r.run("log", "--all", "--topo-order", "--date-order",
+ "-n", strconv.Itoa(n), "--format="+escLogR+format)
+ return r.parseLog(out, err, false)
}
// ------------------------------------------------------------------- trees
@@ -341,7 +562,8 @@ type Entry struct {
Path string // full path from repo root
}
-func (e Entry) IsDir() bool { return e.Type == "tree" }
+func (e Entry) IsDir() bool { return e.Type == "tree" }
+func (e Entry) IsSubmodule() bool { return e.Type == "commit" }
// Tree lists one directory level at rev.
func (r *Repo) Tree(rev, path string) ([]Entry, error) {
@@ -379,6 +601,70 @@ func (r *Repo) Tree(rev, path string) ([]Entry, error) {
return entries, nil
}
+// Stat looks up a single path's own tree entry (its mode/type/sha as
+// recorded by its parent tree), regardless of depth. Unlike Blob, this
+// works for gitlinks (submodules), since it never tries to read object
+// contents.
+func (r *Repo) Stat(rev, path string) (Entry, bool, error) {
+ out, err := r.run("ls-tree", "-z", "--long", rev, "--", path)
+ if err != nil {
+ return Entry{}, false, err
+ }
+ rec := strings.TrimSuffix(string(out), "\x00")
+ if rec == "" {
+ return Entry{}, false, nil
+ }
+ head, name, ok := strings.Cut(rec, "\t")
+ if !ok {
+ return Entry{}, false, nil
+ }
+ f := strings.Fields(head)
+ if len(f) < 3 {
+ return Entry{}, false, nil
+ }
+ e := Entry{Mode: f[0], Type: f[1], SHA: f[2], Name: filepath.Base(name), Path: name}
+ if len(f) > 3 {
+ e.Size, _ = strconv.ParseInt(f[3], 10, 64)
+ }
+ return e, true, nil
+}
+
+// SubmoduleURL looks up the remote configured for a submodule path in
+// .gitmodules at rev. Best-effort: a missing or unparsable file just means
+// no URL is shown alongside the pinned commit.
+func (r *Repo) SubmoduleURL(rev, path string) string {
+ raw, err := r.Blob(rev, ".gitmodules")
+ if err != nil {
+ return ""
+ }
+ var inSection, sectionMatches bool
+ for _, line := range strings.Split(string(raw), "\n") {
+ line = strings.TrimSpace(line)
+ if strings.HasPrefix(line, "[submodule") {
+ inSection = true
+ sectionMatches = false
+ continue
+ }
+ if !inSection {
+ continue
+ }
+ key, val, ok := strings.Cut(line, "=")
+ if !ok {
+ continue
+ }
+ key, val = strings.TrimSpace(key), strings.TrimSpace(val)
+ switch key {
+ case "path":
+ sectionMatches = val == path
+ case "url":
+ if sectionMatches {
+ return val
+ }
+ }
+ }
+ return ""
+}
+
// ------------------------------------------------------------------ diffs
type FileStat struct {
diff --git a/internal/gitx/git_test.go b/internal/gitx/git_test.go
new file mode 100644
index 0000000..73dd814
--- /dev/null
+++ b/internal/gitx/git_test.go
@@ -0,0 +1,157 @@
+package gitx
+
+import (
+ "os"
+ "os/exec"
+ "testing"
+)
+
+// runGit drives git directly (not through Repo.run, which doesn't exist
+// yet at setup time) with the same kind of isolation Repo.run uses --
+// GIT_CONFIG_NOSYSTEM and a throwaway HOME -- so the test behaves the same
+// on a machine with commit.gpgsign=true globally as it does in CI.
+func runGit(t *testing.T, dir string, args ...string) {
+ t.Helper()
+ cmd := exec.Command("git", append([]string{"-C", dir, "-c", "commit.gpgsign=false"}, args...)...)
+ cmd.Env = []string{
+ "PATH=" + os.Getenv("PATH"),
+ "HOME=" + dir,
+ "GIT_CONFIG_NOSYSTEM=1",
+ "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com",
+ "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com",
+ }
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v: %v\n%s", args, err, out)
+ }
+}
+
+// newTestRepo builds a real repository with one empty commit, a branch
+// "feature/x" (a branch name containing a slash), a second branch
+// "release", and a tag "release/1.0". The last two coexist by construction
+// deliberately: refs/heads and refs/tags are separate namespaces, so
+// "release" (a branch) and "release/1.0" (a tag) can both exist even
+// though a branch "release" and a branch "release/1.0" could not -- git
+// refuses that, since a ref name is a path and "release" can't be both a
+// file and a directory. Opened through gitx.Open, because SplitRefPath
+// resolves against Refs(), which shells out to `git for-each-ref` -- there
+// is no faking that, it has to be a real repo with real refs.
+func newTestRepo(t *testing.T) *Repo {
+ t.Helper()
+ dir := t.TempDir()
+ runGit(t, dir, "init", "-q", "-b", "main")
+ runGit(t, dir, "commit", "-q", "--allow-empty", "-m", "init")
+ runGit(t, dir, "branch", "feature/x")
+ runGit(t, dir, "branch", "release")
+ runGit(t, dir, "tag", "release/1.0")
+
+ r, err := Open(dir)
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ return r
+}
+
+// TestSplitRefPath guards the longest-prefix-wins rule that lets ref names
+// contain slashes. A naive split at the first "/" gets both of the two
+// interesting cases here wrong: it would send "feature/x/src/main.go" to
+// ref "feature" (not a ref at all) instead of branch "feature/x", and it
+// would send "release/1.0/docs/readme.md" to branch "release" instead of
+// the more specific tag "release/1.0" -- the real ambiguity SplitRefPath
+// exists to resolve, since both "release" and "release/1.0" are valid refs
+// here and only the longest match is the intended one.
+func TestSplitRefPath(t *testing.T) {
+ r := newTestRepo(t)
+
+ cases := []struct {
+ name string
+ rest string
+ wantRef string
+ wantPath string
+ }{
+ {"single-segment branch with a path", "main/src/main.go", "main", "src/main.go"},
+ {"slashed branch name resolves in full, not just its first segment",
+ "feature/x/src/main.go", "feature/x", "src/main.go"},
+ {"slashed tag wins over a shorter branch that's also a valid prefix",
+ "release/1.0/docs/readme.md", "release/1.0", "docs/readme.md"},
+ {"shorter branch still resolves when no longer candidate matches",
+ "release/README.md", "release", "README.md"},
+ {"slashed branch with no path", "feature/x", "feature/x", ""},
+ {"empty rest falls back to HEAD", "", "main", ""},
+ {"no ref matches: whole thing is a path at HEAD",
+ "no/such/branch/file.go", "main", "no/such/branch/file.go"},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ ref, path := r.SplitRefPath(tc.rest)
+ if ref != tc.wantRef || path != tc.wantPath {
+ t.Errorf("SplitRefPath(%q) = (%q, %q), want (%q, %q)",
+ tc.rest, ref, path, tc.wantRef, tc.wantPath)
+ }
+ })
+ }
+}
+
+// TestIsAncestor builds its own repo (rather than reusing newTestRepo,
+// whose branches all point at the same single commit) because it needs a
+// real linear ancestor/descendant pair plus a genuinely diverged pair:
+// "main" advances past the point where "other" branched off, then "other"
+// advances on its own, so main's tip and other's tip share c1 as a common
+// ancestor but are not ancestors of each other.
+func TestIsAncestor(t *testing.T) {
+ dir := t.TempDir()
+ runGit(t, dir, "init", "-q", "-b", "main")
+ runGit(t, dir, "commit", "-q", "--allow-empty", "-m", "c1")
+
+ r, err := Open(dir)
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ c1, err := r.Resolve("main")
+ if err != nil {
+ t.Fatalf("Resolve(main): %v", err)
+ }
+
+ runGit(t, dir, "branch", "other")
+ runGit(t, dir, "commit", "-q", "--allow-empty", "-m", "c2")
+ c2, err := r.Resolve("main")
+ if err != nil {
+ t.Fatalf("Resolve(main): %v", err)
+ }
+
+ runGit(t, dir, "checkout", "-q", "other")
+ runGit(t, dir, "commit", "-q", "--allow-empty", "-m", "c3-diverged")
+ c3, err := r.Resolve("other")
+ if err != nil {
+ t.Fatalf("Resolve(other): %v", err)
+ }
+
+ cases := []struct {
+ name string
+ a, b string
+ want bool
+ }{
+ {"strict ancestor", c1, c2, true},
+ {"reversed order is not an ancestor relationship", c2, c1, false},
+ {"a commit is its own ancestor", c1, c1, true},
+ {"diverged commits are not ancestors either direction", c2, c3, false},
+ {"diverged commits, reverse direction", c3, c2, false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := r.IsAncestor(tc.a, tc.b)
+ if err != nil {
+ t.Fatalf("IsAncestor(%q, %q): %v", tc.a, tc.b, err)
+ }
+ if got != tc.want {
+ t.Errorf("IsAncestor(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want)
+ }
+ })
+ }
+
+ t.Run("bogus revision is an error, not false", func(t *testing.T) {
+ if _, err := r.IsAncestor("deadbeef", c1); err == nil {
+ t.Error("IsAncestor with a bogus revision: got nil error, want non-nil")
+ }
+ })
+}
diff --git a/internal/modules/blob.go b/internal/modules/blob.go
index b2d9b71..57cf2f0 100644
--- a/internal/modules/blob.go
+++ b/internal/modules/blob.go
@@ -2,6 +2,7 @@ package modules
import (
"bytes"
+ "html/template"
"net/http"
"strings"
"unicode/utf8"
@@ -10,15 +11,55 @@ import (
)
type Blob struct {
- Path string
- Size int
- Binary bool
- Lines []Line
+ Path string
+ Size int
+ Binary bool
+ Image string // validated raster MIME, "" when not a previewable image
+ Lines []Line
+ Submodule *Submodule
+ Lang string // "json" or "yaml", when the path implies one; "" otherwise
+ ParseErr *ParseNote // set when Lang is non-empty and the content doesn't parse
+ Oversize bool // Lang is set, but the file is too large to parse and index
}
+// Structured reports whether the fold/search/reference tooling applies to
+// this file. A recognised extension is not enough on its own: past
+// maxStructuredSize the view falls back to plain numbered lines.
+func (b Blob) Structured() bool { return b.Lang != "" && !b.Oversize }
+
+// StructuredLimit is the size past which that tooling switches itself off,
+// so the banner explaining the fallback can name the threshold.
+func (b Blob) StructuredLimit() int { return maxStructuredSize }
+
+// Preview reports whether the image is small enough to render inline. An
+// image over maxPreviewSize is still downloadable; it just is not fetched a
+// second time by every reader who only wanted to see the tree around it.
+func (b Blob) Preview() bool { return b.Image != "" && b.Size <= maxPreviewSize }
+
+// PreviewLimit names the threshold for the too-large notice.
+func (b Blob) PreviewLimit() int { return maxPreviewSize }
+
+// Submodule describes a gitlink entry (mode 160000): a pointer to a commit
+// in another repository rather than file content, so there is nothing to
+// diff or display line-by-line.
+type Submodule struct {
+ SHA string
+ Remote string // from .gitmodules, best-effort; empty if not found
+}
+
+// Line is one rendered source line. Everything past Text is structure the
+// blob view uses to fold, filter and cross-reference JSON/YAML, and is zero
+// for every other language.
type Line struct {
N int
- Text string
+ Text template.HTML
+
+ Depth int // nesting level, in indent units
+ FoldTo int // last line of the block this one opens; 0 if it opens none
+ Key string // mapping key declared here, for key-scoped search
+ Path string // canonical value path, for path queries; "" when unknown
+ Anchor *Anchor // YAML anchor declared here
+ Ref *StructRef // YAML alias resolved here
}
func init() { core.Register("blob", blobMod) }
@@ -33,6 +74,12 @@ func blobMod(c *core.Ctx, args ...string) (any, error) {
if path == "" {
return nil, core.Errorf(http.StatusNotFound, "no path")
}
+ if entry, ok, _ := c.Repo.Stat(c.Commit, path); ok && entry.Type == "commit" {
+ return Blob{Path: path, Submodule: &Submodule{
+ SHA: entry.SHA,
+ Remote: c.Repo.SubmoduleURL(c.Commit, path),
+ }}, nil
+ }
raw, err := c.Repo.Blob(c.Commit, path)
if err != nil {
return nil, core.Errorf(http.StatusNotFound, "no such file %q", path)
@@ -43,13 +90,37 @@ func blobMod(c *core.Ctx, args ...string) (any, error) {
if len(head) > 8000 {
head = head[:8000]
}
+ // Check the image magic before the generic binary test: a valid raster
+ // image is served as a preview rather than a wall of numbered bytes.
+ b.Image = imageContentType(path, raw)
+ if b.Image != "" {
+ b.Binary = true
+ return b, nil
+ }
if bytes.IndexByte(head, 0) >= 0 || !utf8.Valid(head) {
b.Binary = true
return b, nil
}
- for i, t := range strings.Split(strings.TrimRight(string(raw), "\n"), "\n") {
+ b.Lang = structuredLang(path)
+ b.Oversize = b.Lang != "" && len(raw) > maxStructuredSize
+ if b.Structured() {
+ b.ParseErr = validateStructured(b.Lang, raw)
+ }
+
+ hl, ok := highlightLines(path, raw)
+ if !ok {
+ for _, t := range strings.Split(strings.TrimRight(string(raw), "\n"), "\n") {
+ hl = append(hl, template.HTML(template.HTMLEscapeString(t)))
+ }
+ } else if b.Structured() {
+ hl = withIndentGuides(hl, indentUnit(raw))
+ }
+ for i, t := range hl {
b.Lines = append(b.Lines, Line{N: i + 1, Text: t})
}
+ if b.Structured() {
+ annotateStructure(b.Lines, raw, b.Lang)
+ }
return b, nil
}
@@ -59,10 +130,12 @@ type RawBlob struct {
Path string
Data []byte
Binary bool
+ Image string // validated raster MIME; see imageContentType
}
-func (r RawBlob) Bytes() []byte { return r.Data }
-func (r RawBlob) IsBinary() bool { return r.Binary }
+func (r RawBlob) Bytes() []byte { return r.Data }
+func (r RawBlob) IsBinary() bool { return r.Binary }
+func (r RawBlob) InlineContentType() string { return r.Image }
func init() { core.Register("blobraw", blobRawMod) }
@@ -82,5 +155,10 @@ func blobRawMod(c *core.Ctx, args ...string) (any, error) {
if len(head) > 8000 {
head = head[:8000]
}
- return RawBlob{Path: path, Data: raw, Binary: bytes.IndexByte(head, 0) >= 0}, nil
+ return RawBlob{
+ Path: path,
+ Data: raw,
+ Binary: bytes.IndexByte(head, 0) >= 0,
+ Image: imageContentType(path, raw),
+ }, nil
}
diff --git a/internal/modules/blob_test.go b/internal/modules/blob_test.go
new file mode 100644
index 0000000..70762f8
--- /dev/null
+++ b/internal/modules/blob_test.go
@@ -0,0 +1,121 @@
+package modules
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "gsg/internal/core"
+ "gsg/internal/gitx"
+)
+
+// blobCtx commits files into a throwaway repository and returns a Ctx
+// pointing at it. Isolation matches internal/gitx's own test helper, so a
+// machine with commit.gpgsign=true globally behaves like CI.
+func blobCtx(t *testing.T, files map[string]string) *core.Ctx {
+ t.Helper()
+ dir := t.TempDir()
+ env := []string{
+ "PATH=" + os.Getenv("PATH"),
+ "HOME=" + dir,
+ "GIT_CONFIG_NOSYSTEM=1",
+ "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com",
+ "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com",
+ }
+ git := func(args ...string) {
+ t.Helper()
+ cmd := exec.Command("git", append([]string{"-C", dir, "-c", "commit.gpgsign=false"}, args...)...)
+ cmd.Env = env
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v: %v\n%s", args, err, out)
+ }
+ }
+ git("init", "-q", ".")
+ for name, body := range files {
+ if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ git("add", "-A")
+ git("commit", "-qm", "fixture")
+
+ repo, err := gitx.Open(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sha, err := repo.Resolve("HEAD")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return &core.Ctx{Repo: repo, Ref: "HEAD", Commit: sha}
+}
+
+func blobAt(t *testing.T, c *core.Ctx, path string) Blob {
+ t.Helper()
+ c.Path = path
+ v, err := blobMod(c)
+ if err != nil {
+ t.Fatalf("blob %q: %v", path, err)
+ }
+ b, ok := v.(Blob)
+ if !ok {
+ t.Fatalf("blob %q: got %T", path, v)
+ }
+ return b
+}
+
+// Past maxStructuredSize a config file is served as plain numbered lines:
+// no parse, no fold ranges, no anchor resolution -- and Oversize set so the
+// view can say why rather than silently dropping the tooling.
+func TestStructuredSizeGate(t *testing.T) {
+ var big strings.Builder
+ big.WriteString("items: &items\n")
+ for i := 0; big.Len() <= maxStructuredSize; i++ {
+ fmt.Fprintf(&big, " - name: item-%d\n value: %d\n", i, i)
+ }
+ small := "items: &items\n - name: a\ncopy: *items\n"
+
+ c := blobCtx(t, map[string]string{"big.yaml": big.String(), "small.yaml": small})
+
+ b := blobAt(t, c, "big.yaml")
+ if b.Lang != "yaml" {
+ t.Errorf("Lang = %q, want yaml", b.Lang)
+ }
+ if !b.Oversize || b.Structured() {
+ t.Errorf("Oversize = %v, Structured = %v; want true, false", b.Oversize, b.Structured())
+ }
+ if b.ParseErr != nil {
+ t.Errorf("oversize file was parsed anyway: %+v", b.ParseErr)
+ }
+ if len(b.Lines) == 0 {
+ t.Fatal("oversize file rendered no lines")
+ }
+ for _, l := range b.Lines {
+ if l.FoldTo != 0 || l.Depth != 0 || l.Key != "" || l.Anchor != nil || l.Ref != nil {
+ t.Fatalf("line %d carries structure past the size limit: %+v", l.N, l)
+ }
+ }
+
+ s := blobAt(t, c, "small.yaml")
+ if !s.Structured() || s.Oversize {
+ t.Fatalf("small file: Structured = %v, Oversize = %v", s.Structured(), s.Oversize)
+ }
+ if s.Lines[0].Anchor == nil || s.Lines[2].Ref == nil {
+ t.Error("small file lost its anchor/alias annotations")
+ }
+ if s.Lines[0].FoldTo != 2 {
+ t.Errorf("small file FoldTo = %d, want 2", s.Lines[0].FoldTo)
+ }
+}
+
+// A non-config file is never structured, whatever its size.
+func TestPlainFileIsNotStructured(t *testing.T) {
+ c := blobCtx(t, map[string]string{"main.go": "package main\n\nfunc main() {}\n"})
+ b := blobAt(t, c, "main.go")
+ if b.Structured() || b.Oversize || b.Lang != "" {
+ t.Errorf("go file: Lang = %q, Structured = %v, Oversize = %v", b.Lang, b.Structured(), b.Oversize)
+ }
+}
diff --git a/internal/modules/commit.go b/internal/modules/commit.go
index 5a037f3..713c1e8 100644
--- a/internal/modules/commit.go
+++ b/internal/modules/commit.go
@@ -17,7 +17,7 @@ func commitMod(c *core.Ctx, args ...string) (any, error) {
if c.Commit == "" {
return nil, core.Errorf(http.StatusNotFound, "no commit")
}
- log, err := c.Repo.Log(c.Commit, 1)
+ log, err := c.Repo.Log(c.Commit, 1, 0, false)
if err != nil {
return nil, err
}
diff --git a/internal/modules/compare.go b/internal/modules/compare.go
index 980f1bc..b58a1eb 100644
--- a/internal/modules/compare.go
+++ b/internal/modules/compare.go
@@ -52,7 +52,7 @@ func compareMod(c *core.Ctx, args ...string) (any, error) {
}
cmp.Diff = d
- if log, err := c.Repo.Log(from+".."+head, 250); err == nil {
+ if log, err := c.Repo.Log(from+".."+head, 250, 0, false); err == nil {
cmp.Commits = log
}
return cmp, nil
diff --git a/internal/modules/example_authors.go b/internal/modules/example_authors.go
index fb30007..7ca32c3 100644
--- a/internal/modules/example_authors.go
+++ b/internal/modules/example_authors.go
@@ -30,7 +30,7 @@ func authorsMod(c *core.Ctx, args ...string) (any, error) {
if rev == "" {
rev = c.Ref
}
- log, err := c.Repo.Log(rev, n)
+ log, err := c.Repo.Log(rev, n, 0, false)
if err != nil {
return nil, err
}
diff --git a/internal/modules/graph.go b/internal/modules/graph.go
index c513c1a..db71a0e 100644
--- a/internal/modules/graph.go
+++ b/internal/modules/graph.go
@@ -21,6 +21,10 @@ import (
type Graph struct {
Rows []Row
Lanes int
+
+ Ofs, PageSize int
+ HasPrev, HasNext bool
+ PrevOfs, NextOfs int
}
type Row struct {
@@ -43,15 +47,29 @@ type Row struct {
func init() { core.Register("graph", graphMod) }
-// graph: the DAG across every ref. {{ mod "graph" "120" }}
+// graph: the DAG across every ref. {{ mod "graph" "120" }} -- default
+// core.GraphPageSize. Paginated via the ?ofs= query param on the graph
+// route (see buildCtx), which core.GraphPageSize must match.
+//
+// Lane assignment below is a single forward pass that carries state
+// (`active`) across every row, so a page can't be computed by slicing the
+// git fetch with --skip -- that would reset `active` at the page boundary
+// and corrupt lane continuity. Instead every request walks the full range
+// from the top of history through the end of the requested page, and only
+// the rendered rows are sliced down to the page afterward, once Lanes and
+// each row's Rail SVG have already been computed against the full range.
func graphMod(c *core.Ctx, args ...string) (any, error) {
- n := 150
+ n := core.GraphPageSize
if len(args) > 0 {
if v, err := strconv.Atoi(args[0]); err == nil && v > 0 {
n = v
}
}
- commits, err := c.Repo.LogAll(n)
+ ofs, _ := strconv.Atoi(c.Var("ofs"))
+ if ofs < 0 {
+ ofs = 0
+ }
+ commits, err := c.Repo.LogAll(ofs + n + 1) // from the top; no --skip, see above
if err != nil {
return nil, err
}
@@ -115,6 +133,24 @@ func graphMod(c *core.Ctx, args ...string) (any, error) {
if g.Lanes == 0 {
g.Lanes = 1
}
+
+ total := len(g.Rows)
+ end := ofs + n
+ hasNext := total > end
+ if end > total {
+ end = total
+ }
+ start := ofs
+ if start > total {
+ start = total
+ }
+ g.Rows = g.Rows[start:end]
+ g.Ofs, g.PageSize = ofs, n
+ g.HasPrev, g.PrevOfs = ofs > 0, ofs-n
+ if g.PrevOfs < 0 {
+ g.PrevOfs = 0
+ }
+ g.HasNext, g.NextOfs = hasNext, end
return g, nil
}
diff --git a/internal/modules/highlight.go b/internal/modules/highlight.go
new file mode 100644
index 0000000..8c7431d
--- /dev/null
+++ b/internal/modules/highlight.go
@@ -0,0 +1,79 @@
+package modules
+
+import (
+ "html/template"
+ "strings"
+
+ "github.com/alecthomas/chroma/v2"
+ "github.com/alecthomas/chroma/v2/lexers"
+)
+
+// maxHighlightSize bounds how much source chroma will tokenise. Past this,
+// a file falls back to plain (still viewable, just uncoloured) text rather
+// than risk a slow lex on something pathological.
+const maxHighlightSize = 512 * 1024
+
+// highlightLines lexes src by the language implied by path and returns one
+// escaped, span-wrapped HTML fragment per line. ok is false when path has no
+// recognised language or src is too large to bother lexing; callers should
+// fall back to plain escaped lines in that case.
+func highlightLines(path string, src []byte) (lines []template.HTML, ok bool) {
+ if len(src) > maxHighlightSize {
+ return nil, false
+ }
+ lexer := lexers.Match(path)
+ if lexer == nil {
+ return nil, false
+ }
+ it, err := chroma.Coalesce(lexer).Tokenise(nil, string(src))
+ if err != nil {
+ return nil, false
+ }
+ for _, toks := range chroma.SplitTokensIntoLines(it.Tokens()) {
+ var b strings.Builder
+ for i, tok := range toks {
+ // SplitTokensIntoLines keeps each line's newline attached to
+ // its last token; drop it so it doesn't render as a blank
+ // row inside the (white-space: pre) table cell.
+ if i == len(toks)-1 {
+ tok.Value = strings.TrimSuffix(tok.Value, "\n")
+ }
+ esc := template.HTMLEscapeString(tok.Value)
+ if cls := tokenClass(tok.Type); cls != "" {
+ b.WriteString(`<span class="`)
+ b.WriteString(cls)
+ b.WriteString(`">`)
+ b.WriteString(esc)
+ b.WriteString(`</span>`)
+ } else {
+ b.WriteString(esc)
+ }
+ }
+ lines = append(lines, template.HTML(b.String()))
+ }
+ return lines, true
+}
+
+// tokenClass buckets chroma's ~70 token types down to the handful of colours
+// the theme actually defines (see --syn-* in base.css). Punctuation,
+// operators and plain names are left uncoloured on purpose -- a source line
+// with every token tinted reads as noise, not signal.
+func tokenClass(t chroma.TokenType) string {
+ switch {
+ case t.InCategory(chroma.Comment):
+ return "tok-c"
+ case t.InCategory(chroma.Keyword):
+ return "tok-k"
+ case t.InSubCategory(chroma.LiteralString):
+ return "tok-s"
+ case t.InSubCategory(chroma.LiteralNumber):
+ return "tok-n"
+ case t == chroma.NameFunction, t == chroma.NameFunctionMagic,
+ t == chroma.NameClass, t == chroma.NameNamespace,
+ t == chroma.NameBuiltin, t == chroma.NameBuiltinPseudo,
+ t == chroma.NameTag, t == chroma.NameDecorator:
+ return "tok-fn"
+ default:
+ return ""
+ }
+}
diff --git a/internal/modules/image.go b/internal/modules/image.go
new file mode 100644
index 0000000..088489b
--- /dev/null
+++ b/internal/modules/image.go
@@ -0,0 +1,72 @@
+package modules
+
+import (
+ "bytes"
+ "path"
+ "strings"
+)
+
+// maxPreviewSize bounds how large an image the blob view will render inline.
+// Past it the view keeps the download link but drops the preview, so a large
+// file is not pulled over the wire twice (once as the page's subresource,
+// once as the bytes the reader actually wanted). The /raw/ route itself is
+// not capped here: a direct request is an explicit request for the bytes,
+// and MaxGit still bounds every git read -- it must stay above this value or
+// a previewable-sized image would already be truncated by the time it gets
+// here.
+const maxPreviewSize = 10 << 20 // 10 MiB
+
+// imageContentType returns the MIME type to serve at p when its leading
+// bytes are that format and the extension agrees, and "" otherwise.
+//
+// SECURITY: this is the one place /raw/ is allowed to declare a non-text
+// Content-Type, so the rules are deliberately narrow:
+// - only raster formats are recognised. SVG is an XML document that can
+// carry <script>, so it is never in this list and keeps the text/plain
+// fallback; the danger is top-level navigation to an image/svg+xml
+// response on this origin, not <img>, where scripts do not run.
+// - the extension and the magic bytes must BOTH match, so an .png whose
+// contents are really HTML is not relabelled as an image. Relaxing the
+// current attachment rule for attacker-influenced bytes is only safe
+// because a mislabelled file fails here and keeps the download.
+// - the caller still sends X-Content-Type-Options: nosniff and the
+// sandbox CSP, so even a polyglot declared image/* cannot be re-sniffed
+// as a document.
+func imageContentType(p string, data []byte) string {
+ switch strings.ToLower(path.Ext(p)) {
+ case ".png":
+ if bytes.HasPrefix(data, []byte("\x89PNG\r\n\x1a\n")) {
+ return "image/png"
+ }
+ case ".jpg", ".jpeg":
+ if bytes.HasPrefix(data, []byte("\xff\xd8\xff")) {
+ return "image/jpeg"
+ }
+ case ".gif":
+ if bytes.HasPrefix(data, []byte("GIF87a")) || bytes.HasPrefix(data, []byte("GIF89a")) {
+ return "image/gif"
+ }
+ case ".webp":
+ if len(data) >= 12 && bytes.Equal(data[:4], []byte("RIFF")) &&
+ bytes.Equal(data[8:12], []byte("WEBP")) {
+ return "image/webp"
+ }
+ case ".bmp":
+ if bytes.HasPrefix(data, []byte("BM")) {
+ return "image/bmp"
+ }
+ case ".ico":
+ if bytes.HasPrefix(data, []byte("\x00\x00\x01\x00")) {
+ return "image/x-icon"
+ }
+ case ".avif":
+ // ISO-BMFF: "ftyp" at offset 4, major brand avif/avis at 8. A file
+ // with a compatible-but-not-major avif brand is left unlabelled
+ // rather than guessed at.
+ if len(data) >= 12 && bytes.Equal(data[4:8], []byte("ftyp")) &&
+ (bytes.Equal(data[8:12], []byte("avif")) || bytes.Equal(data[8:12], []byte("avis"))) {
+ return "image/avif"
+ }
+ }
+ return ""
+}
diff --git a/internal/modules/image_test.go b/internal/modules/image_test.go
new file mode 100644
index 0000000..ab563a0
--- /dev/null
+++ b/internal/modules/image_test.go
@@ -0,0 +1,99 @@
+package modules
+
+import "testing"
+
+// A file only earns an inline image MIME when its extension and its magic
+// bytes agree and the format is a raster one. The cases that must return ""
+// are the attacker-influenced ones: content that is not an image at all, and
+// SVG, which is an active document rather than a picture.
+func TestImageContentType(t *testing.T) {
+ png := []byte("\x89PNG\r\n\x1a\n" + "rest of the file")
+ tests := []struct {
+ name string
+ path string
+ data []byte
+ want string
+ }{
+ {"png", "logo.png", png, "image/png"},
+ {"uppercase extension", "LOGO.PNG", png, "image/png"},
+ {"jpeg", "a.jpg", []byte{0xff, 0xd8, 0xff, 0xe0}, "image/jpeg"},
+ {"jpeg long extension", "a.jpeg", []byte{0xff, 0xd8, 0xff, 0xe0}, "image/jpeg"},
+ {"gif87", "a.gif", []byte("GIF87a...."), "image/gif"},
+ {"gif89", "a.gif", []byte("GIF89a...."), "image/gif"},
+ {"webp", "a.webp", []byte("RIFF\x00\x00\x00\x00WEBPVP8 "), "image/webp"},
+ {"bmp", "a.bmp", []byte("BM...."), "image/bmp"},
+ {"ico", "a.ico", []byte{0x00, 0x00, 0x01, 0x00}, "image/x-icon"},
+ {"avif", "a.avif", []byte("\x00\x00\x00\x18ftypavif"), "image/avif"},
+
+ // HTML wearing a .png name, and a real PNG named .txt, must both
+ // fall back to the download path.
+ {"html named png", "evil.png", []byte("<html><script>x</script>"), ""},
+ {"png extension without magic", "evil.png", []byte("not an image"), ""},
+ {"magic without extension", "evil.txt", png, ""},
+ {"truncated png", "evil.png", []byte("\x89PNG"), ""},
+
+ // SVG can carry <script> and would be stored XSS if served as a
+ // document on this origin, so it is never recognised here.
+ {"svg", "evil.svg", []byte(`<svg xmlns="http://www.w3.org/2000/svg"><script/></svg>`), ""},
+ {"no extension", "Makefile", []byte("all:\n"), ""},
+ }
+ for _, tc := range tests {
+ if got := imageContentType(tc.path, tc.data); got != tc.want {
+ t.Errorf("%s: imageContentType(%q) = %q, want %q", tc.name, tc.path, got, tc.want)
+ }
+ }
+}
+
+// The blob view only renders a preview for a validated raster image, and
+// only while it is under the size cap; both facts are what tell the view
+// whether to show an <img>, a download notice, or numbered bytes.
+func TestImageBlobPreview(t *testing.T) {
+ png := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 64)...)
+ big := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, maxPreviewSize)...)
+ c := blobCtx(t, map[string]string{
+ "logo.png": string(png),
+ "big.png": string(big),
+ "evil.png": "<html>not an image</html>",
+ })
+
+ b := blobAt(t, c, "logo.png")
+ if b.Image != "image/png" || !b.Preview() || !b.Binary {
+ t.Errorf("small image: Image=%q Preview=%v Binary=%v", b.Image, b.Preview(), b.Binary)
+ }
+ if b.PreviewLimit() != maxPreviewSize {
+ t.Errorf("PreviewLimit = %d, want %d", b.PreviewLimit(), maxPreviewSize)
+ }
+
+ bigBlob := blobAt(t, c, "big.png")
+ if bigBlob.Image != "image/png" {
+ t.Errorf("large image: Image = %q, want image/png", bigBlob.Image)
+ }
+ if bigBlob.Preview() {
+ t.Error("large image was previewed past the size cap")
+ }
+
+ // Not an image, so it keeps the plain binary path rather than a preview.
+ evil := blobAt(t, c, "evil.png")
+ if evil.Image != "" || evil.Preview() {
+ t.Errorf("misnamed html: Image=%q Preview=%v, want none", evil.Image, evil.Preview())
+ }
+}
+
+// /raw/ must declare the image MIME so the preview and direct navigation
+// render a picture, and must expose it through the interface core checks.
+func TestRawBlobInlineImage(t *testing.T) {
+ png := []byte("\x89PNG\r\n\x1a\n" + "body")
+ c := blobCtx(t, map[string]string{"logo.png": string(png)})
+ c.Path = "logo.png"
+ v, err := blobRawMod(c)
+ if err != nil {
+ t.Fatal(err)
+ }
+ r, ok := v.(RawBlob)
+ if !ok {
+ t.Fatalf("got %T", v)
+ }
+ if r.InlineContentType() != "image/png" {
+ t.Errorf("InlineContentType = %q, want image/png", r.InlineContentType())
+ }
+}
diff --git a/internal/modules/log.go b/internal/modules/log.go
index 37fb3b1..94dfd33 100644
--- a/internal/modules/log.go
+++ b/internal/modules/log.go
@@ -4,21 +4,56 @@ import (
"strconv"
"gsg/internal/core"
+ "gsg/internal/gitx"
)
func init() { core.Register("log", logMod) }
-// log: recent commits. {{ mod "log" "20" }} -- default 25, "0" = all.
+// LogPage is one page of commit history, with enough to render prev/next
+// links without a separate count query (see the n+1 fetch trick below).
+type LogPage struct {
+ Commits []gitx.Commit
+ Ofs, PageSize int
+ HasPrev, HasNext bool
+ PrevOfs, NextOfs int
+}
+
+// log: a page of recent commits. {{ mod "log" "20" }} -- default
+// core.LogPageSize, "0" = all. Paginated via the ?ofs= query param on the
+// log route (see buildCtx), which core.LogPageSize must match.
func logMod(c *core.Ctx, args ...string) (any, error) {
- n := 25
+ n := core.LogPageSize
if len(args) > 0 {
if v, err := strconv.Atoi(args[0]); err == nil {
n = v
}
}
+ ofs, _ := strconv.Atoi(c.Var("ofs"))
+ if ofs < 0 {
+ ofs = 0
+ }
rev := c.Commit
if rev == "" {
rev = c.Ref
}
- return c.Repo.Log(rev, n)
+
+ fetch := n
+ if n > 0 {
+ fetch = n + 1 // one extra row to detect a next page
+ }
+ commits, err := c.Repo.Log(rev, fetch, ofs, true)
+ if err != nil {
+ return nil, err
+ }
+
+ page := LogPage{Ofs: ofs, PageSize: n, HasPrev: ofs > 0, PrevOfs: ofs - n}
+ if page.PrevOfs < 0 {
+ page.PrevOfs = 0
+ }
+ if n > 0 && len(commits) > n {
+ commits = commits[:n]
+ page.HasNext, page.NextOfs = true, ofs+n
+ }
+ page.Commits = commits
+ return page, nil
}
diff --git a/internal/modules/meta.go b/internal/modules/meta.go
index f6f9dad..0ba257a 100644
--- a/internal/modules/meta.go
+++ b/internal/modules/meta.go
@@ -20,7 +20,7 @@ func init() { core.Register("meta", metaMod) }
// the Ctx cache.
func metaMod(c *core.Ctx, args ...string) (any, error) {
m := Meta{RepoInfo: c.Info, DefaultRef: c.Repo.HeadRef()}
- if log, err := c.Repo.Log(c.Ref, 1); err == nil && len(log) > 0 {
+ if log, err := c.Repo.Log(c.Ref, 1, 0, false); err == nil && len(log) > 0 {
m.LastCommit = log[0].When
}
return m, nil
diff --git a/internal/modules/notices.go b/internal/modules/notices.go
new file mode 100644
index 0000000..e0df81f
--- /dev/null
+++ b/internal/modules/notices.go
@@ -0,0 +1,29 @@
+package modules
+
+import (
+ "gsg/internal/core"
+ "gsg/internal/notices"
+)
+
+func init() { core.Register("notices", noticesMod) }
+
+// notices: the site-wide banners followed by the ones for the repository
+// being viewed, in that order. Site-scoped routes (the index) build a Ctx
+// with c.Repo == nil and an empty Info.Slug, so this must never touch
+// c.Repo and must skip the repo half when Slug is empty. A nil default
+// store (the feature is off) is an empty list, not an error -- the
+// template uses modOr and guards on Immutable, so an empty list renders
+// nothing.
+func noticesMod(c *core.Ctx, args ...string) (any, error) {
+ st := notices.Default()
+ if st == nil {
+ return []notices.Notice(nil), nil
+ }
+ snap := st.Snapshot()
+ out := make([]notices.Notice, 0, len(snap.Doc.Global)+8)
+ out = append(out, snap.Doc.Global...)
+ if c.Info.Slug != "" {
+ out = append(out, snap.Doc.Repos[c.Info.Slug]...)
+ }
+ return out, nil
+}
diff --git a/internal/modules/notices_test.go b/internal/modules/notices_test.go
new file mode 100644
index 0000000..02ff9e3
--- /dev/null
+++ b/internal/modules/notices_test.go
@@ -0,0 +1,88 @@
+package modules
+
+import (
+ "html/template"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "gsg/internal/core"
+ "gsg/internal/notices"
+)
+
+// Global notices come before the repository's own; a site-scoped Ctx (nil
+// Repo, empty Slug) gets the global list only and must not panic.
+func TestNoticesModMergesGlobalThenRepo(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "notices.yaml")
+ st, err := notices.Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(st.Close)
+ err = st.Set(notices.Document{
+ Global: []notices.Notice{{Type: notices.TypeCritical, Title: "global"}},
+ Repos: map[string][]notices.Notice{"demo": {{Type: notices.TypeInfo, Title: "repo"}}},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ notices.SetDefault(st)
+ t.Cleanup(func() { notices.SetDefault(nil) })
+
+ v, err := noticesMod(&core.Ctx{Info: core.RepoInfo{Slug: "demo"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, ok := v.([]notices.Notice)
+ if !ok {
+ t.Fatalf("notices returned %T", v)
+ }
+ if len(got) != 2 || got[0].Title != "global" || got[1].Title != "repo" {
+ t.Fatalf("got %+v", got)
+ }
+
+ v, err = noticesMod(&core.Ctx{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := v.([]notices.Notice); len(got) != 1 || got[0].Title != "global" {
+ t.Fatalf("site-scoped got %+v", got)
+ }
+}
+
+// The notices partial must render nothing on a page that will be cached
+// immutably, and must not even call the module there -- the guard wraps the
+// modOr call, not just its output.
+func TestNoticesPartialSuppressesImmutable(t *testing.T) {
+ src, err := os.ReadFile("../../templates/layouts/notices.html")
+ if err != nil {
+ t.Fatal(err)
+ }
+ calls := 0
+ tmpl, err := template.New("notices.html").Funcs(template.FuncMap{
+ "modOr": func(string, ...string) any {
+ calls++
+ return []notices.Notice{{Type: notices.TypeInfo, Title: "banner"}}
+ },
+ }).Parse(string(src))
+ if err != nil {
+ t.Fatal(err)
+ }
+ render := func(immutable bool) string {
+ var sb strings.Builder
+ if err := tmpl.ExecuteTemplate(&sb, "notices", struct{ Immutable bool }{immutable}); err != nil {
+ t.Fatal(err)
+ }
+ return sb.String()
+ }
+
+ got := render(true)
+ if strings.Contains(got, "banner") || calls != 0 {
+ t.Fatalf("immutable page rendered notices (calls=%d): %q", calls, got)
+ }
+ got = render(false)
+ if !strings.Contains(got, "banner") || calls != 1 {
+ t.Fatalf("non-immutable page did not render (calls=%d): %q", calls, got)
+ }
+}
diff --git a/internal/modules/readme.go b/internal/modules/readme.go
index 82642e0..ada1101 100644
--- a/internal/modules/readme.go
+++ b/internal/modules/readme.go
@@ -23,12 +23,18 @@ func readmeMod(c *core.Ctx, args ...string) (any, error) {
if rev == "" {
rev = c.Ref
}
- entries, err := c.Repo.Tree(rev, c.Path)
+ // Same (rev, path) listing the `tree` module already computes for this
+ // page -- go through it instead of forking a second identical ls-tree.
+ v, err := c.Mod("tree")
if err != nil {
return nil, nil
}
+ t, ok := v.(Tree)
+ if !ok {
+ return nil, nil
+ }
present := map[string]bool{}
- for _, e := range entries {
+ for _, e := range t.Entries {
if e.Type == "blob" {
present[e.Name] = true
}
diff --git a/internal/modules/repos.go b/internal/modules/repos.go
index 9709b25..79a8dae 100644
--- a/internal/modules/repos.go
+++ b/internal/modules/repos.go
@@ -3,6 +3,7 @@ package modules
import (
"fmt"
"sort"
+ "sync"
"time"
"gsg/internal/core"
@@ -15,20 +16,96 @@ type RepoSummary struct {
func init() { core.Register("repos", reposMod) }
-// repos: every visible repository, newest activity first.
-// Site-scoped -- does not touch c.Repo, so it is safe on the site Ctx.
-func reposMod(c *core.Ctx, args ...string) (any, error) {
- out := make([]RepoSummary, 0, len(c.Site.Repos))
- for _, h := range c.Site.Repos {
+// reposCacheTTL bounds how stale the index page's "last updated" ordering
+// and repo list can be. reposMod is site-scoped, so it never gets a
+// resolved commit and therefore never touches the module cache (see
+// Ctx.CacheKey) -- without this, every hit to "/" or the JSON index forks
+// git twice per repo, unconditionally, no matter how often the page is
+// requested. A few seconds of staleness here is the same trade-off the
+// fsnotify debounce already makes for repo discovery, just applied to one
+// more surface.
+const reposCacheTTL = 5 * time.Second
+
+var reposCache struct {
+ mu sync.Mutex
+ at time.Time
+ val []RepoSummary
+}
+
+// publicRepos returns every publicly-visible repository, newest activity
+// first, refreshed at most once per reposCacheTTL. This is the expensive,
+// I/O-bound part (one git log per repo) that the cache above exists for,
+// and it is deliberately untouched by personalization (see reposMod):
+// anonymous traffic -- the overwhelming majority of requests -- keeps
+// paying exactly what it paid before logins or share links existed,
+// regardless of how many hidden repos a site has.
+func publicRepos(c *core.Ctx) []RepoSummary {
+ reposCache.mu.Lock()
+ if !reposCache.at.IsZero() && time.Since(reposCache.at) < reposCacheTTL {
+ out := reposCache.val
+ reposCache.mu.Unlock()
+ return out
+ }
+ reposCache.mu.Unlock()
+
+ list := c.Site.RepoList()
+ out := make([]RepoSummary, 0, len(list))
+ for _, h := range list {
if h.Info.Hidden {
continue
}
- s := RepoSummary{RepoInfo: h.Info}
- if log, err := h.Repo.Log(h.Repo.HeadRef(), 1); err == nil && len(log) > 0 {
- s.LastCommit = log[0].When
+ out = append(out, summarize(h))
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].LastCommit.After(out[j].LastCommit) })
+
+ reposCache.mu.Lock()
+ reposCache.val, reposCache.at = out, time.Now()
+ reposCache.mu.Unlock()
+ return out
+}
+
+func summarize(h core.Handle) RepoSummary {
+ s := RepoSummary{RepoInfo: h.Info}
+ if log, err := h.Repo.Log(h.Repo.HeadRef(), 1, 0, false); err == nil && len(log) > 0 {
+ s.LastCommit = log[0].When
+ }
+ return s
+}
+
+// repos: every repository visible to the requester, newest activity first
+// -- every public repo, plus any hidden repo the current identity is
+// allowed to see, whether as an owner (gsg.visible-to) or via an unrevoked
+// share grant. Site-scoped -- does not touch c.Repo, so it is safe on the
+// site Ctx.
+func reposMod(c *core.Ctx, args ...string) (any, error) {
+ base := publicRepos(c)
+ if c.User == "" && len(c.SharedSlugs) == 0 {
+ return base, nil
+ }
+
+ // Uncached and on-demand, by design: only the handful of hidden repos
+ // this specific identity unlocks, never a walk weighted by total
+ // hidden-repo count. Cost scales with how many repos one person can
+ // see, not with site size -- an owner (or share-link visitor) reloading
+ // "/" pays one git log per repo they can unlock, on every request (not
+ // cross-request-memoized), which is the trade-off for keeping
+ // publicRepos's shared cache above entirely free of personalization.
+ var unlocked []RepoSummary
+ for _, h := range c.Site.RepoList() {
+ if !h.Info.Hidden {
+ continue
+ }
+ if h.Info.CanView(c.User) || h.Info.SharedVisible(c.SharedSlugs, c.SharedIssuedAt) {
+ unlocked = append(unlocked, summarize(h))
}
- out = append(out, s)
}
+ if len(unlocked) == 0 {
+ return base, nil
+ }
+
+ out := make([]RepoSummary, 0, len(base)+len(unlocked))
+ out = append(out, base...)
+ out = append(out, unlocked...)
sort.Slice(out, func(i, j int) bool { return out[i].LastCommit.After(out[j].LastCommit) })
return out, nil
}
diff --git a/internal/modules/structured.go b/internal/modules/structured.go
new file mode 100644
index 0000000..f26045f
--- /dev/null
+++ b/internal/modules/structured.go
@@ -0,0 +1,516 @@
+package modules
+
+import (
+ "bytes"
+ "encoding/json"
+ "html/template"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+)
+
+// ParseNote reports why a JSON/YAML blob failed to parse, so the blob view
+// can point at the offending line instead of leaving a reader to guess.
+// Line is 0 when the underlying error can't be pinned to one.
+type ParseNote struct {
+ Line int
+ Msg string
+}
+
+// Anchor is a YAML anchor declared on a line, along with how many alias
+// sites in the file point back at it.
+type Anchor struct {
+ Name string
+ Uses int
+}
+
+// StructRef is a resolved YAML alias: which anchor it names, the line that
+// anchor is declared on, and the value it stands for rendered back to YAML.
+// One per line -- a flow sequence like [*a, *b] resolves only its first.
+type StructRef struct {
+ Name string
+ Line int
+ Preview string
+}
+
+// maxStructuredSize bounds the work one file may cost the structured view:
+// a full parse on the server, and a pass over every row in the browser at
+// load. It matches maxHighlightSize on purpose -- past that point the file
+// is already served as plain, unhighlighted text with no indent guides, and
+// folding a file that has neither would be a strange half-feature. Half a
+// megabyte is far above any hand-written config and below the size where a
+// modest laptop notices the per-row work.
+const maxStructuredSize = maxHighlightSize
+
+// structuredLang returns "json" or "yaml" for a recognised config
+// extension, "" otherwise. The blob view only spends the extra parse and
+// indent-guide pass on formats where nesting depth is the whole point.
+func structuredLang(path string) string {
+ p := strings.ToLower(path)
+ switch {
+ case strings.HasSuffix(p, ".json"):
+ return "json"
+ case strings.HasSuffix(p, ".yaml"), strings.HasSuffix(p, ".yml"):
+ return "yaml"
+ default:
+ return ""
+ }
+}
+
+func validateStructured(lang string, raw []byte) *ParseNote {
+ var err error
+ switch lang {
+ case "json":
+ err = json.Unmarshal(raw, new(any))
+ case "yaml":
+ err = yaml.Unmarshal(raw, new(any))
+ default:
+ return nil
+ }
+ if err == nil {
+ return nil
+ }
+ return &ParseNote{Line: parseErrorLine(lang, raw, err), Msg: err.Error()}
+}
+
+var yamlLineRe = regexp.MustCompile(`line (\d+)`)
+
+func parseErrorLine(lang string, raw []byte, err error) int {
+ if lang == "json" {
+ switch e := err.(type) {
+ case *json.SyntaxError:
+ return bytes.Count(raw[:e.Offset], []byte("\n")) + 1
+ case *json.UnmarshalTypeError:
+ return bytes.Count(raw[:e.Offset], []byte("\n")) + 1
+ }
+ return 0
+ }
+ // yaml.v3 errors are plain strings of the form "yaml: line N: ...", or
+ // for TypeError one such string per bad field -- take the first.
+ if m := yamlLineRe.FindStringSubmatch(err.Error()); m != nil {
+ n, _ := strconv.Atoi(m[1])
+ return n
+ }
+ return 0
+}
+
+// indentUnit finds the smallest non-zero run of leading spaces across
+// non-blank lines, so a 4-space file gets one guide per level and a
+// 2-space file doesn't get a spurious extra one.
+func indentUnit(raw []byte) int {
+ unit := 0
+ for _, line := range bytes.Split(raw, []byte("\n")) {
+ n := 0
+ for n < len(line) && line[n] == ' ' {
+ n++
+ }
+ if n == 0 || n == len(line) {
+ continue // blank or all-whitespace
+ }
+ if unit == 0 || n < unit {
+ unit = n
+ }
+ }
+ if unit == 0 {
+ return 2
+ }
+ return unit
+}
+
+// annotateStructure fills in the per-line structure the blob view needs to
+// fold, filter and cross-reference a config file: nesting depth, the extent
+// of the block each line opens, the mapping key it declares, and (YAML only)
+// the anchors and aliases tying parts of the file together.
+//
+// It works off raw rather than the highlighted HTML: indentation is the one
+// signal that means the same thing in both languages, and it survives
+// minified or oddly-formatted input by simply finding nothing to fold.
+func annotateStructure(lines []Line, raw []byte, lang string) {
+ src := bytes.Split(raw, []byte("\n"))
+ unit := indentUnit(raw)
+
+ ind := make([]int, len(src))
+ for i, l := range src {
+ ind[i] = lineIndent(l)
+ }
+ ends := foldEnds(ind)
+
+ keyRe := jsonKeyRe
+ if lang == "yaml" {
+ keyRe = yamlKeyRe
+ }
+ for i := range src {
+ if i >= len(lines) {
+ break
+ }
+ if ind[i] >= 0 {
+ lines[i].Depth = ind[i] / unit
+ if e := ends[i]; e > 0 && e <= len(lines) {
+ lines[i].FoldTo = e
+ }
+ }
+ if m := keyRe.FindSubmatch(src[i]); m != nil {
+ for _, g := range m[1:] {
+ if len(g) > 0 {
+ lines[i].Key = string(g)
+ break
+ }
+ }
+ }
+ }
+ if lang != "yaml" {
+ annotateJSONPaths(lines, raw)
+ return
+ }
+ anchors, refs, paths := yamlAnnotations(src)
+ for n, a := range anchors {
+ if n >= 1 && n <= len(lines) {
+ lines[n-1].Anchor = a
+ }
+ }
+ for n, r := range refs {
+ if n >= 1 && n <= len(lines) {
+ lines[n-1].Ref = r
+ }
+ }
+ for n, p := range paths {
+ if n >= 1 && n <= len(lines) {
+ lines[n-1].Path = p
+ }
+ }
+}
+
+// lineIndent counts leading spaces, or returns -1 for a line that carries no
+// structure (blank or whitespace-only) so folding steps over it rather than
+// treating column 0 as a dedent.
+func lineIndent(line []byte) int {
+ n := 0
+ for n < len(line) && line[n] == ' ' {
+ n++
+ }
+ if n == len(bytes.TrimRight(line, " \t\r")) {
+ return -1
+ }
+ return n
+}
+
+// foldEnds returns, per line, the 1-based number of the last line belonging
+// to the block that line opens -- 0 when it opens nothing. A line opens a
+// block when the next line carrying structure is indented further, and the
+// block runs until indentation returns to that line's own level or less.
+// Blank lines between siblings are absorbed; blank lines trailing a block
+// are not.
+func foldEnds(ind []int) []int {
+ ends := make([]int, len(ind))
+ type frame struct {
+ ind, idx int
+ child bool
+ }
+ var stack []frame
+ prev := -1 // last line carrying structure
+ for i, in := range ind {
+ if in < 0 {
+ continue
+ }
+ for len(stack) > 0 && stack[len(stack)-1].ind >= in {
+ if t := stack[len(stack)-1]; t.child {
+ ends[t.idx] = prev + 1
+ }
+ stack = stack[:len(stack)-1]
+ }
+ if len(stack) > 0 {
+ stack[len(stack)-1].child = true
+ }
+ stack = append(stack, frame{ind: in, idx: i})
+ prev = i
+ }
+ for _, t := range stack {
+ if t.child {
+ ends[t.idx] = prev + 1
+ }
+ }
+ return ends
+}
+
+var (
+ // The key a line declares, used to scope search to keys rather than
+ // whole lines. Both are deliberately shallow: a miss costs a narrower
+ // search, not a wrong render.
+ yamlKeyRe = regexp.MustCompile(`^\s*(?:-\s+)?(?:"([^"]*)"|'([^']*)'|([^\s#"'][^:#]*?))\s*:(?:\s|$)`)
+ jsonKeyRe = regexp.MustCompile(`^\s*"((?:[^"\\]|\\.)*)"\s*:`)
+)
+
+// yamlAnnotations resolves every alias in the file to the line its anchor is
+// declared on, keyed by 1-based line number, and records each line's
+// canonical value path. Anchors are per-document, so the whole stream is
+// decoded document by document -- a k8s manifest that defines its anchors in
+// the second document would otherwise come back empty. A malformed document
+// ends the walk with whatever was found first; the parse-error banner
+// already reports the failure itself.
+func yamlAnnotations(src [][]byte) (map[int]*Anchor, map[int]*StructRef, map[int]string) {
+ anchors := map[int]*Anchor{}
+ refs := map[int]*StructRef{}
+ paths := map[int]string{}
+ dec := yaml.NewDecoder(bytes.NewReader(bytes.Join(src, []byte("\n"))))
+ for {
+ var doc yaml.Node
+ if err := dec.Decode(&doc); err != nil {
+ break
+ }
+ walkYAML(&doc, src, anchors, refs, paths, "")
+ }
+ return anchors, refs, paths
+}
+
+func walkYAML(n *yaml.Node, src [][]byte, anchors map[int]*Anchor, refs map[int]*StructRef, paths map[int]string, path string) {
+ if n.Anchor != "" {
+ if line := anchorLine(src, n.Anchor, n.Line); line > 0 {
+ if _, ok := anchors[line]; !ok {
+ anchors[line] = &Anchor{Name: n.Anchor}
+ }
+ }
+ }
+ if n.Kind == yaml.AliasNode && n.Alias != nil {
+ target := anchorLine(src, n.Value, n.Alias.Line)
+ if target > 0 {
+ if _, ok := refs[n.Line]; !ok {
+ refs[n.Line] = &StructRef{Name: n.Value, Line: target, Preview: nodePreview(n.Alias)}
+ }
+ if a, ok := anchors[target]; ok {
+ a.Uses++
+ }
+ }
+ // An alias displays only *name here; the aliased value's lines are
+ // wherever the anchor was declared. Recursing into n.Alias would
+ // emit paths pointing at those other lines, so record this line's
+ // path and stop.
+ recordPath(paths, n.Line, path)
+ return
+ }
+
+ switch n.Kind {
+ case yaml.DocumentNode:
+ for _, c := range n.Content {
+ walkYAML(c, src, anchors, refs, paths, path)
+ }
+ case yaml.MappingNode:
+ for i := 0; i+1 < len(n.Content); i += 2 {
+ k, v := n.Content[i], n.Content[i+1]
+ child := joinPath(path, k.Value)
+ if k.Value == "<<" {
+ // A merge key is not a property of the mapping, and the
+ // rendered text never expands it, so a `<<` segment would
+ // name something the reader cannot see. Keep the parent.
+ child = path
+ }
+ // Walk the key too (anchors may be declared on one), but record
+ // the value's path last so it wins for the shared line.
+ walkYAML(k, src, anchors, refs, paths, path)
+ recordPath(paths, k.Line, child)
+ walkYAML(v, src, anchors, refs, paths, child)
+ }
+ case yaml.SequenceNode:
+ for i, c := range n.Content {
+ child := joinIndex(path, i)
+ recordPath(paths, c.Line, child)
+ walkYAML(c, src, anchors, refs, paths, child)
+ }
+ default:
+ recordPath(paths, n.Line, path)
+ }
+}
+
+// anchorLine locates the line the "&name" token sits on. A node's own Line
+// is where its *value* begins, which for a block mapping is the line after
+// the key the anchor was written on -- so scan back from there for the
+// token itself and point at that instead.
+func anchorLine(src [][]byte, name string, at int) int {
+ tok := []byte("&" + name)
+ if at > len(src) {
+ at = len(src)
+ }
+ for i := at - 1; i >= 0 && i > at-64; i-- {
+ j := bytes.Index(src[i], tok)
+ if j < 0 {
+ continue
+ }
+ if k := j + len(tok); k < len(src[i]) && isAnchorChar(src[i][k]) {
+ continue // &namelonger
+ }
+ return i + 1
+ }
+ return 0
+}
+
+func isAnchorChar(b byte) bool {
+ return b == '-' || b == '_' || b == '.' ||
+ (b >= '0' && b <= '9') || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
+}
+
+// nodePreview renders what an alias actually stands for, short enough to sit
+// in a tooltip. The anchor name is stripped from the copy so the preview
+// reads as the value, not as its declaration.
+func nodePreview(n *yaml.Node) string {
+ c := *n
+ c.Anchor = ""
+ var buf bytes.Buffer
+ enc := yaml.NewEncoder(&buf)
+ enc.SetIndent(2) // yaml.Marshal's default of 4 doesn't match most files
+ if err := enc.Encode(&c); err != nil {
+ return ""
+ }
+ enc.Close()
+ s := strings.TrimRight(buf.String(), "\n")
+ if ls := strings.Split(s, "\n"); len(ls) > 12 {
+ s = strings.Join(append(ls[:12], "…"), "\n")
+ }
+ if len(s) > 480 {
+ s = strings.ToValidUTF8(s[:480], "") + "…"
+ }
+ return s
+}
+
+// withIndentGuides prefixes each line's leading spaces with one guide span
+// per nesting level, so deeply nested JSON/YAML reads like a tree instead
+// of a wall of spaces. Leading whitespace is always plain, unwrapped text
+// at this point (see highlightLines' Text/TextWhitespace handling), so
+// trimming it back off the rendered HTML here is safe.
+func withIndentGuides(lines []template.HTML, unit int) []template.HTML {
+ out := make([]template.HTML, len(lines))
+ for i, l := range lines {
+ s := string(l)
+ n := 0
+ for n < len(s) && s[n] == ' ' {
+ n++
+ }
+ if n < unit {
+ out[i] = l
+ continue
+ }
+ var b strings.Builder
+ levels, rem := n/unit, n%unit
+ for j := 0; j < levels; j++ {
+ b.WriteString(`<span class="ind" style="width:`)
+ b.WriteString(strconv.Itoa(unit))
+ b.WriteString(`ch"></span>`)
+ }
+ if rem > 0 {
+ b.WriteString(strings.Repeat(" ", rem))
+ }
+ b.WriteString(s[n:])
+ out[i] = template.HTML(b.String())
+ }
+ return out
+}
+
+// --------------------------------------------------------------- paths
+
+// A path query names a value the way obj1.property3.items[4].val does.
+// Both the server (here) and static/js/structured.js write the same
+// canonical form so a query can be compared as a string: simple keys are
+// dotted, indices attach directly, and anything else is bracket-quoted.
+var (
+ simplePathKey = regexp.MustCompile(`^[A-Za-z0-9_$-]+$`)
+ pathEscaper = strings.NewReplacer(`\`, `\\`, `"`, `\"`)
+)
+
+func joinPath(parent, key string) string {
+ if simplePathKey.MatchString(key) {
+ if parent == "" {
+ return key
+ }
+ return parent + "." + key
+ }
+ return parent + `["` + pathEscaper.Replace(key) + `"]`
+}
+
+func joinIndex(parent string, i int) string {
+ return parent + "[" + strconv.Itoa(i) + "]"
+}
+
+// recordPath keeps the last path recorded for a line. A line can carry more
+// than one path (JSON/YAML flow style puts several values on one row), and
+// the last one parsed is the deepest -- the one a reader naming a full path
+// is most likely to mean. A container query still reaches its children by
+// the boundary-aware prefix match in structured.js, so dropping the shallow
+// path costs nothing there.
+func recordPath(paths map[int]string, line int, path string) {
+ if path == "" || line <= 0 {
+ return
+ }
+ paths[line] = path
+}
+
+// annotateJSONPaths records each line's canonical value path. It reuses
+// encoding/json's own decoder rather than a second parser, so the offsets it
+// sees are exactly the ones the format defines.
+func annotateJSONPaths(lines []Line, raw []byte) {
+ paths := jsonPaths(raw)
+ for i := range lines {
+ if p, ok := paths[lines[i].N]; ok {
+ lines[i].Path = p
+ }
+ }
+}
+
+func jsonPaths(raw []byte) map[int]string {
+ paths := map[int]string{}
+ lineOf := newLineIndex(raw)
+ dec := json.NewDecoder(bytes.NewReader(raw))
+
+ // walk consumes one JSON value at path. Objects record the path on the
+ // key's line and recurse into the value; arrays record it on each
+ // element's line. A malformed document simply stops -- the parse-error
+ // banner owns reporting that.
+ var walk func(path string)
+ walk = func(path string) {
+ tok, err := dec.Token()
+ if err != nil {
+ return
+ }
+ recordPath(paths, lineOf(dec.InputOffset()), path)
+ d, ok := tok.(json.Delim)
+ if !ok {
+ return
+ }
+ switch d {
+ case '{':
+ for dec.More() {
+ kt, err := dec.Token()
+ if err != nil {
+ return
+ }
+ key, _ := kt.(string)
+ child := joinPath(path, key)
+ recordPath(paths, lineOf(dec.InputOffset()), child)
+ walk(child)
+ }
+ dec.Token() // consume '}'
+ case '[':
+ for i := 0; dec.More(); i++ {
+ walk(joinIndex(path, i))
+ }
+ dec.Token() // consume ']'
+ }
+ }
+ walk("")
+ return paths
+}
+
+// newLineIndex maps a byte offset to a 1-based line number. Newline
+// positions are found once and binary-searched per token, so indexing a
+// whole file stays O(n log n) rather than counting from zero each time.
+func newLineIndex(raw []byte) func(int64) int {
+ var newlines []int64
+ for i, b := range raw {
+ if b == '\n' {
+ newlines = append(newlines, int64(i))
+ }
+ }
+ return func(off int64) int {
+ return sort.Search(len(newlines), func(i int) bool { return newlines[i] >= off }) + 1
+ }
+}
diff --git a/internal/modules/structured_test.go b/internal/modules/structured_test.go
new file mode 100644
index 0000000..37b6277
--- /dev/null
+++ b/internal/modules/structured_test.go
@@ -0,0 +1,274 @@
+package modules
+
+import (
+ "html/template"
+ "strings"
+ "testing"
+)
+
+// lines builds the Line slice annotateStructure expects, mirroring what
+// blobMod produces before the structure pass runs.
+func lines(raw string) []Line {
+ out := []Line{}
+ for i, t := range strings.Split(raw, "\n") {
+ out = append(out, Line{N: i + 1, Text: template.HTML(t)})
+ }
+ return out
+}
+
+const sampleYAML = `defaults: &defaults
+ adapter: postgres
+ pool: 5
+
+development:
+ <<: *defaults
+ database: dev
+
+test:
+ <<: *defaults
+ database: test
+ hosts:
+ - a
+ - b
+`
+
+func TestFoldRangesYAML(t *testing.T) {
+ ls := lines(sampleYAML)
+ annotateStructure(ls, []byte(sampleYAML), "yaml")
+
+ want := map[int]int{
+ 1: 3, // defaults: block ends at "pool: 5", not the blank line after
+ 5: 7, // development:
+ 9: 14, // test:, stopping short of the empty line 15
+ 12: 14, // hosts:
+ }
+ for _, l := range ls {
+ if got, ok := want[l.N]; ok {
+ if l.FoldTo != got {
+ t.Errorf("line %d: FoldTo = %d, want %d", l.N, l.FoldTo, got)
+ }
+ } else if l.FoldTo != 0 {
+ t.Errorf("line %d: FoldTo = %d, want none", l.N, l.FoldTo)
+ }
+ }
+ if ls[1].Depth != 1 {
+ t.Errorf("line 2 depth = %d, want 1", ls[1].Depth)
+ }
+ if ls[13].Depth != 2 {
+ t.Errorf("line 14 depth = %d, want 2", ls[13].Depth)
+ }
+}
+
+func TestKeysAndAnchors(t *testing.T) {
+ ls := lines(sampleYAML)
+ annotateStructure(ls, []byte(sampleYAML), "yaml")
+
+ if ls[0].Key != "defaults" {
+ t.Errorf("line 1 key = %q, want %q", ls[0].Key, "defaults")
+ }
+ if ls[13].Key != "" {
+ t.Errorf("line 14 (sequence item) key = %q, want none", ls[13].Key)
+ }
+
+ // The anchor is reported on the line the &token is written on, not on
+ // the line the mapping it names begins.
+ a := ls[0].Anchor
+ if a == nil {
+ t.Fatal("line 1: no anchor")
+ }
+ if a.Name != "defaults" || a.Uses != 2 {
+ t.Errorf("anchor = %+v, want {defaults 2}", *a)
+ }
+
+ for _, n := range []int{6, 10} {
+ r := ls[n-1].Ref
+ if r == nil {
+ t.Fatalf("line %d: no ref", n)
+ }
+ if r.Name != "defaults" || r.Line != 1 {
+ t.Errorf("line %d ref = %+v, want defaults@1", n, *r)
+ }
+ if !strings.Contains(r.Preview, "adapter: postgres") {
+ t.Errorf("line %d preview = %q, want the resolved mapping", n, r.Preview)
+ }
+ }
+}
+
+// Anchors defined in the second document of a stream are still resolved:
+// yaml.Unmarshal would only ever see the first.
+func TestMultiDocumentAnchors(t *testing.T) {
+ const src = `---
+kind: First
+---
+base: &b
+ x: 1
+copy: *b
+`
+ ls := lines(src)
+ annotateStructure(ls, []byte(src), "yaml")
+
+ if ls[3].Anchor == nil || ls[3].Anchor.Name != "b" {
+ t.Fatalf("line 4 anchor = %+v, want b", ls[3].Anchor)
+ }
+ if r := ls[5].Ref; r == nil || r.Line != 4 {
+ t.Fatalf("line 6 ref = %+v, want b@4", r)
+ }
+}
+
+func TestFoldRangesJSON(t *testing.T) {
+ const src = `{
+ "name": "gsg",
+ "deps": {
+ "a": 1
+ },
+ "list": [
+ 2
+ ]
+}`
+ ls := lines(src)
+ annotateStructure(ls, []byte(src), "json")
+
+ if ls[0].FoldTo != 8 {
+ t.Errorf("line 1 FoldTo = %d, want 8", ls[0].FoldTo)
+ }
+ if ls[2].FoldTo != 4 {
+ t.Errorf("line 3 FoldTo = %d, want 4", ls[2].FoldTo)
+ }
+ if ls[5].FoldTo != 7 {
+ t.Errorf("line 6 FoldTo = %d, want 7", ls[5].FoldTo)
+ }
+ if ls[1].Key != "name" {
+ t.Errorf("line 2 key = %q, want %q", ls[1].Key, "name")
+ }
+ // No anchors outside YAML, and a brace inside a string is not structure.
+ if ls[1].Anchor != nil || ls[1].Ref != nil {
+ t.Error("JSON line carries anchor/ref data")
+ }
+}
+
+// Minified JSON has no indentation to fold on; it must degrade to a plain
+// view rather than producing bogus ranges.
+func TestMinifiedJSONHasNoFolds(t *testing.T) {
+ const src = `{"a":{"b":[1,2]},"c":3}`
+ ls := lines(src)
+ annotateStructure(ls, []byte(src), "json")
+ if ls[0].FoldTo != 0 {
+ t.Errorf("FoldTo = %d, want 0", ls[0].FoldTo)
+ }
+}
+
+// Nested arrays of objects are the shape a path query like
+// obj1.property3.items[4].val is written against.
+func TestJSONPaths(t *testing.T) {
+ const src = `{
+ "obj1": {
+ "property3": {
+ "items": [
+ {
+ "val": 0
+ },
+ {
+ "val": 1
+ }
+ ]
+ }
+ }
+}`
+ ls := lines(src)
+ annotateStructure(ls, []byte(src), "json")
+
+ want := map[int]string{
+ 2: "obj1",
+ 3: "obj1.property3",
+ 4: "obj1.property3.items",
+ 5: "obj1.property3.items[0]",
+ 6: "obj1.property3.items[0].val",
+ 8: "obj1.property3.items[1]",
+ 9: "obj1.property3.items[1].val",
+ }
+ for _, l := range ls {
+ if got, ok := want[l.N]; ok {
+ if l.Path != got {
+ t.Errorf("line %d Path = %q, want %q", l.N, l.Path, got)
+ }
+ } else if l.Path != "" {
+ t.Errorf("line %d Path = %q, want none", l.N, l.Path)
+ }
+ }
+}
+
+// A flow-style row carries several paths at once; the deepest is kept, so
+// a query naming the final member still resolves. The shallow ones stay
+// reachable when a container query prefix-matches the deep path.
+func TestJSONFlowPathKeepsDeepest(t *testing.T) {
+ const src = `{"a": {"b": 1}}`
+ ls := lines(src)
+ annotateStructure(ls, []byte(src), "json")
+ if ls[0].Path != "a.b" {
+ t.Errorf("Path = %q, want a.b", ls[0].Path)
+ }
+}
+
+func TestYAMLPaths(t *testing.T) {
+ ls := lines(sampleYAML)
+ annotateStructure(ls, []byte(sampleYAML), "yaml")
+
+ want := map[int]string{
+ 1: "defaults",
+ 2: "defaults.adapter",
+ 3: "defaults.pool",
+ 5: "development",
+ 6: "development", // merge key keeps the parent path
+ 7: "development.database",
+ 9: "test",
+ 10: "test",
+ 11: "test.database",
+ 12: "test.hosts",
+ 13: "test.hosts[0]",
+ 14: "test.hosts[1]",
+ }
+ for _, l := range ls {
+ if got, ok := want[l.N]; ok {
+ if l.Path != got {
+ t.Errorf("line %d Path = %q, want %q", l.N, l.Path, got)
+ }
+ } else if l.Path != "" {
+ t.Errorf("line %d Path = %q, want none", l.N, l.Path)
+ }
+ }
+}
+
+func TestYAMLSequenceOfMappingsPaths(t *testing.T) {
+ const src = `obj1:
+ property3:
+ items:
+ - val: 0
+ - val: 1
+`
+ ls := lines(src)
+ annotateStructure(ls, []byte(src), "yaml")
+ if ls[3].Path != "obj1.property3.items[0].val" {
+ t.Errorf("line 4 Path = %q", ls[3].Path)
+ }
+ if ls[4].Path != "obj1.property3.items[1].val" {
+ t.Errorf("line 5 Path = %q", ls[4].Path)
+ }
+}
+
+func TestPathKeyQuoting(t *testing.T) {
+ tests := []struct{ parent, key, want string }{
+ {"", "obj1", "obj1"},
+ {"obj1", "items", "obj1.items"},
+ {"obj1", "a.b", `obj1["a.b"]`},
+ {"", "weird key", `["weird key"]`},
+ {"obj1", `quo"te`, `obj1["quo\"te"]`},
+ }
+ for _, tc := range tests {
+ if got := joinPath(tc.parent, tc.key); got != tc.want {
+ t.Errorf("joinPath(%q, %q) = %q, want %q", tc.parent, tc.key, got, tc.want)
+ }
+ }
+ if got := joinIndex("obj1.items", 4); got != "obj1.items[4]" {
+ t.Errorf("joinIndex = %q", got)
+ }
+}
diff --git a/internal/notices/admin.html b/internal/notices/admin.html
new file mode 100644
index 0000000..e67b63b
--- /dev/null
+++ b/internal/notices/admin.html
@@ -0,0 +1,64 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Notices &middot; {{ .Site }}</title>
+<link rel="stylesheet" href="/static/css/base.css">
+<link rel="stylesheet" href="/static/css/admin.css">
+<script src="/static/js/admin.js" defer></script>
+</head>
+<body>
+<main class="wrap">
+ <header class="hero">
+ <h1 class="page-title">Notices</h1>
+ <p class="page-subtitle">Global notices render above every repository's.</p>
+ </header>
+
+ {{ if .Saved }}<div class="notice notice-fix">Saved.</div>{{ end }}
+ {{ if .Error }}<div class="notice notice-critical">
+ <strong class="notice-title">Not saved</strong>
+ <pre class="notice-body">{{ .Error }}</pre>
+ </div>{{ end }}
+
+ <form method="post" action="/admin/notices" class="admin-form">
+ <input type="hidden" name="csrf" value="{{ .CSRF }}">
+ <div class="table-wrap">
+ <table class="table admin-table">
+ <thead>
+ <tr>
+ <th>Scope</th><th>Type</th><th>Title</th><th>Body</th>
+ <th>Link</th><th>Link text</th><th>Remove</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{ range $i, $row := .Rows }}
+ <tr>
+ <td><select name="scope">
+ <option value="global"{{ if eq $row.Scope "global" }} selected{{ end }}>global</option>
+ {{ range $.Repos }}<option value="{{ . }}"{{ if eq $row.Scope . }} selected{{ end }}>{{ . }}</option>{{ end }}
+ </select></td>
+ <td><select name="type">
+ <option value="critical"{{ if eq $row.Type "critical" }} selected{{ end }}>critical</option>
+ <option value="warning"{{ if eq $row.Type "warning" }} selected{{ end }}>warning</option>
+ <option value="info"{{ if eq $row.Type "info" }} selected{{ end }}>info</option>
+ <option value="fix"{{ if eq $row.Type "fix" }} selected{{ end }}>fix</option>
+ </select></td>
+ <td><input name="title" value="{{ $row.Title }}"></td>
+ <td><textarea name="body" rows="2">{{ $row.Body }}</textarea></td>
+ <td><input name="link" value="{{ $row.Link }}"></td>
+ <td><input name="link_text" value="{{ $row.LinkText }}"></td>
+ <td class="admin-del"><input type="checkbox" name="del" value="{{ $i }}"></td>
+ </tr>
+ {{ end }}
+ </tbody>
+ </table>
+ </div>
+ <p class="admin-actions">
+ <button class="chip admin-save" type="submit">Save notices</button>
+ <button class="chip js-only" type="button" id="add-notice">Add notice</button>
+ </p>
+ </form>
+</main>
+</body>
+</html>
diff --git a/internal/notices/handler.go b/internal/notices/handler.go
new file mode 100644
index 0000000..e9e65ca
--- /dev/null
+++ b/internal/notices/handler.go
@@ -0,0 +1,316 @@
+package notices
+
+import (
+ "crypto/rand"
+ "crypto/subtle"
+ "embed"
+ "encoding/hex"
+ "fmt"
+ "html/template"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+//go:embed admin.html
+var adminFS embed.FS
+
+// HandlerConfig is everything the admin surface needs that this package
+// cannot discover for itself. Repos is the visible repository list, used to
+// populate the scope dropdown.
+type HandlerConfig struct {
+ Store *Store
+ Repos []string
+ SiteName string
+ UserHeader string // e.g. "X-Authentik-Username"; logging only, never authz
+
+ // GroupHeader and RequiredGroup gate the admin surface itself, on top of
+ // whatever authentication the reverse proxy already did. Authentik's
+ // forward_auth can be shared by every /admin/* route (so several people
+ // can log in for share links), while only members of RequiredGroup may
+ // reach notices editing. RequiredGroup empty disables the check -- every
+ // request that reaches this handler is authorized, i.e. today's
+ // behavior. GroupHeader defaults to "X-Authentik-Groups", which
+ // Authentik sends as a "|"-delimited list of the user's groups.
+ GroupHeader string
+ RequiredGroup string
+
+ Logger *slog.Logger
+}
+
+type adminRow struct {
+ Scope, Type, Title, Body, Link, LinkText string
+}
+
+type adminView struct {
+ Site, CSRF, Error string
+ Saved bool
+ Repos []string
+ Rows []adminRow
+}
+
+const (
+ csrfCookie = "gsg_admin_csrf"
+ maxFormBytes = 1 << 20
+)
+
+type handler struct {
+ cfg HandlerConfig
+ tmpl *template.Template
+}
+
+// NewHandler builds the admin mux. It is deliberately separate from the
+// read-only server: the only write path in gsg lives here, and it writes a
+// config file, never a repository.
+func NewHandler(cfg HandlerConfig) (http.Handler, error) {
+ if cfg.Store == nil {
+ return nil, fmt.Errorf("notices admin: no store")
+ }
+ if cfg.Logger == nil {
+ cfg.Logger = slog.Default()
+ }
+ if cfg.UserHeader == "" {
+ cfg.UserHeader = "X-Authentik-Username"
+ }
+ if cfg.GroupHeader == "" {
+ cfg.GroupHeader = "X-Authentik-Groups"
+ }
+ tmpl, err := template.ParseFS(adminFS, "admin.html")
+ if err != nil {
+ return nil, fmt.Errorf("notices admin template: %w", err)
+ }
+ h := &handler{cfg: cfg, tmpl: tmpl}
+
+ mux := http.NewServeMux()
+ // Method-qualified patterns: anything else (PUT, DELETE, or a stray
+ // GET/POST to another path) is refused by the mux, not silently handled.
+ mux.HandleFunc("GET /admin/{$}", h.index)
+ mux.HandleFunc("GET /admin/notices", h.get)
+ mux.HandleFunc("POST /admin/notices", h.post)
+
+ if cfg.RequiredGroup == "" {
+ return mux, nil
+ }
+ return h.requireGroup(mux), nil
+}
+
+// requireGroup wraps next so that every admin route first checks
+// RequiredGroup, before any handler runs (including the CSRF cookie setup
+// in get/index -- a rejected caller gets no state from this handler at
+// all). Authentication already happened upstream at the reverse proxy; this
+// is authorization on top of it, using a group claim the proxy forwards, so
+// that reaching the (shared) /admin/* forward_auth is not by itself enough
+// to edit notices.
+func (h *handler) requireGroup(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !h.inRequiredGroup(r) {
+ h.cfg.Logger.Warn("notices admin: group check rejected",
+ "user", r.Header.Get(h.cfg.UserHeader),
+ "groups", r.Header.Get(h.cfg.GroupHeader))
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+// inRequiredGroup reports whether r carries cfg.RequiredGroup in its
+// GroupHeader. Authentik delimits multiple groups with "|"; entries are
+// trimmed so incidental whitespace around the delimiter doesn't cause a
+// false negative.
+func (h *handler) inRequiredGroup(r *http.Request) bool {
+ for _, g := range strings.Split(r.Header.Get(h.cfg.GroupHeader), "|") {
+ if strings.TrimSpace(g) == h.cfg.RequiredGroup {
+ return true
+ }
+ }
+ return false
+}
+
+func (h *handler) index(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "/admin/notices", http.StatusSeeOther)
+}
+
+func (h *handler) get(w http.ResponseWriter, r *http.Request) {
+ h.secure(w)
+ tok, err := ensureCSRF(w, r)
+ if err != nil {
+ http.Error(w, "cannot start session", http.StatusInternalServerError)
+ return
+ }
+ h.render(w, http.StatusOK, tok, "", r.URL.Query().Get("saved") == "1")
+}
+
+func (h *handler) post(w http.ResponseWriter, r *http.Request) {
+ h.secure(w)
+
+ // Header checks first: they need no body. Then bound and parse the
+ // body, then compare the CSRF token against the cookie. Parsing before
+ // the token check is safe because the read is capped.
+ if !sameOrigin(r) {
+ h.cfg.Logger.Warn("notices admin: origin rejected",
+ "host", r.Host, "origin", r.Header.Get("Origin"),
+ "sec-fetch-site", r.Header.Get("Sec-Fetch-Site"))
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "bad form", http.StatusBadRequest)
+ return
+ }
+ if !checkCSRF(r) {
+ h.cfg.Logger.Warn("notices admin: csrf rejected",
+ "user", r.Header.Get(h.cfg.UserHeader))
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return
+ }
+
+ doc, err := docFromForm(r)
+ if err == nil {
+ err = h.cfg.Store.Set(doc)
+ }
+ if err != nil {
+ tok, _ := ensureCSRF(w, r)
+ h.render(w, http.StatusBadRequest, tok, err.Error(), false)
+ return
+ }
+
+ h.cfg.Logger.Info("notices updated",
+ "user", r.Header.Get(h.cfg.UserHeader),
+ "global", len(doc.Global), "repos", len(doc.Repos))
+ http.Redirect(w, r, "/admin/notices?saved=1", http.StatusSeeOther)
+}
+
+// secure sets the admin response headers. style-src 'self' only: the page
+// ships no inline script or style, so the CSP can stay tight.
+func (h *handler) secure(w http.ResponseWriter) {
+ hdr := w.Header()
+ hdr.Set("X-Content-Type-Options", "nosniff")
+ hdr.Set("X-Frame-Options", "DENY")
+ hdr.Set("Content-Security-Policy",
+ "default-src 'none'; style-src 'self'; script-src 'self'; img-src 'self'; form-action 'self'; base-uri 'none'")
+}
+
+func (h *handler) render(w http.ResponseWriter, status int, csrf, errMsg string, saved bool) {
+ snap := h.cfg.Store.Snapshot()
+ rows := make([]adminRow, 0, len(snap.Doc.Global)+2)
+ for _, n := range snap.Doc.Global {
+ rows = append(rows, adminRow{"global", n.Type, n.Title, n.Body, n.Link, n.LinkText})
+ }
+ slugs := make([]string, 0, len(snap.Doc.Repos))
+ for slug := range snap.Doc.Repos {
+ slugs = append(slugs, slug)
+ }
+ sort.Strings(slugs)
+ for _, slug := range slugs {
+ for _, n := range snap.Doc.Repos[slug] {
+ rows = append(rows, adminRow{slug, n.Type, n.Title, n.Body, n.Link, n.LinkText})
+ }
+ }
+ // One blank row is the no-JS "add" affordance: fill it and save. The
+ // admin.js helper clones it for the Add button.
+ rows = append(rows, adminRow{"global", TypeInfo, "", "", "", ""})
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ if err := h.tmpl.Execute(w, adminView{
+ Site: h.cfg.SiteName, CSRF: csrf, Error: errMsg, Saved: saved,
+ Repos: h.cfg.Repos, Rows: rows,
+ }); err != nil {
+ h.cfg.Logger.Error("notices admin render", "err", err)
+ }
+}
+
+// docFromForm rebuilds the document from the rendered row list. Rows are
+// parallel slices (scope[], type[], ...); deletion is a sparse del[] list
+// of row indices. A row with every field blank is treated as unused -- so
+// clearing a row removes it. PostForm, not Form, so a query string cannot
+// inject or override fields.
+func docFromForm(r *http.Request) (Document, error) {
+ form := r.PostForm
+ at := func(name string, i int) string {
+ if v := form[name]; i < len(v) {
+ return v[i]
+ }
+ return ""
+ }
+ del := map[int]bool{}
+ for _, s := range form["del"] {
+ if i, err := strconv.Atoi(s); err == nil {
+ del[i] = true
+ }
+ }
+ n := len(form["scope"])
+ doc := Document{Repos: map[string][]Notice{}}
+ for i := 0; i < n; i++ {
+ if del[i] {
+ continue
+ }
+ nt := Notice{
+ Type: at("type", i), Title: at("title", i), Body: at("body", i),
+ Link: at("link", i), LinkText: at("link_text", i),
+ }
+ if nt.Type == "" && nt.Title == "" && nt.Body == "" && nt.Link == "" {
+ continue
+ }
+ scope := at("scope", i)
+ if scope == "" || scope == "global" {
+ doc.Global = append(doc.Global, nt)
+ } else {
+ doc.Repos[scope] = append(doc.Repos[scope], nt)
+ }
+ }
+ return doc, nil // Store.Set validates
+}
+
+func ensureCSRF(w http.ResponseWriter, r *http.Request) (string, error) {
+ if c, err := r.Cookie(csrfCookie); err == nil && len(c.Value) >= 32 {
+ return c.Value, nil
+ }
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ tok := hex.EncodeToString(b)
+ http.SetCookie(w, &http.Cookie{
+ Name: csrfCookie, Value: tok, Path: "/admin",
+ HttpOnly: true, Secure: isHTTPS(r), SameSite: http.SameSiteStrictMode,
+ MaxAge: 86400,
+ })
+ return tok, nil
+}
+
+func checkCSRF(r *http.Request) bool {
+ c, err := r.Cookie(csrfCookie)
+ if err != nil {
+ return false
+ }
+ got := r.PostForm.Get("csrf")
+ return subtle.ConstantTimeCompare([]byte(c.Value), []byte(got)) == 1
+}
+
+func isHTTPS(r *http.Request) bool {
+ return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
+}
+
+// sameOrigin blocks a cross-site POST even if the auth cookie were sent.
+// Forward auth proves the browser holds a session; it does not prove the
+// request came from our own page.
+func sameOrigin(r *http.Request) bool {
+ switch r.Header.Get("Sec-Fetch-Site") {
+ case "", "same-origin", "none":
+ default:
+ return false
+ }
+ if o := r.Header.Get("Origin"); o != "" {
+ u, err := url.Parse(o)
+ if err != nil || !strings.EqualFold(u.Host, r.Host) {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/notices/handler_test.go b/internal/notices/handler_test.go
new file mode 100644
index 0000000..e076a20
--- /dev/null
+++ b/internal/notices/handler_test.go
@@ -0,0 +1,114 @@
+package notices
+
+import (
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "testing"
+)
+
+func testHandler(t *testing.T, cfg HandlerConfig) http.Handler {
+ t.Helper()
+ store, err := Open(filepath.Join(t.TempDir(), "notices.yaml"))
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ t.Cleanup(store.Close)
+ cfg.Store = store
+ if cfg.Logger == nil {
+ cfg.Logger = slog.Default()
+ }
+ h, err := NewHandler(cfg)
+ if err != nil {
+ t.Fatalf("NewHandler: %v", err)
+ }
+ return h
+}
+
+// Without RequiredGroup, the handler behaves exactly as it always did:
+// reaching it at all is enough, regardless of any group header.
+func TestHandlerNoGroupGateByDefault(t *testing.T) {
+ h := testHandler(t, HandlerConfig{})
+
+ req := httptest.NewRequest("GET", "/admin/notices", nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+}
+
+// With RequiredGroup set, a caller whose GroupHeader doesn't list it is
+// refused before the handler does anything else -- no CSRF cookie, no page.
+func TestHandlerRejectsMissingGroup(t *testing.T) {
+ h := testHandler(t, HandlerConfig{RequiredGroup: "gsg-admins"})
+
+ cases := []struct {
+ name string
+ groups string
+ }{
+ {"no header", ""},
+ {"different group", "some-other-group"},
+ {"prefix collision", "gsg-admins-extra"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ req := httptest.NewRequest("GET", "/admin/notices", nil)
+ if tc.groups != "" {
+ req.Header.Set("X-Authentik-Groups", tc.groups)
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
+ }
+ if rec.Result().Cookies() != nil && len(rec.Result().Cookies()) != 0 {
+ t.Errorf("rejected request set cookies: %v", rec.Result().Cookies())
+ }
+ })
+ }
+}
+
+// A member -- anywhere in the "|"-delimited list, with incidental
+// whitespace -- is let through.
+func TestHandlerAllowsMemberGroup(t *testing.T) {
+ h := testHandler(t, HandlerConfig{RequiredGroup: "gsg-admins"})
+
+ cases := []string{
+ "gsg-admins",
+ "other-group|gsg-admins",
+ "gsg-admins|other-group",
+ "other-group| gsg-admins |another",
+ }
+ for _, groups := range cases {
+ req := httptest.NewRequest("GET", "/admin/notices", nil)
+ req.Header.Set("X-Authentik-Groups", groups)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Errorf("groups=%q: status = %d, want %d", groups, rec.Code, http.StatusOK)
+ }
+ }
+}
+
+// A custom GroupHeader is honored instead of the Authentik default.
+func TestHandlerCustomGroupHeader(t *testing.T) {
+ h := testHandler(t, HandlerConfig{RequiredGroup: "gsg-admins", GroupHeader: "X-Custom-Groups"})
+
+ req := httptest.NewRequest("GET", "/admin/notices", nil)
+ req.Header.Set("X-Authentik-Groups", "gsg-admins") // wrong header, should not count
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("status = %d, want %d (wrong header should not authorize)", rec.Code, http.StatusForbidden)
+ }
+
+ req = httptest.NewRequest("GET", "/admin/notices", nil)
+ req.Header.Set("X-Custom-Groups", "gsg-admins")
+ rec = httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+}
diff --git a/internal/notices/notices.go b/internal/notices/notices.go
new file mode 100644
index 0000000..c855f67
--- /dev/null
+++ b/internal/notices/notices.go
@@ -0,0 +1,221 @@
+// Package notices holds operator-authored banners shown at the top of every
+// page: a site-wide list plus a list per repository. Content lives in one
+// YAML file that is watched for changes, so editing it never needs a
+// rebuild, and the optional admin listener can rewrite it.
+//
+// This package deliberately does not import internal/core: it is plain
+// config, and keeping it dependency-free lets both the module and the admin
+// handler reach it without an import cycle.
+package notices
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "net/url"
+ "sort"
+ "strings"
+ "unicode/utf8"
+
+ "gopkg.in/yaml.v3"
+)
+
+// Severities. The value doubles as the CSS class suffix, so it is validated
+// on every load and save; a typo must not become an unstyled banner.
+const (
+ TypeCritical = "critical"
+ TypeWarning = "warning"
+ TypeInfo = "info"
+ TypeFix = "fix"
+)
+
+// Notice is one banner. Link and LinkText are optional; an empty Link
+// renders no anchor.
+type Notice struct {
+ Type string `yaml:"type"`
+ Title string `yaml:"title,omitempty"`
+ Body string `yaml:"body,omitempty"`
+ Link string `yaml:"link,omitempty"`
+ LinkText string `yaml:"link_text,omitempty"`
+}
+
+// Document is the on-disk shape. Global renders above every repo's list.
+type Document struct {
+ Global []Notice `yaml:"global,omitempty"`
+ Repos map[string][]Notice `yaml:"repos,omitempty"`
+}
+
+// Snapshot is an immutable document plus the content hash identifying it.
+// They are carried together so a reader can never see data and revision
+// disagree.
+type Snapshot struct {
+ Doc Document
+ Rev string
+}
+
+const (
+ maxNotices = 200
+ maxPerRepo = 50
+ maxTitle = 200
+ maxBody = 4000
+ maxLink = 2048
+ maxLinkText = 120
+)
+
+// typeAliases make hand-editing forgiving without letting an invalid type
+// reach the template.
+var typeAliases = map[string]string{
+ "critical": TypeCritical,
+ "warning": TypeWarning,
+ "warn": TypeWarning,
+ "info": TypeInfo,
+ "informational": TypeInfo,
+ "information": TypeInfo,
+ "fix": TypeFix,
+ "fixed": TypeFix,
+}
+
+func canonicalType(s string) (string, bool) {
+ t, ok := typeAliases[strings.ToLower(strings.TrimSpace(s))]
+ return t, ok
+}
+
+// validSlug mirrors a repository directory name: one path element, no
+// separators. It keeps the repos map keys from becoming junk.
+func validSlug(s string) bool {
+ if s == "" || s == "." || s == ".." {
+ return false
+ }
+ for _, r := range s {
+ switch {
+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
+ r == '.', r == '_', r == '-':
+ default:
+ return false
+ }
+ }
+ return true
+}
+
+// checkLink allows a site-relative path and the http/https/mailto schemes.
+// html/template would neutralise a javascript: URL anyway, but rejecting it
+// here turns a silent #ZgotmplZ into a visible form error.
+func checkLink(raw string) error {
+ if strings.HasPrefix(raw, "//") || strings.ContainsAny(raw, "\\\x00\r\n") {
+ return fmt.Errorf("link %q is not allowed", raw)
+ }
+ u, err := url.Parse(raw)
+ if err != nil {
+ return fmt.Errorf("link %q: %w", raw, err)
+ }
+ if !u.IsAbs() {
+ return nil // relative path
+ }
+ switch strings.ToLower(u.Scheme) {
+ case "http", "https", "mailto":
+ return nil
+ default:
+ return fmt.Errorf("link %q: scheme %q is not allowed", raw, u.Scheme)
+ }
+}
+
+// validate trims and canonicalises in place and returns every problem it
+// found, so the admin form can show all of them at once.
+func validate(doc *Document) error {
+ var errs []error
+ total := 0
+ check := func(list []Notice, where string) []Notice {
+ for i := range list {
+ n := &list[i]
+ total++
+ if t, ok := canonicalType(n.Type); ok {
+ n.Type = t
+ } else {
+ errs = append(errs, fmt.Errorf("%s[%d]: type %q must be one of critical, warning, info, fix", where, i, n.Type))
+ }
+ n.Title = strings.TrimSpace(n.Title)
+ n.Body = strings.TrimRight(n.Body, "\n")
+ n.Link = strings.TrimSpace(n.Link)
+ n.LinkText = strings.TrimSpace(n.LinkText)
+ if n.Title == "" && n.Body == "" {
+ errs = append(errs, fmt.Errorf("%s[%d]: needs a title or a body", where, i))
+ }
+ switch {
+ case utf8.RuneCountInString(n.Title) > maxTitle:
+ errs = append(errs, fmt.Errorf("%s[%d]: title longer than %d characters", where, i, maxTitle))
+ case utf8.RuneCountInString(n.Body) > maxBody:
+ errs = append(errs, fmt.Errorf("%s[%d]: body longer than %d characters", where, i, maxBody))
+ case utf8.RuneCountInString(n.LinkText) > maxLinkText:
+ errs = append(errs, fmt.Errorf("%s[%d]: link text longer than %d characters", where, i, maxLinkText))
+ }
+ if len(n.Link) > maxLink {
+ errs = append(errs, fmt.Errorf("%s[%d]: link longer than %d bytes", where, i, maxLink))
+ }
+ if n.Link != "" {
+ if err := checkLink(n.Link); err != nil {
+ errs = append(errs, fmt.Errorf("%s[%d]: %w", where, i, err))
+ }
+ }
+ }
+ return list
+ }
+
+ doc.Global = check(doc.Global, "global")
+ slugs := make([]string, 0, len(doc.Repos))
+ for slug := range doc.Repos {
+ slugs = append(slugs, slug)
+ }
+ sort.Strings(slugs)
+ for _, slug := range slugs {
+ if !validSlug(slug) {
+ errs = append(errs, fmt.Errorf("repos: %q is not a valid repository slug", slug))
+ continue
+ }
+ if len(doc.Repos[slug]) > maxPerRepo {
+ errs = append(errs, fmt.Errorf("repos.%s: more than %d notices", slug, maxPerRepo))
+ }
+ doc.Repos[slug] = check(doc.Repos[slug], "repos."+slug)
+ }
+ if total > maxNotices {
+ errs = append(errs, fmt.Errorf("more than %d notices in total", maxNotices))
+ }
+ return errors.Join(errs...)
+}
+
+// parse decodes strictly: an unknown key is a typo the operator should see,
+// not something silently ignored. An empty file is an empty document.
+func parse(b []byte) (Document, error) {
+ var doc Document
+ dec := yaml.NewDecoder(bytes.NewReader(b))
+ dec.KnownFields(true)
+ if err := dec.Decode(&doc); err != nil {
+ if errors.Is(err, io.EOF) {
+ return Document{Repos: map[string][]Notice{}}, nil
+ }
+ return Document{}, fmt.Errorf("notices: %w", err)
+ }
+ if err := validate(&doc); err != nil {
+ return Document{}, err
+ }
+ if doc.Repos == nil {
+ doc.Repos = map[string][]Notice{}
+ }
+ return doc, nil
+}
+
+// marshal assumes the document has already been through validate, which is
+// what canonicalises severity aliases and trims fields. Both callers (parse
+// and Store.Set) validate first.
+func marshal(doc Document) ([]byte, error) {
+ var buf bytes.Buffer
+ enc := yaml.NewEncoder(&buf)
+ enc.SetIndent(2)
+ if err := enc.Encode(doc); err != nil {
+ return nil, err
+ }
+ if err := enc.Close(); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
diff --git a/internal/notices/store.go b/internal/notices/store.go
new file mode 100644
index 0000000..bfb04bc
--- /dev/null
+++ b/internal/notices/store.go
@@ -0,0 +1,230 @@
+package notices
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/fsnotify/fsnotify"
+)
+
+// Store owns one notices file. Reads are lock-free (an atomic snapshot);
+// writes and the watcher's reloads serialise on mu so a web save and an
+// external edit can never interleave.
+type Store struct {
+ path string
+ dir string
+
+ mu sync.Mutex
+ cur atomic.Pointer[Snapshot]
+
+ fsw *fsnotify.Watcher
+ done chan struct{}
+ closeOnce sync.Once
+}
+
+// Open loads the file (an absent file is an empty document) and starts
+// watching its directory. A parse error is fatal at startup so a typo is
+// visible immediately; later reload errors keep the last good snapshot.
+func Open(path string) (*Store, error) {
+ abs, err := filepath.Abs(path)
+ if err != nil {
+ return nil, err
+ }
+ dir := filepath.Dir(abs)
+ if fi, err := os.Stat(dir); err != nil || !fi.IsDir() {
+ return nil, fmt.Errorf("notices: directory %s does not exist", dir)
+ }
+
+ s := &Store{path: abs, dir: dir, done: make(chan struct{})}
+ snap, err := readSnapshot(abs)
+ if err != nil {
+ return nil, err
+ }
+ s.cur.Store(snap)
+
+ fsw, err := fsnotify.NewWatcher()
+ if err != nil {
+ return nil, fmt.Errorf("notices watch: %w", err)
+ }
+ if err := fsw.Add(dir); err != nil {
+ fsw.Close()
+ return nil, fmt.Errorf("notices watch: %w", err)
+ }
+ s.fsw = fsw
+ go s.watch()
+ return s, nil
+}
+
+// readSnapshot reads and hashes the file. A missing file is an empty
+// document, not an error -- the admin UI creates it on first save.
+func readSnapshot(path string) (*Snapshot, error) {
+ b, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ b = nil
+ } else {
+ return nil, fmt.Errorf("notices: %w", err)
+ }
+ }
+ doc, err := parse(b)
+ if err != nil {
+ return nil, err
+ }
+ return &Snapshot{Doc: doc, Rev: hash(b)}, nil
+}
+
+func hash(b []byte) string {
+ sum := sha256.Sum256(b)
+ return hex.EncodeToString(sum[:])[:16]
+}
+
+// Snapshot is the current document. The returned value shares its backing
+// slices and map with the stored one; callers must not mutate it.
+func (s *Store) Snapshot() Snapshot {
+ p := s.cur.Load()
+ if p == nil {
+ return Snapshot{}
+ }
+ return *p
+}
+
+// Revision is the content hash. O(1); safe to call on every request.
+func (s *Store) Revision() string {
+ p := s.cur.Load()
+ if p == nil {
+ return ""
+ }
+ return p.Rev
+}
+
+// Set validates, writes atomically (temp file in the same directory, fsync,
+// rename, 0600), and publishes the new snapshot immediately, so the next
+// request sees it without waiting for the watcher.
+func (s *Store) Set(doc Document) error {
+ if err := validate(&doc); err != nil {
+ return err
+ }
+ b, err := marshal(doc)
+ if err != nil {
+ return err
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ tmp, err := os.CreateTemp(s.dir, ".notices-*.tmp")
+ if err != nil {
+ return fmt.Errorf("notices: %w", err)
+ }
+ tmpName := tmp.Name()
+ defer os.Remove(tmpName) // no-op once the rename succeeds
+ if err := tmp.Chmod(0o600); err != nil {
+ tmp.Close()
+ return err
+ }
+ if _, err := tmp.Write(b); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Sync(); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ if err := os.Rename(tmpName, s.path); err != nil {
+ return err
+ }
+
+ s.cur.Store(&Snapshot{Doc: doc, Rev: hash(b)})
+ return nil
+}
+
+// watch is the only goroutine that touches the debounce timer, so no lock
+// is needed for it. Events for our own ".notices-*.tmp" files are ignored;
+// only the real path triggers a reload.
+func (s *Store) watch() {
+ const debounce = 200 * time.Millisecond
+ var timer *time.Timer
+ var timerC <-chan time.Time
+
+ for {
+ select {
+ case ev, ok := <-s.fsw.Events:
+ if !ok {
+ return
+ }
+ if filepath.Clean(ev.Name) != s.path {
+ continue
+ }
+ if timer == nil {
+ timer = time.NewTimer(debounce)
+ } else {
+ if !timer.Stop() {
+ select {
+ case <-timer.C:
+ default:
+ }
+ }
+ timer.Reset(debounce)
+ }
+ timerC = timer.C
+ case <-timerC:
+ timerC = nil
+ s.reload()
+ case err, ok := <-s.fsw.Errors:
+ if !ok {
+ return
+ }
+ slog.Warn("notices watch error", "err", err)
+ case <-s.done:
+ if timer != nil {
+ timer.Stop()
+ }
+ return
+ }
+ }
+}
+
+// reload re-reads the file and swaps the snapshot only when the content
+// hash moved. A parse error keeps the previous snapshot, so a half-written
+// or mistyped file never blanks the banners.
+func (s *Store) reload() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ snap, err := readSnapshot(s.path)
+ if err != nil {
+ slog.Warn("notices: keeping previous snapshot", "err", err)
+ return
+ }
+ if cur := s.cur.Load(); cur != nil && cur.Rev == snap.Rev {
+ return
+ }
+ s.cur.Store(snap)
+ slog.Info("notices reloaded", "revision", snap.Rev)
+}
+
+func (s *Store) Close() {
+ s.closeOnce.Do(func() {
+ close(s.done)
+ if s.fsw != nil {
+ s.fsw.Close()
+ }
+ })
+}
+
+// def is the process-wide store the `notices` module reads. A nil default
+// means the feature is off; the module returns an empty list, not an error.
+var def atomic.Pointer[Store]
+
+func SetDefault(s *Store) { def.Store(s) }
+func Default() *Store { return def.Load() }
diff --git a/internal/notices/store_test.go b/internal/notices/store_test.go
new file mode 100644
index 0000000..48ed6b7
--- /dev/null
+++ b/internal/notices/store_test.go
@@ -0,0 +1,150 @@
+package notices
+
+import (
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func TestParseRejectsUnknownField(t *testing.T) {
+ _, err := parse([]byte("global:\n - type: info\n subject: hi\n"))
+ if err == nil {
+ t.Fatal("unknown field accepted")
+ }
+ if !strings.Contains(err.Error(), "subject") {
+ t.Fatalf("error does not name the field: %v", err)
+ }
+}
+
+func TestParseRejectsBadType(t *testing.T) {
+ _, err := parse([]byte("global:\n - type: critcal\n title: hi\n"))
+ if err == nil || !strings.Contains(err.Error(), "critcal") {
+ t.Fatalf("bad type: err = %v", err)
+ }
+}
+
+func TestParseEmpty(t *testing.T) {
+ for _, in := range [][]byte{nil, []byte(""), []byte("{}\n")} {
+ doc, err := parse(in)
+ if err != nil {
+ t.Fatalf("parse(%q): %v", in, err)
+ }
+ if len(doc.Global) != 0 || len(doc.Repos) != 0 {
+ t.Fatalf("parse(%q) = %+v, want empty", in, doc)
+ }
+ if doc.Repos == nil {
+ t.Fatalf("parse(%q): Repos is nil", in)
+ }
+ }
+}
+
+func TestTypeAliasesAndTrimming(t *testing.T) {
+ doc, err := parse([]byte("global:\n - type: informational\n title: ' spaced '\n"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ n := doc.Global[0]
+ if n.Type != TypeInfo || n.Title != "spaced" {
+ t.Fatalf("got %+v", n)
+ }
+}
+
+func TestLinkSchemes(t *testing.T) {
+ cases := []struct {
+ link string
+ ok bool
+ }{
+ {"/status", true},
+ {"https://example.org/a", true},
+ {"mailto:ops@example.org", true},
+ {"javascript:alert(1)", false},
+ {"//evil.example", false},
+ {"ftp://example.org", false},
+ }
+ for _, tc := range cases {
+ doc := Document{Global: []Notice{{Type: TypeInfo, Title: "x", Link: tc.link}}}
+ err := validate(&doc)
+ if tc.ok && err != nil {
+ t.Errorf("link %q rejected: %v", tc.link, err)
+ }
+ if !tc.ok && err == nil {
+ t.Errorf("link %q accepted", tc.link)
+ }
+ }
+}
+
+func TestSetRoundTripAndRevision(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "notices.yaml")
+ st, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(st.Close)
+
+ if st.Revision() == "" {
+ t.Fatal("empty store has no revision")
+ }
+ doc := Document{
+ Global: []Notice{{Type: TypeCritical, Title: "Outage", Body: "line\nline"}},
+ Repos: map[string][]Notice{"demo": {{Type: "informational", Title: "Repo"}}},
+ }
+ if err := st.Set(doc); err != nil {
+ t.Fatal(err)
+ }
+ // informational must have been canonicalised in the published snapshot.
+ if got := st.Snapshot().Doc.Repos["demo"][0].Type; got != TypeInfo {
+ t.Fatalf("published type = %q, want %q", got, TypeInfo)
+ }
+ rev := st.Revision()
+
+ fi, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if perm := fi.Mode().Perm(); perm != 0o600 {
+ t.Errorf("file mode = %o, want 600", perm)
+ }
+
+ again, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(again.Close)
+ if !reflect.DeepEqual(st.Snapshot().Doc, again.Snapshot().Doc) {
+ t.Fatalf("round trip mismatch:\n%+v\n%+v", st.Snapshot().Doc, again.Snapshot().Doc)
+ }
+
+ // A no-op save is byte-identical and keeps the revision.
+ if err := st.Set(doc); err != nil {
+ t.Fatal(err)
+ }
+ if st.Revision() != rev {
+ t.Fatalf("no-op save changed revision %q -> %q", rev, st.Revision())
+ }
+
+ // A content change moves it.
+ doc.Global[0].Body = "different"
+ if err := st.Set(doc); err != nil {
+ t.Fatal(err)
+ }
+ if st.Revision() == rev {
+ t.Fatal("content change kept the same revision")
+ }
+}
+
+func TestOpenMissingFileAndBadDir(t *testing.T) {
+ dir := t.TempDir()
+ st, err := Open(filepath.Join(dir, "notices.yaml"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(st.Close)
+ if len(st.Snapshot().Doc.Global) != 0 {
+ t.Fatal("missing file is not empty")
+ }
+ if _, err := Open(filepath.Join(dir, "nope", "notices.yaml")); err == nil {
+ t.Fatal("missing directory accepted")
+ }
+}
diff --git a/internal/session/session.go b/internal/session/session.go
new file mode 100644
index 0000000..d78a27f
--- /dev/null
+++ b/internal/session/session.go
@@ -0,0 +1,280 @@
+// Package session mints and verifies the signed, stateless cookies gsg
+// uses to recognize a logged-in browser. There is no server-side session
+// store: everything needed to verify a cookie travels inside it, HMAC-signed
+// against a secret that lives only in this process's memory. A restart
+// invalidates every outstanding session -- an accepted trade-off for a
+// login this lightweight.
+//
+// The master secret is never used to sign anything directly. Each purpose
+// (today: the user session cookie; later: share-link tokens) gets its own
+// key derived from the secret via deriveKey, so a value signed for one
+// purpose can never be replayed as another -- that's enforced by
+// construction, not by a discriminator field a bug could skip.
+package session
+
+import (
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/subtle"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ // CookieName is the browser cookie carrying the signed session.
+ CookieName = "gsg_user"
+
+ // TTL is how long a session lasts after login before it must be
+ // re-established. Embedded inside the signed payload, not left to the
+ // cookie's own Max-Age/Expires alone, so a copied cookie can't outlive
+ // what the server considers valid.
+ TTL = 30 * 24 * time.Hour
+
+ // ShareCookieName is the browser cookie a redeemed share link mints.
+ ShareCookieName = "gsg_share"
+
+ purposeSession = "session"
+ purposeShareURL = "share-url"
+ purposeShareCookie = "share-cookie"
+)
+
+// Manager issues and verifies gsg's signed cookies. Safe for concurrent
+// use; a nil *Manager verifies every request as anonymous, so wiring it up
+// is optional.
+type Manager struct {
+ secret []byte
+}
+
+// NewManager generates a fresh in-memory secret.
+func NewManager() *Manager {
+ secret := make([]byte, 32)
+ if _, err := rand.Read(secret); err != nil {
+ panic("session: failed to generate secret: " + err.Error())
+ }
+ return &Manager{secret: secret}
+}
+
+// deriveKey derives a purpose-scoped signing key from the master secret.
+// This is a single-step HMAC-based derivation (not full RFC 5869 HKDF),
+// which is sufficient here: the input is already a uniformly random 32-byte
+// secret, so there's no entropy-extraction step to do, only labeling.
+func (m *Manager) deriveKey(purpose string) []byte {
+ mac := hmac.New(sha256.New, m.secret)
+ mac.Write([]byte(purpose))
+ return mac.Sum(nil)
+}
+
+// Issue mints a signed session cookie for username.
+func (m *Manager) Issue(username string) *http.Cookie {
+ exp := time.Now().Add(TTL).Unix()
+ payload := username + "|" + strconv.FormatInt(exp, 10)
+ return &http.Cookie{
+ Name: CookieName,
+ Value: sign(m.deriveKey(purposeSession), payload, ""),
+ Path: "/",
+ HttpOnly: true,
+ Secure: true,
+ SameSite: http.SameSiteLaxMode,
+ Expires: time.Unix(exp, 0),
+ }
+}
+
+// Verify reads and checks the session cookie on r, returning the
+// authenticated username, or "" if there is none, it's malformed, it's
+// signed for a different purpose, or it has expired.
+func (m *Manager) Verify(r *http.Request) string {
+ if m == nil {
+ return ""
+ }
+ c, err := r.Cookie(CookieName)
+ if err != nil {
+ return ""
+ }
+ payload, ok := verify(m.deriveKey(purposeSession), c.Value, "")
+ if !ok {
+ return ""
+ }
+ user, expStr, ok := strings.Cut(payload, "|")
+ if !ok {
+ return ""
+ }
+ exp, err := strconv.ParseInt(expStr, 10, 64)
+ if err != nil || time.Now().Unix() > exp {
+ return ""
+ }
+ return user
+}
+
+// Clear returns a cookie that immediately expires the session.
+func (m *Manager) Clear() *http.Cookie {
+ return &http.Cookie{
+ Name: CookieName,
+ Value: "",
+ Path: "/",
+ HttpOnly: true,
+ Secure: true,
+ SameSite: http.SameSiteLaxMode,
+ MaxAge: -1,
+ }
+}
+
+// ShareClaims is the payload embedded in both a share-URL token and the
+// gsg_share cookie minted on redemption. Slugs is the set of repo slugs the
+// grant unlocks; IAT/Exp are unix seconds. Pwd records only whether the
+// URL token requires a password -- it carries no password material itself.
+type ShareClaims struct {
+ V int `json:"v"`
+ Slugs []string `json:"slugs"`
+ IAT int64 `json:"iat"`
+ Exp int64 `json:"exp"`
+ Pwd bool `json:"pwd"`
+}
+
+func (c ShareClaims) expired() bool { return c.Exp != 0 && time.Now().Unix() > c.Exp }
+
+// IssuedAt is c.IAT as a time.Time, for comparison against a repo's
+// gsg.share-revoked-after.
+func (c ShareClaims) IssuedAt() time.Time { return time.Unix(c.IAT, 0) }
+
+// NewShareToken signs a share-URL token. password is "" for an unprotected
+// link; when set, it is folded into the signature, never stored in the
+// token -- see the package doc.
+func (m *Manager) NewShareToken(slugs []string, ttl time.Duration, password string) (string, ShareClaims) {
+ now := time.Now()
+ claims := ShareClaims{V: 1, Slugs: slugs, IAT: now.Unix(), Exp: now.Add(ttl).Unix(), Pwd: password != ""}
+ b, _ := json.Marshal(claims) // ShareClaims always marshals; no error path
+ return sign(m.deriveKey(purposeShareURL), string(b), password), claims
+}
+
+// PeekShareToken decodes a share-URL token's claims WITHOUT verifying its
+// signature. It exists only so the redemption page can decide whether to
+// show a password prompt before it knows the password; the result must
+// never be used to grant access -- only VerifyShareToken's result may do
+// that.
+func PeekShareToken(tok string) (ShareClaims, bool) {
+ encPayload, _, found := strings.Cut(tok, ".")
+ if !found {
+ return ShareClaims{}, false
+ }
+ b, err := dec(encPayload)
+ if err != nil {
+ return ShareClaims{}, false
+ }
+ var c ShareClaims
+ if err := json.Unmarshal(b, &c); err != nil || c.V != 1 {
+ return ShareClaims{}, false
+ }
+ return c, true
+}
+
+// VerifyShareToken checks a share-URL token against the password the
+// visitor supplied (pass "" for a token that doesn't require one) and
+// returns its claims. Wrong password, tampering, or expiry all fail the
+// same way -- there is nothing here for an attacker to distinguish.
+func (m *Manager) VerifyShareToken(tok, password string) (ShareClaims, bool) {
+ payload, ok := verify(m.deriveKey(purposeShareURL), tok, password)
+ if !ok {
+ return ShareClaims{}, false
+ }
+ var c ShareClaims
+ if err := json.Unmarshal([]byte(payload), &c); err != nil || c.V != 1 || c.expired() {
+ return ShareClaims{}, false
+ }
+ return c, true
+}
+
+// IssueShareCookie mints the gsg_share cookie from already-verified claims.
+// IAT and Exp are copied verbatim from the URL token, not restamped to
+// "now": the revocation check compares a repo's gsg.share-revoked-after
+// against IAT, and resetting it here would let an already-redeemed browser
+// outlive a revocation aimed at it.
+func (m *Manager) IssueShareCookie(claims ShareClaims) *http.Cookie {
+ b, _ := json.Marshal(claims)
+ return &http.Cookie{
+ Name: ShareCookieName,
+ Value: sign(m.deriveKey(purposeShareCookie), string(b), ""),
+ Path: "/",
+ HttpOnly: true,
+ Secure: true,
+ SameSite: http.SameSiteLaxMode,
+ Expires: time.Unix(claims.Exp, 0),
+ }
+}
+
+// VerifyShareCookie reads and checks the gsg_share cookie on r.
+func (m *Manager) VerifyShareCookie(r *http.Request) (ShareClaims, bool) {
+ if m == nil {
+ return ShareClaims{}, false
+ }
+ c, err := r.Cookie(ShareCookieName)
+ if err != nil {
+ return ShareClaims{}, false
+ }
+ payload, ok := verify(m.deriveKey(purposeShareCookie), c.Value, "")
+ if !ok {
+ return ShareClaims{}, false
+ }
+ var claims ShareClaims
+ if err := json.Unmarshal([]byte(payload), &claims); err != nil || claims.V != 1 || claims.expired() {
+ return ShareClaims{}, false
+ }
+ return claims, true
+}
+
+// sign produces "base64url(payload).base64url(hmac-sha256(payload||0||extra))".
+// Compact and self-contained like a JWT, without pulling in a JWT library or
+// its algorithm-confusion surface (alg:none, HS/RS key confusion) -- the key
+// and the hash are fixed by this package, not negotiable per token. extra is
+// "" for every purpose except a password-protected share-URL token, where
+// it's the password: folding it into the signature input (rather than
+// storing a hash of it in the payload) means nobody holding just the token
+// can crack it offline, and a guess can only be checked by asking the
+// server, which can rate-limit attempts.
+func sign(key []byte, payload, extra string) string {
+ mac := hmac.New(sha256.New, key)
+ mac.Write([]byte(payload))
+ mac.Write([]byte{0})
+ mac.Write([]byte(extra))
+ return enc(payload) + "." + enc(string(mac.Sum(nil)))
+}
+
+// verify checks tok against key and extra and, on success, returns the
+// payload.
+func verify(key []byte, tok, extra string) (payload string, ok bool) {
+ encPayload, encSig, found := strings.Cut(tok, ".")
+ if !found {
+ return "", false
+ }
+ payloadBytes, err := dec(encPayload)
+ if err != nil {
+ return "", false
+ }
+ sig, err := dec(encSig)
+ if err != nil {
+ return "", false
+ }
+ mac := hmac.New(sha256.New, key)
+ mac.Write(payloadBytes)
+ mac.Write([]byte{0})
+ mac.Write([]byte(extra))
+ if subtle.ConstantTimeCompare(sig, mac.Sum(nil)) != 1 {
+ return "", false
+ }
+ return string(payloadBytes), true
+}
+
+func enc(s string) string { return base64.RawURLEncoding.EncodeToString([]byte(s)) }
+
+func dec(s string) ([]byte, error) {
+ b, err := base64.RawURLEncoding.DecodeString(s)
+ if err != nil {
+ return nil, fmt.Errorf("session: bad encoding: %w", err)
+ }
+ return b, nil
+}
diff --git a/internal/share/new.html b/internal/share/new.html
new file mode 100644
index 0000000..5651865
--- /dev/null
+++ b/internal/share/new.html
@@ -0,0 +1,64 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Share &middot; {{ .Site }}</title>
+<link rel="stylesheet" href="/static/css/base.css">
+<link rel="stylesheet" href="/static/css/share.css">
+</head>
+<body>
+<main class="wrap">
+ <header class="hero">
+ <h1 class="page-title">Share a hidden repository</h1>
+ <p class="page-subtitle">Anyone with the link can view exactly the repositories you select below &mdash; no login required.</p>
+ </header>
+
+ {{ if .Result }}
+ <div class="notice notice-fix">
+ <strong class="notice-title">Link ready</strong>
+ <p class="share-link"><a href="{{ .Result }}">{{ .Result }}</a></p>
+ <p class="share-hint">Send this URL to whoever needs it. If you set a password, tell them separately &mdash; it isn't part of the link.</p>
+ </div>
+ <p><a href="/share/new">Create another</a></p>
+ {{ else }}
+ {{ if .Error }}
+ <div class="notice notice-critical">
+ <strong class="notice-title">Not created</strong>
+ <p>{{ .Error }}</p>
+ </div>
+ {{ end }}
+
+ {{ if not .Repos }}
+ <p>You don't have any hidden repositories to share.</p>
+ {{ else }}
+ <form method="post" action="/share/new" class="share-form">
+ <fieldset class="share-repos">
+ <legend>Repositories</legend>
+ {{ $sel := .Selected }}
+ {{ range .Repos }}
+ <label class="share-repo">
+ <input type="checkbox" name="repo" value="{{ .Slug }}"{{ if index $sel .Slug }} checked{{ end }}>
+ <span class="share-repo-title">{{ .Title }}</span>
+ <span class="share-maxage">up to {{ humanDuration .MaxAge }}</span>
+ </label>
+ {{ end }}
+ </fieldset>
+
+ <label class="share-field">
+ <span>Password (optional)</span>
+ <input type="password" name="password" autocomplete="new-password">
+ </label>
+
+ <label class="share-field">
+ <span>Expires in (optional &mdash; e.g. "24h", "168h")</span>
+ <input type="text" name="ttl" placeholder="defaults to the shortest selected repo's ceiling">
+ </label>
+
+ <button class="chip admin-save" type="submit">Create link</button>
+ </form>
+ {{ end }}
+ {{ end }}
+</main>
+</body>
+</html>
diff --git a/internal/share/share.go b/internal/share/share.go
new file mode 100644
index 0000000..afb646b
--- /dev/null
+++ b/internal/share/share.go
@@ -0,0 +1,435 @@
+// Package share implements anonymous, stateless share links: a logged-in
+// user picks a set of their hidden repos and mints a signed URL that grants
+// view access to exactly those repos, no login required, optionally gated
+// by a password. Everything needed to verify a link travels inside it --
+// there is no server-side record of what's been shared, only the one
+// revocation lever gsg.share-revoked-after provides (see internal/core).
+//
+// This package is deliberately decoupled from gsg/internal/core, the same
+// way internal/notices is: HandlerConfig takes plain data and callbacks,
+// not core.Site or core.RepoInfo, so main.go is the only place that bridges
+// the two.
+package share
+
+import (
+ "embed"
+ "fmt"
+ "html/template"
+ "log/slog"
+ "net"
+ "net/http"
+ "net/url"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "gsg/internal/session"
+)
+
+//go:embed new.html view.html
+var templatesFS embed.FS
+
+// maxSlugs caps how many repos a single share token may name. The token
+// travels as a URL, and an unbounded slug list eventually exceeds practical
+// length limits in browsers, mail clients, and proxies (roughly 2000
+// characters).
+const maxSlugs = 64
+
+const (
+ rateLimitWindow = time.Minute
+ rateLimitMax = 10
+)
+
+// RepoOption is one hidden repo a user could choose to share.
+type RepoOption struct {
+ Slug string
+ Title string
+ MaxAge time.Duration // gsg.share-max-age: the ceiling/default for this repo
+}
+
+// HandlerConfig is everything the share surface needs that it cannot
+// discover for itself.
+type HandlerConfig struct {
+ Sessions *session.Manager
+
+ // VisibleHiddenRepos returns the hidden repos user is currently allowed
+ // to see (and therefore may share). Used both to populate the mint form
+ // and to re-filter a mint submission -- a submitted slug is never
+ // trusted just because the form included it.
+ VisibleHiddenRepos func(user string) []RepoOption
+
+ // RevokedAfter returns a repo's gsg.share-revoked-after (zero if unset)
+ // and whether the repo still exists at all, for filtering a token's
+ // slugs at redemption time.
+ RevokedAfter func(slug string) (revokedAfter time.Time, exists bool)
+
+ SiteName string
+ Logger *slog.Logger
+}
+
+type handler struct {
+ cfg HandlerConfig
+ newTmpl *template.Template
+ viewTmpl *template.Template
+ limiter *rateLimiter
+}
+
+// NewHandler builds the share mux: minting (requires a gsg_user session)
+// and redemption (does not). Both belong on the public listener (see
+// main.go) and are hand-mounted directly on its mux rather than expressed
+// as a core.Route -- core.Route's Renderer always builds a body from a Ctx,
+// and a mint result page or a redirect-plus-Set-Cookie don't fit that
+// shape.
+func NewHandler(cfg HandlerConfig) (http.Handler, error) {
+ if cfg.Sessions == nil {
+ return nil, fmt.Errorf("share: no session manager")
+ }
+ if cfg.VisibleHiddenRepos == nil || cfg.RevokedAfter == nil {
+ return nil, fmt.Errorf("share: missing required callback")
+ }
+ if cfg.Logger == nil {
+ cfg.Logger = slog.Default()
+ }
+
+ funcs := template.FuncMap{"humanDuration": humanDuration}
+ newTmpl, err := template.New("new.html").Funcs(funcs).ParseFS(templatesFS, "new.html")
+ if err != nil {
+ return nil, fmt.Errorf("share: new.html: %w", err)
+ }
+ viewTmpl, err := template.New("view.html").Funcs(funcs).ParseFS(templatesFS, "view.html")
+ if err != nil {
+ return nil, fmt.Errorf("share: view.html: %w", err)
+ }
+
+ h := &handler{cfg: cfg, newTmpl: newTmpl, viewTmpl: viewTmpl, limiter: newRateLimiter()}
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /share/new", h.newForm)
+ mux.HandleFunc("POST /share/new", h.newSubmit)
+ mux.HandleFunc("GET /share/view/{token}", h.view)
+ mux.HandleFunc("POST /share/view/{token}", h.redeem)
+ return mux, nil
+}
+
+type formView struct {
+ Site string
+ Error string
+ Result string
+ Repos []RepoOption
+ Selected map[string]bool
+}
+
+func (h *handler) newForm(w http.ResponseWriter, r *http.Request) {
+ user := h.cfg.Sessions.Verify(r)
+ if user == "" {
+ http.Redirect(w, r, "/auth/login?next=/share/new", http.StatusSeeOther)
+ return
+ }
+ h.renderForm(w, http.StatusOK, user, "", nil)
+}
+
+func (h *handler) renderForm(w http.ResponseWriter, status int, user, errMsg string, selected map[string]bool) {
+ repos := h.cfg.VisibleHiddenRepos(user)
+ sort.Slice(repos, func(i, j int) bool { return repos[i].Slug < repos[j].Slug })
+
+ secureHeaders(w)
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ if err := h.newTmpl.Execute(w, formView{
+ Site: h.cfg.SiteName, Error: errMsg, Repos: repos, Selected: selected,
+ }); err != nil {
+ h.cfg.Logger.Error("share new render", "err", err)
+ }
+}
+
+func (h *handler) newSubmit(w http.ResponseWriter, r *http.Request) {
+ user := h.cfg.Sessions.Verify(r)
+ if user == "" {
+ http.Error(w, "not logged in", http.StatusUnauthorized)
+ return
+ }
+ // Same-origin check on the POST: minting persists nothing server-side,
+ // but it's still an authenticated action worth CSRF-class protection --
+ // a cross-site page shouldn't be able to make a logged-in visitor's
+ // browser silently mint share links.
+ if !sameOrigin(r) {
+ h.cfg.Logger.Warn("share new: origin rejected",
+ "host", r.Host, "origin", r.Header.Get("Origin"),
+ "sec-fetch-site", r.Header.Get("Sec-Fetch-Site"))
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, 1<<16)
+ if err := r.ParseForm(); err != nil {
+ h.renderForm(w, http.StatusBadRequest, user, "bad form submission", nil)
+ return
+ }
+
+ allowed := h.cfg.VisibleHiddenRepos(user)
+ byslug := make(map[string]RepoOption, len(allowed))
+ for _, o := range allowed {
+ byslug[o.Slug] = o
+ }
+
+ submitted := r.PostForm["repo"]
+ selected := make(map[string]bool, len(submitted))
+ for _, s := range submitted {
+ selected[s] = true
+ }
+
+ // Never trust the submitted slug list: only repos byslug (this user's
+ // own current visibility) can end up in the token, however the form
+ // was tampered with.
+ var slugs []string
+ var ceiling time.Duration
+ for _, s := range submitted {
+ o, ok := byslug[s]
+ if !ok {
+ continue
+ }
+ slugs = append(slugs, o.Slug)
+ if ceiling == 0 || o.MaxAge < ceiling {
+ ceiling = o.MaxAge
+ }
+ }
+ if len(slugs) == 0 {
+ h.renderForm(w, http.StatusBadRequest, user, "select at least one repository", selected)
+ return
+ }
+ if len(slugs) > maxSlugs {
+ h.renderForm(w, http.StatusBadRequest, user,
+ fmt.Sprintf("a single link can name at most %d repositories", maxSlugs), selected)
+ return
+ }
+ sort.Strings(slugs)
+
+ // The user may always ask for less than the ceiling, never more; an
+ // unparsable or too-long value falls back to the ceiling rather than
+ // rejecting the submission outright, since the ceiling is always a
+ // valid choice.
+ ttl := ceiling
+ if v := strings.TrimSpace(r.PostForm.Get("ttl")); v != "" {
+ if d, err := time.ParseDuration(v); err == nil && d > 0 && d <= ceiling {
+ ttl = d
+ }
+ }
+
+ password := r.PostForm.Get("password")
+ tok, claims := h.cfg.Sessions.NewShareToken(slugs, ttl, password)
+
+ h.cfg.Logger.Info("share link minted",
+ "user", user, "repos", len(slugs), "password", password != "",
+ "exp", time.Unix(claims.Exp, 0).Format(time.RFC3339))
+
+ secureHeaders(w)
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := h.newTmpl.Execute(w, formView{
+ Site: h.cfg.SiteName, Result: absoluteURL(r, "/share/view/"+tok),
+ }); err != nil {
+ h.cfg.Logger.Error("share new render", "err", err)
+ }
+}
+
+type viewData struct {
+ Site string
+ Error string
+ Action string
+}
+
+// view is the landing page for a share link. A token that requires no
+// password redeems immediately; one that does gets a password prompt
+// first. PeekShareToken is unverified and only decides which of those two
+// to show -- redeem always re-derives the real answer via VerifyShareToken.
+func (h *handler) view(w http.ResponseWriter, r *http.Request) {
+ tok := r.PathValue("token")
+ claims, ok := session.PeekShareToken(tok)
+ if !ok {
+ http.Error(w, "invalid share link", http.StatusNotFound)
+ return
+ }
+ if !claims.Pwd {
+ h.redeem(w, r)
+ return
+ }
+ h.renderPassword(w, http.StatusOK, tok, "")
+}
+
+func (h *handler) renderPassword(w http.ResponseWriter, status int, tok, errMsg string) {
+ secureHeaders(w)
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ if err := h.viewTmpl.Execute(w, viewData{
+ Site: h.cfg.SiteName, Error: errMsg, Action: "/share/view/" + tok,
+ }); err != nil {
+ h.cfg.Logger.Error("share view render", "err", err)
+ }
+}
+
+// redeem verifies the token (with whatever password was submitted, "" if
+// none), filters its slugs against live revocation state, and mints the
+// gsg_share cookie. It also serves the no-password GET path via view above,
+// where the submitted password is always "".
+func (h *handler) redeem(w http.ResponseWriter, r *http.Request) {
+ tok := r.PathValue("token")
+ r.Body = http.MaxBytesReader(w, r.Body, 1<<16)
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "bad form submission", http.StatusBadRequest)
+ return
+ }
+ password := r.PostForm.Get("password")
+
+ // Only rate-limit actual guesses against a password-protected link --
+ // there is nothing to guess on an unprotected one. Keyed on the source
+ // IP alone, not IP+token: PeekShareToken doesn't verify a signature, so
+ // keying on the token string would let an unauthenticated caller mint
+ // unbounded distinct keys (one per invented token) for free. IP-only
+ // also has the right semantics anyway -- one source gets a fixed
+ // budget of guesses per window, not a fixed budget per link it tries.
+ if peek, ok := session.PeekShareToken(tok); ok && peek.Pwd {
+ if !h.limiter.allow(remoteIP(r)) {
+ http.Error(w, "too many attempts, try again later", http.StatusTooManyRequests)
+ return
+ }
+ }
+
+ claims, ok := h.cfg.Sessions.VerifyShareToken(tok, password)
+ if !ok {
+ if peek, peekOK := session.PeekShareToken(tok); peekOK && peek.Pwd {
+ h.renderPassword(w, http.StatusUnauthorized, tok, "incorrect password")
+ return
+ }
+ http.Error(w, "invalid or expired share link", http.StatusNotFound)
+ return
+ }
+
+ // Narrow to what's still live: a repo that no longer exists, or whose
+ // gsg.share-revoked-after postdates this grant, drops out here. This is
+ // a courtesy at redemption time -- the authoritative check runs again
+ // on every subsequent request in core.Server.buildCtx, since revocation
+ // must reach a browser that already holds the cookie, not just block
+ // new redemptions.
+ kept := make([]string, 0, len(claims.Slugs))
+ for _, slug := range claims.Slugs {
+ revokedAfter, exists := h.cfg.RevokedAfter(slug)
+ if !exists {
+ continue
+ }
+ if !revokedAfter.IsZero() && !claims.IssuedAt().After(revokedAfter) {
+ continue
+ }
+ kept = append(kept, slug)
+ }
+ if len(kept) == 0 {
+ http.Error(w, "this share link no longer grants access to any repository", http.StatusGone)
+ return
+ }
+ claims.Slugs = kept
+
+ http.SetCookie(w, h.cfg.Sessions.IssueShareCookie(claims))
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+// rateLimiter is a small in-memory, per-process fixed-window limiter --
+// the same posture gsg already accepts elsewhere (see reposCache in
+// internal/modules/repos.go): it resets on restart, and, as a known
+// simplification, never evicts a key once created. Acceptable for guarding
+// a share link's password on a self-hosted, single-instance tool.
+type rateLimiter struct {
+ mu sync.Mutex
+ hits map[string][]time.Time
+}
+
+func newRateLimiter() *rateLimiter { return &rateLimiter{hits: map[string][]time.Time{}} }
+
+func (l *rateLimiter) allow(key string) bool {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ now := time.Now()
+ cutoff := now.Add(-rateLimitWindow)
+
+ // Compact in place: safe because append never writes past the range
+ // index it's reading from.
+ kept := l.hits[key][:0]
+ for _, t := range l.hits[key] {
+ if t.After(cutoff) {
+ kept = append(kept, t)
+ }
+ }
+ if len(kept) >= rateLimitMax {
+ l.hits[key] = kept
+ return false
+ }
+ l.hits[key] = append(kept, now)
+ return true
+}
+
+func remoteIP(r *http.Request) string {
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ return r.RemoteAddr
+ }
+ return host
+}
+
+// secureHeaders mirrors internal/notices's admin page: no inline script or
+// style, so the CSP can stay tight. script-src is 'none' here (stricter
+// than notices' 'self') since these pages ship no JS at all. Cache-Control
+// is stricter still: the mint result page's body is a bearer capability
+// (the share URL itself), so unlike the rest of the site it must never be
+// stored anywhere, not even by the requesting browser.
+func secureHeaders(w http.ResponseWriter) {
+ hdr := w.Header()
+ hdr.Set("X-Content-Type-Options", "nosniff")
+ hdr.Set("X-Frame-Options", "DENY")
+ hdr.Set("Content-Security-Policy",
+ "default-src 'none'; style-src 'self'; script-src 'none'; img-src 'self'; form-action 'self'; base-uri 'none'")
+ hdr.Set("Cache-Control", "no-store")
+}
+
+// sameOrigin blocks a cross-site POST even if the session cookie would
+// otherwise be sent. A valid gsg_user cookie proves the browser is logged
+// in; it does not prove the request came from our own page.
+func sameOrigin(r *http.Request) bool {
+ switch r.Header.Get("Sec-Fetch-Site") {
+ case "", "same-origin", "none":
+ default:
+ return false
+ }
+ if o := r.Header.Get("Origin"); o != "" {
+ u, err := url.Parse(o)
+ if err != nil || !strings.EqualFold(u.Host, r.Host) {
+ return false
+ }
+ }
+ return true
+}
+
+func absoluteURL(r *http.Request, path string) string {
+ scheme := "http"
+ if r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
+ scheme = "https"
+ }
+ return scheme + "://" + r.Host + path
+}
+
+func humanDuration(d time.Duration) string {
+ switch {
+ case d <= 0:
+ return "0"
+ case d%(24*time.Hour) == 0:
+ days := d / (24 * time.Hour)
+ if days == 1 {
+ return "1 day"
+ }
+ return fmt.Sprintf("%d days", days)
+ case d%time.Hour == 0:
+ hours := d / time.Hour
+ if hours == 1 {
+ return "1 hour"
+ }
+ return fmt.Sprintf("%d hours", hours)
+ default:
+ return d.String()
+ }
+}
diff --git a/internal/share/view.html b/internal/share/view.html
new file mode 100644
index 0000000..6bb909f
--- /dev/null
+++ b/internal/share/view.html
@@ -0,0 +1,30 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Share link &middot; {{ .Site }}</title>
+<link rel="stylesheet" href="/static/css/base.css">
+<link rel="stylesheet" href="/static/css/share.css">
+</head>
+<body>
+<main class="wrap">
+ <header class="hero">
+ <h1 class="page-title">Password required</h1>
+ <p class="page-subtitle">This share link is password-protected.</p>
+ </header>
+
+ {{ if .Error }}
+ <div class="notice notice-critical"><p>{{ .Error }}</p></div>
+ {{ end }}
+
+ <form method="post" action="{{ .Action }}" class="share-form">
+ <label class="share-field">
+ <span>Password</span>
+ <input type="password" name="password" autofocus>
+ </label>
+ <button class="chip admin-save" type="submit">View</button>
+ </form>
+</main>
+</body>
+</html>
diff --git a/internal/site/routes.go b/internal/site/routes.go
index e0a6d8e..494d28e 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"}},
@@ -19,13 +19,11 @@ func Routes() []core.Route {
Render: core.JSON{Build: api.IndexEndpoint}},
// ---- per repository ----------------------------------------------
- {Name: "repo",
+ // NavLabel order below is the order tabs appear in the repo-scoped
+ // nav (top bar and page-nav chips) -- see core.BuildNav.
+ {Name: "repo", CacheableAnon: true,
Pattern: "GET /{repo}/{$}",
- Render: core.View{Template: "repo.html"}},
-
- {Name: "refs",
- Pattern: "GET /{repo}/refs/{$}",
- Render: core.View{Template: "refs.html"}},
+ Render: core.View{Template: "tree.html"}},
{Name: "api-repo",
Pattern: "GET /{repo}/api/repo.json",
@@ -33,14 +31,14 @@ func Routes() []core.Route {
// {rest...} fuses ref and path; SplitRefPath separates them by
// longest-matching ref, so feature/x/src/main.go works.
- {Name: "log",
- Pattern: "GET /{repo}/log/{rest...}",
- Render: core.View{Template: "log.html"}},
-
- {Name: "tree",
+ {Name: "tree", NavLabel: "files", CacheableAnon: true,
Pattern: "GET /{repo}/tree/{rest...}",
Render: core.View{Template: "tree.html"}},
+ {Name: "log", NavLabel: "log", CacheableAnon: true,
+ Pattern: "GET /{repo}/log/{rest...}",
+ Render: core.View{Template: "log.html"}},
+
{Name: "blob",
Pattern: "GET /{repo}/blob/{rest...}",
Render: core.View{Template: "blob.html"}},
@@ -50,12 +48,24 @@ func Routes() []core.Route {
Pattern: "GET /{repo}/raw/{rest...}",
Render: core.Raw{Module: "blobraw"}},
- // The DAG across every ref.
- {Name: "graph",
- Pattern: "GET /{repo}/graph/{$}",
+ // 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", CacheableAnon: true,
+ Pattern: "GET /{repo}/graph/{rest...}",
Render: core.View{Template: "graph.html"}},
- // Arbitrary two-commit diff: /demo/compare/main...feature
+ {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. 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/main.go b/main.go
index ccffdd7..68a9ff4 100644
--- a/main.go
+++ b/main.go
@@ -2,12 +2,17 @@
//
// gsg -root /srv/git -addr :8080 -name "rschanz.org"
// gsg -root /srv/git -templates ./templates # hot-reload the theme
+// gsg -root /srv/git -notices /etc/gsg/notices.yaml -admin-addr 127.0.0.1:8081
+// gsg -root /srv/git -admin-addr 127.0.0.1:8081 # login only, no notices
+// gsg -root /srv/git -notices /etc/gsg/notices.yaml -admin-addr 127.0.0.1:8081 -admin-group gsg-admins
//
// Per-repository settings live in each repo's own git config, cgit-style:
//
// git --git-dir=/srv/git/foo.git config gsg.title "foo"
// git --git-dir=/srv/git/foo.git config gsg.description "a thing"
// git --git-dir=/srv/git/foo.git config gsg.hide true
+// git --git-dir=/srv/git/foo.git config gsg.visible-to "alice,bob"
+// git --git-dir=/srv/git/foo.git config gsg.gpgkeyring /srv/git/foo-keys.asc
package main
import (
@@ -17,15 +22,18 @@ import (
"io/fs"
"log"
"log/slog"
+ "net"
"net/http"
+ "net/url"
"os"
- "path/filepath"
- "sort"
+ "strings"
"time"
"gsg/internal/core"
- "gsg/internal/gitx"
_ "gsg/internal/modules" // registers the built-in modules
+ "gsg/internal/notices"
+ "gsg/internal/session"
+ "gsg/internal/share"
"gsg/internal/site"
)
@@ -35,89 +43,273 @@ var templateFS embed.FS
//go:embed static
var staticFS embed.FS
+type options struct {
+ root, addr, name, base, tmplDir string
+ noticesPath, adminAddr string
+ adminAllowRemote bool
+ adminGroupHeader, adminGroup string
+ cacheSize int
+ timeout time.Duration
+}
+
func main() {
- var (
- root = flag.String("root", "/srv/git", "directory containing bare repositories")
- addr = flag.String("addr", ":8080", "listen address")
- name = flag.String("name", "git", "site name")
- base = flag.String("base", "", "base URL")
- tmplDir = flag.String("templates", "", "serve templates from this directory, re-parsed per request")
- cache = flag.Int("cache", 4096, "module cache entries")
- timeout = flag.Duration("timeout", 10*time.Second, "per-request git budget")
- )
+ var o options
+ flag.StringVar(&o.root, "root", "/srv/git", "directory containing bare repositories")
+ flag.StringVar(&o.addr, "addr", ":8080", "listen address")
+ flag.StringVar(&o.name, "name", "git", "site name")
+ flag.StringVar(&o.base, "base", "", "base URL")
+ flag.StringVar(&o.tmplDir, "templates", "", "serve templates from this directory, re-parsed per request")
+ flag.StringVar(&o.noticesPath, "notices", "", "YAML file of site and per-repo notices (empty disables the feature)")
+ flag.StringVar(&o.adminAddr, "admin-addr", "", "listen address for the notices admin UI (empty disables it; use loopback unless behind a container network)")
+ 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, "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()
- if err := run(*root, *addr, *name, *base, *tmplDir, *cache, *timeout); err != nil {
+ if err := run(o); err != nil {
log.Fatalf("gsg: %v", err)
}
}
-func run(root, addr, name, base, tmplDir string, cacheSize int, timeout time.Duration) error {
- siteCfg := &core.Site{Name: name, BaseURL: base, Root: root}
+func run(o options) error {
+ siteCfg := &core.Site{Name: o.name, BaseURL: o.base, Root: o.root}
- handles, err := discover(root)
- if err != nil {
- return err
+ var nstore *notices.Store
+ if o.noticesPath != "" {
+ var err error
+ nstore, err = notices.Open(o.noticesPath)
+ if err != nil {
+ return err
+ }
+ defer nstore.Close()
+ notices.SetDefault(nstore)
+ // The site-level analogue of RepoInfo.GPGKeyring: notices can
+ // change without a commit moving, so the revision has to be in
+ // every module cache key and ETag (see Ctx.contentIdentity).
+ siteCfg.RevFn = nstore.Revision
}
- if len(handles) == 0 {
- return fmt.Errorf("no repositories found under %s", root)
+ if err := core.Start(siteCfg); err != nil {
+ return err
}
- siteCfg.Repos = handles
+
+ // One in-memory secret for the whole process: it signs the gsg_user
+ // login cookie minted on the admin listener and verified on the public
+ // one. A restart invalidates every outstanding session -- acceptable
+ // for a login this lightweight.
+ sessions := session.NewManager()
var tfs fs.FS
- if tmplDir != "" {
- tfs = os.DirFS(tmplDir)
+ var err error
+ if o.tmplDir != "" {
+ tfs = os.DirFS(o.tmplDir)
} else if tfs, err = fs.Sub(templateFS, "templates"); err != nil {
return err
}
- srv, err := core.NewServer(siteCfg, site.Routes(), tfs, cacheSize)
+ srv, err := core.NewServer(siteCfg, site.Routes(), tfs, o.cacheSize)
if err != nil {
return err
}
- srv.TemplateDir = tmplDir
- srv.Timeout = timeout
+ srv.TemplateDir = o.tmplDir
+ srv.Timeout = o.timeout
+ srv.Sessions = sessions
static, err := fs.Sub(staticFS, "static")
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
+ // through site.Routes() for the same reason /auth/login is hand-mounted
+ // on the admin mux below: core.Route's Renderer always builds a body
+ // from a Ctx, and a redirect-plus-Set-Cookie doesn't fit that shape.
+ mux.HandleFunc("GET /auth/logout", func(w http.ResponseWriter, r *http.Request) {
+ http.SetCookie(w, sessions.Clear())
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+ })
+
+ // Share links: minting authorizes off the gsg_user cookie, which only
+ // this (public) listener ever verifies -- the admin listener's whole
+ // reason to exist is trusting X-Authentik-Username, not browser
+ // cookies, so mixing the two would blur that boundary for no benefit.
+ // Redemption is by definition unauthenticated public traffic.
+ shareHandler, err := share.NewHandler(share.HandlerConfig{
+ Sessions: sessions,
+ VisibleHiddenRepos: func(user string) []share.RepoOption {
+ var out []share.RepoOption
+ for _, h := range siteCfg.RepoList() {
+ if h.Info.Hidden && h.Info.CanView(user) {
+ out = append(out, share.RepoOption{
+ Slug: h.Info.Slug,
+ Title: h.Info.Title,
+ MaxAge: h.Info.ShareMaxAge,
+ })
+ }
+ }
+ return out
+ },
+ RevokedAfter: func(slug string) (time.Time, bool) {
+ h, ok := siteCfg.Lookup(slug)
+ if !ok {
+ return time.Time{}, false
+ }
+ return h.Info.ShareRevokedAfter, true
+ },
+ SiteName: o.name,
+ Logger: slog.Default(),
+ })
+ if err != nil {
+ return err
+ }
+ mux.Handle("/share/", shareHandler)
+
mux.Handle("/", srv.Handler())
+ servers := []*http.Server{{
+ Addr: o.addr,
+ Handler: mux,
+ ReadHeaderTimeout: 5 * time.Second,
+ WriteTimeout: o.timeout + 5*time.Second,
+ }}
+
+ if o.adminAddr != "" {
+ // The public mux stays strictly read-only. Anything that trusts
+ // X-Authentik-Username -- notices editing, login -- lives on this
+ // separate listener so a misconfigured Caddy forward_auth cannot
+ // expose it on the public port.
+ if !o.adminAllowRemote && !isLoopbackAddr(o.adminAddr) {
+ return fmt.Errorf("-admin-addr %q is not loopback; pass -admin-allow-remote to allow it", o.adminAddr)
+ }
+ amux := http.NewServeMux()
+ // Serve /static/ on the admin listener too. Behind Caddy the admin page
+ // 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/", cachedStatic(static))
+
+ // /auth/login: reachable only through the same Caddy forward_auth
+ // block as /admin/*, so by the time a request lands here,
+ // X-Authentik-Username is already set and trustworthy. There is no
+ // separate /auth/callback -- one hop does the whole job. There is
+ // also no link to this URL anywhere in the UI; people who need it
+ // are told out of band.
+ amux.HandleFunc("GET /auth/login", func(w http.ResponseWriter, r *http.Request) {
+ user := r.Header.Get("X-Authentik-Username")
+ if user == "" {
+ http.Error(w, "missing identity header", http.StatusUnauthorized)
+ return
+ }
+ http.SetCookie(w, sessions.Issue(user))
+ http.Redirect(w, r, safeNext(r.URL.Query().Get("next")), http.StatusSeeOther)
+ })
+
+ // Notices editing is a separate opt-in on top of the admin
+ // listener: -admin-addr alone is enough to enable login.
+ if nstore != nil {
+ repos := make([]string, 0)
+ for _, h := range siteCfg.RepoList() {
+ if !h.Info.Hidden {
+ repos = append(repos, h.Info.Slug)
+ }
+ }
+ adminHandler, err := notices.NewHandler(notices.HandlerConfig{
+ Store: nstore,
+ Repos: repos,
+ SiteName: o.name,
+ UserHeader: "X-Authentik-Username",
+ GroupHeader: o.adminGroupHeader,
+ RequiredGroup: o.adminGroup,
+ Logger: slog.Default(),
+ })
+ if err != nil {
+ return err
+ }
+ amux.Handle("/admin/", adminHandler)
+ }
+
+ servers = append(servers, &http.Server{
+ Addr: o.adminAddr,
+ Handler: amux,
+ ReadHeaderTimeout: 5 * time.Second,
+ ReadTimeout: 15 * time.Second,
+ WriteTimeout: 15 * time.Second,
+ })
+ slog.Info("gsg admin listening", "addr", o.adminAddr)
+ }
+
slog.Info("gsg listening",
- "addr", addr, "repos", len(handles),
+ "addr", o.addr, "repos", len(siteCfg.RepoList()),
"routes", len(site.Routes()), "modules", len(core.Names()))
- hs := &http.Server{
- Addr: addr,
- Handler: mux,
- ReadHeaderTimeout: 5 * time.Second,
- WriteTimeout: timeout + 5*time.Second,
+ errc := make(chan error, len(servers))
+ for _, s := range servers {
+ s := s
+ go func() { errc <- s.ListenAndServe() }()
}
- return hs.ListenAndServe()
+ return <-errc
}
-// discover finds bare repositories one level below root.
-// Extend to walk recursively if you nest by section.
-func discover(root string) ([]core.Handle, error) {
- entries, err := os.ReadDir(root)
+// 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)
+}
+
+// safeNext returns next if it is a same-site relative path, else "/". next
+// comes from a query parameter on a login redirect; without this check it
+// would be an open redirect (?next=https://evil.example) off a trusted
+// domain, right after proving who the user is.
+func safeNext(next string) string {
+ if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
+ return "/"
+ }
+ if u, err := url.Parse(next); err != nil || u.Scheme != "" || u.Host != "" {
+ return "/"
+ }
+ return next
+}
+
+// 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 {
+ host, _, err := net.SplitHostPort(addr)
if err != nil {
- return nil, err
+ host = addr
}
- var handles []core.Handle
- for _, e := range entries {
- if !e.IsDir() {
- continue
- }
- r, err := gitx.Open(filepath.Join(root, e.Name()))
- if err != nil {
- continue // not a repository
- }
- handles = append(handles, core.Handle{Repo: r, Info: core.LoadRepoInfo(r)})
+ switch host {
+ case "127.0.0.1", "::1", "localhost":
+ return true
+ default:
+ return false
}
- sort.Slice(handles, func(i, j int) bool { return handles[i].Info.Slug < handles[j].Info.Slug })
- return handles, nil
}
diff --git a/static/css/admin.css b/static/css/admin.css
new file mode 100644
index 0000000..2ed9feb
--- /dev/null
+++ b/static/css/admin.css
@@ -0,0 +1,60 @@
+/* Admin surface only. Reuses base.css tokens and components; nothing here
+ is loaded by the public pages. No literal colours -- everything comes
+ from the :root custom properties. */
+
+.admin-table td,
+.admin-table th { vertical-align: top; }
+
+.admin-table select,
+.admin-table input,
+.admin-table textarea {
+ font: inherit;
+ font-size: .8rem;
+ color: var(--fg);
+ background: var(--bg-elev);
+ border: 1px solid var(--line);
+ border-radius: var(--r-sm);
+ padding: .25rem .45rem;
+ width: 100%;
+ min-width: 6rem;
+}
+
+.admin-table textarea { resize: vertical; }
+
+.admin-table select:focus-visible,
+.admin-table input:focus-visible,
+.admin-table textarea:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: -2px;
+}
+
+.admin-table .admin-del,
+.admin-table .admin-del input {
+ width: auto;
+ min-width: 0;
+ text-align: center;
+}
+
+.admin-save { cursor: pointer; font-size: .8rem; padding: .4rem .9rem; }
+
+/* Progressive enhancement: the Add button is only useful with admin.js, so
+ it stays hidden until that script adds .has-js to <body>. The checkbox is
+ always visible without JS. */
+.js-only { display: none; }
+.has-js .js-only { display: inline-flex; }
+
+.admin-actions {
+ display: flex;
+ gap: .5rem;
+ align-items: center;
+ margin-top: .75rem;
+}
+
+.row-remove { cursor: pointer; }
+
+.notice-error,
+.notice pre {
+ margin: .35rem 0 0;
+ white-space: pre-wrap;
+ font: inherit;
+}
diff --git a/static/css/base.css b/static/css/base.css
new file mode 100644
index 0000000..2d3aff4
--- /dev/null
+++ b/static/css/base.css
@@ -0,0 +1,542 @@
+/* gsg — a quiet, text-first interface in the spirit of cgit: hairlines
+ instead of boxes, no shadows, no hover animation. The one indulgence is
+ a black-and-purple signal color -- used for links, hashes, and a thin
+ two-tone stripe at the very top of the page -- so the theme still reads
+ as its own thing rather than "yet another dark git UI".
+
+ Loaded on every page: design tokens, reset, nav, page chrome, and the
+ handful of components (cards, tables, chips, facts-bar, footer,
+ notices) shared by more than one view. View-specific rules live in the
+ other files under static/css/, linked only by the views that need
+ them. */
+
+:root {
+ /* Base — near-black ink */
+ --bg: #0F111A;
+ --bg-elev: #141725;
+ --surface: #1C2032;
+ --surface-2: #24283C;
+ --line: #2A3045;
+ --line-soft: #2E354E;
+
+ /* Foreground */
+ --fg: #FFFFFF;
+ --fg-muted: #9AA0B3;
+ --fg-subtle: #6B7280;
+
+ /* Accent — one signal color, not a rainbow */
+ --accent: #A78BFA;
+ --accent-2: #7C3AED;
+ --accent-grad: linear-gradient(90deg, var(--accent), var(--accent-2));
+
+ /* Status colors */
+ --add: #10B981;
+ --add-bg: rgba(16, 185, 129, 0.15);
+ --del: #EF4444;
+ --del-bg: rgba(239, 68, 68, 0.15);
+
+ /* Operator notice severities. One token pair per type; the .notice-*
+ modifiers below are the only place the palette is applied. */
+ --notice-critical: #EF4444;
+ --notice-critical-bg: rgba(239, 68, 68, 0.15);
+ --notice-warning: #F59E0B;
+ --notice-warning-bg: rgba(245, 158, 11, 0.15);
+ --notice-info: #60A5FA;
+ --notice-info-bg: rgba(96, 165, 250, 0.15);
+ --notice-fix: #10B981;
+ --notice-fix-bg: rgba(16, 185, 129, 0.15);
+
+ /* Counterpart to --accent -- tags and other "second kind of thing"
+ markers use this instead of a shade too close to the purple accent. */
+ --tag: #FAA78B;
+
+ /* Syntax highlighting -- deliberately just four colours (keyword,
+ string, number, name) plus a muted comment, not a full-spectrum
+ scheme. Blob view only; see static/css/blob.css. */
+ --syn-kw: #818CF8;
+ --syn-str: #34D399;
+ --syn-num: #FAA78B;
+ --syn-fn: #60A5FA;
+
+ /* Lane colors — spectrum for telling graph lanes apart, independent
+ of the brand accent above */
+ --lane-0: #818CF8;
+ --lane-1: #10B981;
+ --lane-2: #F59E0B;
+ --lane-3: #EC4899;
+ --lane-4: #3B82F6;
+ --lane-5: #84CC16;
+
+ /* Typography */
+ --mono: ui-monospace, "SFMono-Regular", "JetBrains Mono", "Cascadia Mono",
+ Menlo, Consolas, "Liberation Mono", monospace;
+ --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
+
+ /* Spacing & radius */
+ --r: 8px;
+ --r-sm: 4px;
+ --pad: clamp(.85rem, 2.5vw, 1.5rem);
+}
+
+/**@media (prefers-color-scheme: light) {
+ :root {
+ --bg: #F3F4F6;
+ --bg-elev: #FFFFFF;
+ --surface: #FFFFFF;
+ --surface-2: #F9FAFB;
+ --line: #E5E7EB;
+ --line-soft: #E5E7EB;
+ --fg: #111827;
+ --fg-muted: #6B7280;
+ --fg-subtle: #9CA3AF;
+ --accent: #7C3AED;
+ --accent-2: #5B21B6;
+ --add: #059669;
+ --del: #DC2626;
+ --tag: #B45309;
+ --notice-critical: #DC2626;
+ --notice-warning: #B45309;
+ --notice-info: #1D4ED8;
+ --notice-fix: #059669;
+ --syn-kw: #4F46E5;
+ --syn-str: #047857;
+ --syn-num: #B45309;
+ --syn-fn: #1D4ED8;
+ }
+} **/
+
+/* ---------------------------------------------------------------- reset & base */
+
+*, *::before, *::after { box-sizing: border-box; }
+
+html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--fg);
+ font-family: var(--mono);
+ font-size: 13px;
+ line-height: 1.55;
+ font-variant-ligatures: none;
+ -webkit-font-smoothing: antialiased;
+}
+
+/* The one graphic flourish: a thin two-tone stripe, always at the very
+ top of the viewport. Small enough to be quiet, present enough to be a
+ mark -- the rest of the page is just text and hairlines. */
+body::before {
+ content: "";
+ position: fixed;
+ top: 0; left: 0; right: 0;
+ height: 3px;
+ background: var(--accent-grad);
+ z-index: 200;
+}
+
+a { color: var(--accent); text-decoration: none; }
+a:hover { text-decoration: underline; }
+
+/* Full viewport width, minus a small gutter -- no centered max-width
+ column. Anything wide (tables, diffs, the graph) scrolls inside its own
+ container instead of forcing the page to scroll horizontally. */
+.wrap { width: 100%; margin: 0; padding-inline: var(--pad); }
+
+/* ---------------------------------------------------------------- navigation */
+
+.nav {
+ position: sticky; top: 3px; z-index: 100;
+ background: var(--bg-elev);
+ border-bottom: 1px solid var(--line);
+}
+.nav-inner {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
+ align-items: center;
+ gap: .75rem;
+ height: 3rem;
+}
+
+.nav-start {
+ display: flex; align-items: center; gap: .75rem;
+ grid-column: 1;
+ min-width: 0;
+}
+
+.nav-end {
+ grid-column: 3;
+ justify-self: end;
+ display: flex; align-items: center; gap: .75rem;
+}
+
+.nav-user {
+ font-size: .78rem;
+ color: var(--fg-muted);
+ white-space: nowrap;
+}
+.nav-user a { color: var(--fg-muted); }
+.nav-user a:hover { color: var(--accent); }
+
+.brand {
+ display: flex; align-items: center; gap: .5rem;
+ color: var(--fg); font-weight: 700; font-size: .95rem;
+ letter-spacing: -.02em;
+}
+.brand .logo {
+ width: 22px; height: 22px;
+ background: var(--accent);
+ border-radius: var(--r-sm);
+ display: grid; place-items: center;
+ color: var(--bg); font-weight: 700; font-size: .78rem;
+}
+.brand:hover { color: var(--accent); text-decoration: none; }
+
+.repo-name {
+ color: var(--fg); font-weight: 600;
+ margin-left: .5rem;
+}
+.nav-repo-name { color: var(--line); }
+.nav-repo-name:hover { color: var(--accent); }
+
+/* Tabs — plain text, an underline marks the current page */
+.tabs {
+ display: flex; gap: 1rem;
+ grid-column: 2;
+ justify-self: center;
+}
+.tab {
+ padding: .2rem 0;
+ color: var(--fg-muted);
+ font-size: .78rem;
+ border-bottom: 2px solid transparent;
+}
+.tab:hover {
+ color: var(--fg);
+ text-decoration: none;
+}
+.tab.active {
+ color: var(--fg);
+ border-bottom-color: var(--accent);
+}
+
+/* Ref picker — plain, borderless select */
+.refpicker {
+ position: relative;
+}
+.refpicker select {
+ font: inherit; font-size: .78rem;
+ color: var(--fg-muted);
+ background: none;
+ border: 1px solid var(--line);
+ border-radius: var(--r-sm);
+ padding: .25rem 1.75rem .25rem .6rem;
+ cursor: pointer;
+ appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236B7280' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right .6rem center;
+ background-size: 10px 10px;
+}
+.refpicker select:hover { border-color: var(--accent); color: var(--fg); }
+.refpicker select:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: -2px;
+}
+
+/* ---------------------------------------------------------------- page layout */
+
+main.wrap { padding-top: 1.25rem; padding-bottom: 2rem; }
+
+/* Hero: a plain text header with a hairline underneath -- no box, no
+ gradient. The page title carries the weight instead of a card. */
+.hero {
+ margin-bottom: 1.25rem;
+ padding-bottom: .9rem;
+ border-bottom: 1px solid var(--line);
+}
+
+.page-title {
+ margin: 0;
+ font-size: 1.3rem;
+ font-weight: 700;
+ letter-spacing: -.02em;
+ line-height: 1.25;
+ /* Repository titles are often long unbroken slugs ("my-project-name").
+ Without this they push past the viewport instead of wrapping. Balance
+ avoids a single dangling word on the second line. */
+ overflow-wrap: anywhere;
+ text-wrap: balance;
+}
+.page-subtitle {
+ margin: .35rem 0 0;
+ color: var(--fg-muted);
+ font-size: .82rem;
+}
+
+.breadcrumbs {
+ display: flex; flex-wrap: wrap; gap: .4rem;
+ align-items: baseline;
+ margin-top: 1rem;
+ font-size: .9rem;
+ color: var(--fg-muted);
+}
+.breadcrumbs a { color: var(--accent); }
+.breadcrumbs .here { color: var(--fg); }
+
+/* ---------------------------------------------------------------- cards & containers */
+
+/* Reserved for content that genuinely needs a boundary (a diff, a grouped
+ sub-section) -- a thin border, nothing else. Most pages don't use it. */
+.card {
+ border: 1px solid var(--line);
+ border-radius: var(--r);
+ padding: .7rem .9rem;
+ margin-bottom: .75rem;
+}
+
+/* Facts bar — a plain line of label: value pairs, cgit-style, not a grid
+ of stat tiles. */
+.facts-bar {
+ display: flex;
+ flex-wrap: wrap;
+ column-gap: 1.5rem;
+ row-gap: .3rem;
+ margin-bottom: 1rem;
+ font-size: .82rem;
+}
+.fact { display: flex; gap: .4rem; align-items: baseline; }
+.fact-label {
+ color: var(--fg-subtle);
+}
+.fact-label::after { content: ":"; }
+.fact-value {
+ color: var(--fg);
+ font-weight: 600;
+}
+
+/* ---------------------------------------------------------------- tables */
+
+/* Borderless, hairline-separated rows -- like cgit's listings -- rather
+ than a bordered card. Tables can still be wider than the viewport (long
+ paths, many columns on mobile); scrolling lives on the table itself so
+ the page body never gains a horizontal scrollbar. */
+.table-wrap { overflow-x: auto; }
+.table {
+ width: 100%;
+ border-collapse: collapse;
+}
+.table th {
+ color: var(--fg-subtle);
+ font-size: .68rem;
+ text-transform: uppercase;
+ letter-spacing: .07em;
+ padding: .4rem .6rem;
+ text-align: left;
+ font-weight: 600;
+ border-bottom: 1px solid var(--line);
+}
+.table td {
+ padding: .4rem .6rem;
+ border-bottom: 1px solid var(--line-soft);
+ vertical-align: middle;
+}
+.table tr:hover td {
+ background: var(--surface-2);
+}
+
+/* ---------------------------------------------------------------- chips & badges */
+
+.chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: .4rem;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+/* Plain bordered tags, not filled pill badges. */
+.chip {
+ display: inline-flex;
+ align-items: center;
+ gap: .35rem;
+ font-size: .7rem;
+ padding: .2rem .5rem;
+ border: 1px solid var(--line);
+ border-radius: var(--r-sm);
+ color: var(--fg-muted);
+ background: none;
+}
+.chip:hover {
+ border-color: var(--accent);
+ color: var(--fg);
+ text-decoration: none;
+}
+.chip-tag:hover {
+ border-color: var(--tag);
+}
+.chip-branch { color: var(--accent); border-color: currentColor; }
+.chip-tag { color: var(--tag); border-color: currentColor; }
+.chip-ok { color: var(--add); border-color: currentColor; }
+.chip-bad { color: var(--del); border-color: currentColor; }
+
+.compare-form {
+ display: flex;
+ align-items: center;
+ gap: .4rem;
+}
+.compare-input {
+ font: inherit; font-size: .78rem;
+ color: var(--fg);
+ background: none;
+ border: 1px solid var(--line);
+ border-radius: var(--r-sm);
+ padding: .25rem .6rem;
+ min-width: 0;
+ flex: 1 1 20rem;
+}
+.compare-input:hover { border-color: var(--accent); }
+.compare-input:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: -2px;
+}
+.compare-form .chip {
+ cursor: pointer;
+ font: inherit;
+ font-size: .7rem;
+}
+
+/* ---------------------------------------------------------------- footer */
+
+footer {
+ margin-top: 1.5rem;
+ padding: 1rem 0;
+ border-top: 1px solid var(--line);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: .75rem;
+ font-size: .8rem;
+}
+footer .footer-left {
+ display: flex;
+ gap: .75rem;
+ align-items: center;
+}
+footer .footer-right {
+ color: var(--fg-subtle);
+ font-size: .75rem;
+}
+footer a { color: var(--accent); }
+
+/* ---------------------------------------------------------------- notices */
+
+.notice {
+ padding: .5rem .8rem;
+ border-left: 2px solid var(--accent);
+ color: var(--fg-muted);
+ font-size: .8rem;
+ margin: .75rem 0;
+}
+
+/* Operator notices. The severity only changes the left rule and the
+ background tint; the base .notice keeps the shared shape. */
+.notices { margin: .75rem 0 1rem; }
+.notice-critical { border-left-color: var(--notice-critical); background: var(--notice-critical-bg); }
+.notice-warning { border-left-color: var(--notice-warning); background: var(--notice-warning-bg); }
+.notice-info { border-left-color: var(--notice-info); background: var(--notice-info-bg); }
+.notice-fix { border-left-color: var(--notice-fix); background: var(--notice-fix-bg); }
+.notice-title { display: block; color: var(--fg); }
+.notice-body { white-space: pre-wrap; }
+.notice-link { margin-left: .35rem; }
+
+/* ---------------------------------------------------------------- error page */
+
+.error-page { padding-top: 3rem; padding-bottom: 3rem; text-align: center; }
+.error-code {
+ font-size: 3rem;
+ font-weight: 700;
+ color: var(--accent);
+ margin: 0;
+ letter-spacing: -.03em;
+}
+.error-page .page-title { margin-top: .25rem; }
+.error-page .page-subtitle { margin-bottom: 1rem; }
+
+/* ---------------------------------------------------------------- responsive */
+
+@media (max-width: 640px) {
+ .nav-inner {
+ display: flex;
+ flex-wrap: wrap;
+ height: auto; padding-block: .5rem;
+ }
+ .nav-start {
+ flex: 1 1 auto;
+ /* Let the repo name drop below the brand rather than overflow the row. */
+ flex-wrap: wrap;
+ row-gap: .15rem;
+ }
+ .nav-repo-name {
+ min-width: 0;
+ margin-left: 0;
+ overflow-wrap: anywhere;
+ }
+ .nav-end { margin-left: auto; }
+ .tabs {
+ justify-self: auto;
+ width: 100%;
+ order: 3;
+ gap: .75rem;
+ }
+ .facts-bar {
+ flex-direction: column;
+ gap: .25rem;
+ }
+ .hero { padding-bottom: .7rem; }
+ .page-title {
+ font-size: 1.1rem;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ animation: none !important;
+ transition: none !important;
+ }
+}
+
+/* ---------------------------------------------------------------- utilities */
+
+.hidden { display: none !important; }
+.muted { color: var(--fg-muted); }
+.text-accent { color: var(--accent); }
+.font-mono { font-family: var(--mono); }
+.w-full { width: 100%; }
+.text-right { text-align: right !important; }
+.text-center { text-align: center !important; }
+.text-sm { font-size: .85rem; }
+.text-xs { font-size: .75rem; }
+
+/* Focus states for accessibility */
+:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+/* Scrollbar styling */
+::-webkit-scrollbar {
+ height: 8px;
+ width: 8px;
+}
+::-webkit-scrollbar-track {
+ background: var(--bg-elev);
+ border-radius: 4px;
+}
+::-webkit-scrollbar-thumb {
+ background: var(--line);
+ border-radius: 4px;
+}
+::-webkit-scrollbar-thumb:hover {
+ background: var(--fg-subtle);
+}
diff --git a/static/css/blob.css b/static/css/blob.css
new file mode 100644
index 0000000..f367717
--- /dev/null
+++ b/static/css/blob.css
@@ -0,0 +1,168 @@
+/* Blob view — numbered source lines. Owned by views/blob.html */
+
+.source {
+ margin: .75rem 0;
+ background: var(--surface-2);
+ border: 1px solid var(--line);
+ border-radius: var(--r-sm);
+ overflow-x: auto;
+}
+.source table { border-collapse: collapse; font-size: .8rem; }
+.source tr:target { background: rgba(167, 139, 250, 0.18); }
+.source .ln {
+ padding: 0 .75rem;
+ text-align: right;
+ color: var(--fg-subtle);
+ user-select: none;
+ white-space: nowrap;
+ border-right: 1px solid var(--line);
+}
+.source .ln a { color: inherit; }
+.source .lc {
+ padding: 0 .75rem;
+ white-space: pre;
+ width: 100%;
+}
+
+/* Syntax highlighting, set by internal/modules/highlight.go. Tokens without
+ a mapped class (punctuation, operators, plain names) are left uncoloured. */
+.source .tok-k { color: var(--syn-kw); }
+.source .tok-s { color: var(--syn-str); }
+.source .tok-n { color: var(--syn-num); }
+.source .tok-fn { color: var(--syn-fn); }
+.source .tok-c { color: var(--fg-subtle); font-style: italic; }
+
+/* JSON/YAML: a parse-error banner, and per-level indent guides set by
+ internal/modules/structured.go. Width is inlined per span since the
+ indent unit (2 vs 4 spaces) varies by file. */
+.notice-warn { border-left-color: var(--del); }
+.source .ind {
+ display: inline-block;
+ border-left: 1px solid var(--line-soft);
+ height: 1.2em;
+ vertical-align: text-top;
+}
+
+/* Fold controls. The chevron lives in the gutter, but the gutter reserves
+ its width on every row -- otherwise line numbers jog sideways down the
+ file depending on which lines happen to open a block. */
+.source.foldable .ln { position: relative; padding-left: 1.5rem; }
+.source .fold {
+ position: absolute;
+ left: .15rem;
+ top: 0;
+ width: 1.1rem;
+ padding: 0;
+ border: 0;
+ background: none;
+ color: var(--fg-subtle);
+ font: inherit;
+ line-height: inherit;
+ cursor: pointer;
+}
+.source .fold::before { content: "▾"; display: block; }
+.source .fold[aria-expanded="false"]::before { content: "▸"; }
+.source .fold:hover { color: var(--accent); }
+.source tr.is-collapsed .lc::after {
+ content: " " attr(data-fold-note);
+ color: var(--fg-subtle);
+}
+.source tr.is-hidden { display: none; }
+
+/* Search state. Matches are marked at row level: rewriting the cell to wrap
+ hits in <mark> would destroy the highlight spans underneath. */
+.source tr.is-match { background: rgba(167, 139, 250, 0.10); }
+.source tr.is-current { background: rgba(167, 139, 250, 0.28); }
+
+/* YAML anchors and aliases, resolved in internal/modules/structured.go. */
+.source .aref {
+ margin-left: .6rem;
+ padding: 0 .3rem;
+ border: 1px solid var(--line-soft);
+ border-radius: var(--r-sm);
+ color: var(--fg-muted);
+ font-size: .9em;
+ text-decoration: none;
+ white-space: nowrap;
+}
+.source .aref:hover { color: var(--accent); border-color: var(--accent); }
+.source .anchor-uses {
+ margin-left: .6rem;
+ color: var(--fg-subtle);
+ font-size: .85em;
+}
+
+/* Toolbar, built at runtime by static/js/structured.js -- a page without JS
+ never shows it. */
+.sbar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: .4rem;
+ margin-top: .75rem;
+}
+.sbar-q {
+ flex: 1 1 14rem;
+ min-width: 0;
+ padding: .3rem .5rem;
+ background: var(--surface);
+ border: 1px solid var(--line);
+ border-radius: var(--r-sm);
+ color: var(--fg);
+ font: inherit;
+ font-size: .8rem;
+}
+.sbar-q:focus { outline: none; border-color: var(--accent); }
+.sbar-count {
+ min-width: 5.5rem;
+ color: var(--fg-subtle);
+ font-size: .75rem;
+ text-align: right;
+}
+.sbar-scope { display: flex; }
+.sbar-scope button,
+.sbar-btn {
+ padding: .3rem .55rem;
+ background: var(--surface);
+ border: 1px solid var(--line);
+ color: var(--fg-muted);
+ font: inherit;
+ font-size: .75rem;
+ cursor: pointer;
+}
+.sbar-scope button { border-radius: 0; margin-left: -1px; }
+.sbar-scope button:first-child { border-radius: var(--r-sm) 0 0 var(--r-sm); margin-left: 0; }
+.sbar-scope button:last-child { border-radius: 0 var(--r-sm) var(--r-sm) 0; }
+.sbar-btn { border-radius: var(--r-sm); }
+.sbar-scope button:hover,
+.sbar-btn:not(:disabled):hover { color: var(--fg); border-color: var(--line-soft); }
+.sbar-scope button[aria-pressed="true"],
+.sbar-btn[aria-pressed="true"] {
+ color: var(--accent);
+ border-color: var(--accent);
+}
+.sbar-btn:disabled { opacity: .45; cursor: default; }
+
+/* Image preview. The bytes come from /raw/, which serves validated raster
+ formats inline; the frame keeps a large image from widening the page and
+ the checkerboard keeps a transparent PNG legible in both themes. */
+.blob-image {
+ margin: .75rem 0;
+ padding: .75rem;
+ background: var(--surface-2);
+ border: 1px solid var(--line);
+ border-radius: var(--r-sm);
+ text-align: center;
+ overflow: auto;
+}
+.blob-image img {
+ max-width: 100%;
+ height: auto;
+ background-image:
+ linear-gradient(45deg, var(--line-soft) 25%, transparent 25%),
+ linear-gradient(-45deg, var(--line-soft) 25%, transparent 25%),
+ linear-gradient(45deg, transparent 75%, var(--line-soft) 75%),
+ linear-gradient(-45deg, transparent 75%, var(--line-soft) 75%);
+ background-size: 16px 16px;
+ background-position: 0 0, 0 8px, 8px -8px, -8px 0;
+}
diff --git a/static/css/diff.css b/static/css/diff.css
new file mode 100644
index 0000000..9a80ef3
--- /dev/null
+++ b/static/css/diff.css
@@ -0,0 +1,33 @@
+/* Diff & code display — owned by views/commit.html and views/compare.html */
+
+.code-block {
+ margin: .75rem 0;
+ background: var(--surface-2);
+ border: 1px solid var(--line);
+ border-radius: var(--r-sm);
+ overflow-x: auto;
+}
+.code-block pre {
+ margin: 0;
+ padding: .75rem;
+ font-size: .8rem;
+ line-height: 1.55;
+}
+
+.diff { font-size: .8rem; line-height: 1.55; overflow-x: auto; margin: .75rem 0; }
+.diff .pl { white-space: pre; }
+.diff .pl.hunk {
+ color: var(--accent);
+ background: rgba(167, 139, 250, 0.12);
+ border-left: 3px solid var(--accent);
+}
+.diff .pl.add {
+ color: var(--add);
+ background: var(--add-bg);
+ border-left: 3px solid var(--add);
+}
+.diff .pl.del {
+ color: var(--del);
+ background: rgba(239, 68, 68, 0.15);
+ border-left: 3px solid var(--del);
+}
diff --git a/static/css/graph.css b/static/css/graph.css
new file mode 100644
index 0000000..a44aee1
--- /dev/null
+++ b/static/css/graph.css
@@ -0,0 +1,30 @@
+/* Commit graph — owned by views/graph.html */
+
+.graph-table {
+ width: 100%;
+ border-collapse: separate;
+ border-spacing: 0;
+ margin: .75rem 0;
+}
+.graph-table td {
+ padding: 0;
+ height: 30px;
+ white-space: nowrap;
+ vertical-align: middle;
+ border-bottom: 1px solid var(--line-soft);
+}
+.graph-table tr:hover td {
+ background: var(--surface-2);
+}
+.graph-rail { padding: 0; }
+.rail-svg { display: block; }
+.graph-msg {
+ padding: 0 .6rem;
+ font-size: .78rem;
+}
+.graph-who, .graph-when {
+ color: var(--fg-muted);
+ font-size: .7rem;
+ text-align: right;
+ padding: 0 .6rem;
+}
diff --git a/static/css/readme.css b/static/css/readme.css
new file mode 100644
index 0000000..d1c3661
--- /dev/null
+++ b/static/css/readme.css
@@ -0,0 +1,66 @@
+/* README prose — owned by views/repo.html and views/commit.html (commit
+ messages render through the same markdown pipeline) */
+
+.readme {
+ margin-top: 1rem;
+ padding: 1rem 1.25rem;
+ background: var(--surface);
+ border: 1px solid var(--line);
+ border-radius: var(--r);
+ overflow-x: auto;
+}
+.readme h1, .readme h2, .readme h3 {
+ font-family: var(--sans);
+ color: var(--fg);
+ margin-top: 1.25rem;
+ margin-bottom: .5rem;
+}
+.readme h1 { font-size: 1.5rem; border-bottom: 2px solid var(--line); padding-bottom: .5rem; }
+.readme h2 { font-size: 1.25rem; }
+.readme h3 { font-size: 1.05rem; }
+.readme p, .readme ul, .readme ol, .readme blockquote, .readme table {
+ margin: 0 0 1rem;
+}
+.readme ul, .readme ol { padding-left: 1.5rem; }
+.readme li { margin-bottom: .25rem; }
+.readme blockquote {
+ padding: .5rem 1rem;
+ margin: 1rem 0;
+ background: var(--bg-elev);
+ border-left: 3px solid var(--accent);
+ border-radius: 0 var(--r-sm) var(--r-sm) 0;
+ color: var(--fg-muted);
+}
+.readme table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: .85rem;
+}
+.readme th, .readme td {
+ padding: .6rem .8rem;
+ border: 1px solid var(--line);
+}
+.readme th {
+ background: var(--surface-2);
+ color: var(--fg);
+ font-weight: 600;
+}
+.readme code {
+ font-family: var(--mono);
+ font-size: .85rem;
+ background: var(--bg-elev);
+ padding: .1rem .4rem;
+ border-radius: 4px;
+ border: 1px solid var(--line-soft);
+}
+.readme pre {
+ background: var(--bg-elev);
+ padding: 1rem;
+ border-radius: var(--r-sm);
+ overflow-x: auto;
+}
+.readme pre code {
+ padding: 0;
+ background: none;
+ border: none;
+}
diff --git a/static/css/repo-list.css b/static/css/repo-list.css
new file mode 100644
index 0000000..c8b4395
--- /dev/null
+++ b/static/css/repo-list.css
@@ -0,0 +1,121 @@
+/* Repo listing — owned by views/index.html */
+
+/* ---- Grid variant (.repo-row) ------------------------------------------
+ minmax(0, …) lets the fr tracks shrink below their min-content width;
+ bare `2fr` is minmax(auto, 2fr), so one long unbroken repo name would
+ hold the track open and overflow the row. */
+.repo-row {
+ display: grid;
+ grid-template-columns: minmax(0, 2fr) minmax(0, 3fr) auto;
+ gap: 1rem;
+ align-items: center;
+}
+.repo-name a {
+ color: var(--fg);
+ font-weight: 600;
+ /* `anywhere` (unlike `break-word`) also lowers min-content width, which
+ is what actually lets the track shrink. */
+ overflow-wrap: anywhere;
+}
+.repo-name a:hover { color: var(--accent); }
+.repo-owner {
+ display: inline-block;
+ margin-left: .5rem;
+ padding: .05rem .4rem;
+ border-radius: 999px;
+ background: var(--bg-subtle, rgba(127, 127, 127, .15));
+ color: var(--fg-subtle);
+ font-size: .7rem;
+ font-weight: 500;
+ vertical-align: middle;
+}
+.repo-desc {
+ color: var(--fg-muted);
+ font-size: .85rem;
+ overflow-wrap: anywhere;
+}
+.repo-meta {
+ color: var(--fg-subtle);
+ font-size: .8rem;
+ text-align: right;
+ overflow-wrap: break-word;
+}
+
+@media (max-width: 640px) {
+ .repo-row {
+ grid-template-columns: minmax(0, 1fr);
+ gap: .5rem;
+ }
+ .repo-meta { text-align: left; }
+}
+
+/* ---- Table variant (.table-repos) --------------------------------------
+ Each section is its own <table>. Fixed layout with shared percentages
+ keeps the columns aligned across sections. Two requirements:
+
+ - The table needs a non-auto width. Without it, browsers fall back to
+ auto layout (CSS 2.1 §17.5.2.1), which sizes columns from content
+ per table and produces the cross-section drift.
+ - The percentages must sum to 100%.
+
+ col-meta at 17% fits "2026-09-03 15:04" on one line down to roughly a
+ 900px viewport. Narrower than that, the cell wraps at the space between
+ date and time. This only works because the cell isn't `nowrap`:
+ overflow-wrap never applies where white-space forbids wrapping. */
+.table-repos {
+ table-layout: fixed;
+ width: 100%;
+}
+.table-repos .col-repo { width: 25%; }
+.table-repos .col-desc { width: 58%; }
+.table-repos .col-meta { width: 17%; }
+.table-repos td:last-child { white-space: normal; }
+
+/* Only the full header labels are shown. The narrow layout below hides the
+ header row visually, so the short labels are no longer used; delete
+ .label-short from the markup and this rule can go. */
+.table-repos .label-short { display: none; }
+
+/* Below 640px, columns are dropped and each row is stacked:
+
+ name ............ date
+ description
+
+ Once nothing sits side by side, cross-section alignment no longer
+ matters. The header is hidden visually but stays in the accessibility
+ tree. Changing `display` on table elements can remove their implicit
+ table roles in some engines, so add role="table" / "row" / "cell" in
+ the markup if screen-reader semantics matter. */
+@media (max-width: 640px) {
+ .table-repos,
+ .table-repos tbody,
+ .table-repos td {
+ display: block;
+ width: auto;
+ }
+ .table-repos thead {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ }
+ .table-repos tr {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ grid-template-areas:
+ "repo meta"
+ "desc desc";
+ gap: .25rem .75rem;
+ padding: .6rem 0;
+ }
+ .table-repos td:nth-child(1) { grid-area: repo; }
+ .table-repos td:nth-child(2) { grid-area: desc; }
+ .table-repos td:nth-child(3) {
+ grid-area: meta;
+ text-align: right;
+ /* Safe to keep on one line here: the `auto` track sizes to fit it. */
+ white-space: nowrap;
+ }
+}
diff --git a/static/css/repo.css b/static/css/repo.css
new file mode 100644
index 0000000..5ecaf75
--- /dev/null
+++ b/static/css/repo.css
@@ -0,0 +1,46 @@
+/* Repo homepage — owned by views/repo.html */
+
+/* Latest commit — one plain bordered row */
+.latest-commit {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ gap: .85rem;
+ align-items: center;
+ padding: .6rem .8rem;
+ border: 1px solid var(--line);
+ border-radius: var(--r-sm);
+ margin-bottom: 1rem;
+}
+.latest-commit:hover { border-color: var(--accent); }
+.commit-hash {
+ color: var(--accent);
+ font-family: var(--mono);
+ font-size: .78rem;
+ font-weight: 600;
+ user-select: all;
+}
+.commit-msg {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: 500;
+ color: var(--fg);
+}
+.commit-meta {
+ color: var(--fg-muted);
+ font-size: .78rem;
+ white-space: nowrap;
+}
+
+@media (max-width: 640px) {
+ .latest-commit {
+ grid-template-columns: 1fr;
+ gap: 0.5rem;
+ text-align: center;
+ }
+ .latest-commit .commit-meta {
+ display: block;
+ margin-top: .25rem;
+ }
+}
diff --git a/static/css/share.css b/static/css/share.css
new file mode 100644
index 0000000..82bb555
--- /dev/null
+++ b/static/css/share.css
@@ -0,0 +1,63 @@
+/* Share link pages: mint form, password prompt. Small and self-contained,
+ built on base.css's tokens rather than introducing new ones. */
+
+.share-form {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ max-width: 32rem;
+ margin-top: 1rem;
+}
+
+.share-repos {
+ border: 1px solid var(--line);
+ border-radius: var(--r-sm);
+ padding: .75rem 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: .5rem;
+}
+
+.share-repos legend {
+ padding: 0 .4rem;
+ color: var(--fg-muted);
+ font-size: .8rem;
+}
+
+.share-repo {
+ display: flex;
+ align-items: baseline;
+ gap: .5rem;
+}
+
+.share-repo-title { color: var(--fg); }
+
+.share-maxage {
+ color: var(--fg-muted);
+ font-size: .78rem;
+ margin-left: auto;
+}
+
+.share-field {
+ display: flex;
+ flex-direction: column;
+ gap: .3rem;
+ font-size: .85rem;
+ color: var(--fg-muted);
+}
+
+.share-field input {
+ font: inherit;
+ color: var(--fg);
+ background: var(--bg);
+ border: 1px solid var(--line);
+ border-radius: var(--r-sm);
+ padding: .4rem .6rem;
+}
+
+.share-link { word-break: break-all; }
+
+.share-hint {
+ color: var(--fg-muted);
+ font-size: .82rem;
+}
diff --git a/static/js/admin.js b/static/js/admin.js
new file mode 100644
index 0000000..41bf282
--- /dev/null
+++ b/static/js/admin.js
@@ -0,0 +1,60 @@
+// Progressive enhancement for the notices admin form.
+//
+// Without JavaScript the server-rendered checkbox removes a row and the
+// single blank row is how you add one. This script only makes those two
+// actions interactive: it turns the checkbox into a Remove button and
+// clones the blank row for an Add button. It never changes what the form
+// submits, so a JS-on submit and a JS-off submit build the same document.
+(function () {
+ 'use strict';
+
+ var form = document.querySelector('form.admin-form');
+ if (!form) return;
+ document.body.classList.add('has-js');
+
+ var tbody = form.querySelector('tbody');
+ if (!tbody) return;
+
+ // Capture the server-rendered blank "add" row before anyone types in it,
+ // and use its markup as the template for rows the Add button creates.
+ var seed = tbody.lastElementChild;
+ if (!seed) return;
+ var seedHTML = seed.outerHTML;
+
+ function enhance(row) {
+ var cell = row.querySelector('.admin-del');
+ if (!cell || cell.dataset.enhanced) return;
+ cell.dataset.enhanced = '1';
+
+ // The checkbox stays in the DOM (hidden) so a form that is somehow
+ // submitted without JS still means what it did before.
+ var box = cell.querySelector('input[type=checkbox]');
+ if (box) box.hidden = true;
+
+ var btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'chip row-remove';
+ btn.textContent = 'Remove';
+ btn.addEventListener('click', function () {
+ row.parentNode.removeChild(row);
+ });
+ cell.appendChild(btn);
+ }
+
+ function addRow() {
+ var wrap = document.createElement('tbody');
+ wrap.innerHTML = seedHTML;
+ var row = wrap.firstElementChild;
+ row.querySelectorAll('input, textarea').forEach(function (el) {
+ if (el.type === 'checkbox') el.checked = false;
+ else el.value = '';
+ });
+ tbody.appendChild(row);
+ enhance(row);
+ }
+
+ Array.prototype.forEach.call(tbody.querySelectorAll('tr'), enhance);
+
+ var add = document.getElementById('add-notice');
+ if (add) add.addEventListener('click', addRow);
+})();
diff --git a/static/js/structured.js b/static/js/structured.js
new file mode 100644
index 0000000..22ce34b
--- /dev/null
+++ b/static/js/structured.js
@@ -0,0 +1,346 @@
+/* Structured-file reading aids for the blob view: fold blocks, search keys
+ and values, filter the file down to what matched.
+
+ Everything here is progressive enhancement. The server renders a plain
+ numbered table with structure hung off data attributes (see
+ internal/modules/structured.go); this file builds the toolbar and the fold
+ controls at runtime, so a page loaded without JS is exactly the table it
+ has always been rather than a dead search box. */
+(function () {
+ var src = document.querySelector('.source.structured');
+ if (!src) return;
+ var rows = Array.prototype.slice.call(src.querySelectorAll('tbody > tr'));
+ if (!rows.length) return;
+
+ // One record per line. Text is read once from textContent -- never
+ // innerHTML, which would match highlight markup as if it were content.
+ // The alias links and anchor-use badges are chrome the server added, not
+ // file content -- searching "uses" must not match every anchor.
+ function cellText(cell) {
+ if (!cell) return '';
+ var s = '';
+ for (var i = 0; i < cell.childNodes.length; i++) {
+ var c = cell.childNodes[i];
+ if (c.nodeType === 1 && (c.className === 'aref' || c.className === 'anchor-uses')) continue;
+ s += c.textContent;
+ }
+ return s;
+ }
+
+ var R = rows.map(function (tr) {
+ var cell = tr.querySelector('.lc');
+ var text = cellText(cell);
+ var key = tr.dataset.key || '';
+ var val = text;
+ if (key) {
+ var at = text.indexOf(key);
+ var colon = text.indexOf(':', at < 0 ? 0 : at + key.length);
+ if (colon >= 0) val = text.slice(colon + 1);
+ }
+ return {
+ tr: tr, cell: cell,
+ n: +tr.dataset.n,
+ depth: +tr.dataset.depth || 0,
+ foldTo: +tr.dataset.foldTo || 0,
+ key: key.toLowerCase(),
+ path: (tr.dataset.path || '').toLowerCase(),
+ text: text.toLowerCase(),
+ blank: text.trim() === '',
+ val: val.trim().toLowerCase(),
+ match: false
+ };
+ });
+ var byLine = {};
+ R.forEach(function (r) { byLine[r.n] = r; });
+
+ var collapsed = {}; // opener line number -> true
+ var scope = 'all'; // all | keys | values
+ var filtering = false;
+ var matches = [];
+ var cursor = -1;
+
+ /* ---- fold controls ------------------------------------------------ */
+
+ // Minified or tab-indented files have no indentation to fold on. They get
+ // search, but no gutter reserved for chevrons that will never appear and
+ // no collapse-all that would do nothing.
+ var foldable = R.some(function (r) { return r.foldTo > 0; });
+ if (foldable) src.classList.add('foldable');
+
+ R.forEach(function (r) {
+ if (!r.foldTo || !r.cell) return;
+ var b = document.createElement('button');
+ b.className = 'fold';
+ b.type = 'button';
+ b.setAttribute('aria-expanded', 'true');
+ b.title = 'Collapse lines ' + (r.n + 1) + '–' + r.foldTo;
+ b.addEventListener('click', function () {
+ if (collapsed[r.n]) delete collapsed[r.n]; else collapsed[r.n] = true;
+ render();
+ });
+ r.tr.querySelector('.ln').appendChild(b);
+ // On the cell, not the row: attr() in ::after only reads the attributes
+ // of the element the pseudo-element belongs to.
+ r.cell.dataset.foldNote = '⋯ ' + (r.foldTo - r.n) + ' lines';
+ });
+
+ function foldAll(on) {
+ collapsed = {};
+ if (on) R.forEach(function (r) { if (r.foldTo) collapsed[r.n] = true; });
+ render();
+ }
+
+ // A match hidden inside a collapsed block reads as "no results", so open
+ // every block enclosing it before rendering.
+ function expose(n) {
+ for (var o in collapsed) {
+ var r = byLine[o];
+ if (r && r.n < n && n <= r.foldTo) delete collapsed[o];
+ }
+ }
+
+ /* ---- search ------------------------------------------------------- */
+
+ function hay(r) {
+ return scope === 'keys' ? r.key : scope === 'values' ? r.val : r.text;
+ }
+
+ var lastQ = '';
+
+ /* ---- path queries -------------------------------------------------- */
+
+ // A query containing a dot or bracket is tried as a value path first:
+ // obj1.property3.items[4].val. It is normalised to the same canonical
+ // form the server writes into data-path (internal/modules/structured.go),
+ // so a quoted key round-trips and indices attach directly. A query that
+ // resolves to nothing falls through to the substring search below, which
+ // keeps dotted text like a version string searchable.
+ function normalizePath(q) {
+ q = q.trim();
+ if (q[0] === '$') q = q.slice(1);
+ var out = '', first = true, i = 0;
+ function appendKey(k) {
+ if (/^[A-Za-z0-9_$-]+$/.test(k)) out += (first ? '' : '.') + k;
+ else out += '["' + k.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"]';
+ first = false;
+ }
+ while (i < q.length) {
+ var c = q[i];
+ if (c === '.') { i++; continue; }
+ if (c === '[') {
+ var j = i + 1;
+ if (q[j] === '"' || q[j] === "'") {
+ var quote = q[j], key = '';
+ j++;
+ while (j < q.length && q[j] !== quote) {
+ if (q[j] === '\\' && j + 1 < q.length) { key += q[j + 1]; j += 2; }
+ else { key += q[j]; j++; }
+ }
+ if (q[j] !== quote || q[j + 1] !== ']') return '';
+ appendKey(key);
+ i = j + 2;
+ continue;
+ }
+ var num = '';
+ while (j < q.length && q[j] !== ']') { num += q[j]; j++; }
+ if (q[j] !== ']' || !/^\d+$/.test(num)) return '';
+ out += '[' + num + ']';
+ first = false;
+ i = j + 1;
+ continue;
+ }
+ var start = i;
+ while (i < q.length && q[i] !== '.' && q[i] !== '[') i++;
+ appendKey(q.slice(start, i));
+ }
+ return out;
+ }
+
+ // pathMatches returns the set of line numbers the query names, or null
+ // when the query is not a path or names nothing. An exact match also
+ // brings in the subtree beneath it (its items[0], items[0].val, ...), so
+ // naming a container shows its contents. The boundary check keeps a.b
+ // from matching a sibling called a.bc.
+ function pathMatches(q) {
+ var target = normalizePath(q);
+ if (!target) return null;
+ var hit = {}, any = false;
+ R.forEach(function (r) {
+ if (!r.path) return;
+ if (r.path === target) { hit[r.n] = true; any = true; return; }
+ if (r.path.indexOf(target) === 0) {
+ var next = r.path[target.length];
+ if (next === '.' || next === '[') { hit[r.n] = true; any = true; }
+ }
+ });
+ return any ? hit : null;
+ }
+
+ function search(q) {
+ q = lastQ = q.trim().toLowerCase();
+ var hits = q && /[.\[]/.test(q) ? pathMatches(q) : null;
+ matches = [];
+ R.forEach(function (r) {
+ r.match = hits ? !!hits[r.n] : (q !== '' && hay(r).indexOf(q) >= 0);
+ if (r.match) matches.push(r);
+ });
+ matches.forEach(function (r) { expose(r.n); });
+ cursor = matches.length ? 0 : -1;
+ render();
+ }
+
+ // In filter mode a matching line keeps the ancestors that give it its path
+ // and the block it owns -- a bare list of hits out of context is worse
+ // than the file.
+ function keepSet() {
+ var keep = {}, stack = [];
+ R.forEach(function (r) {
+ // A blank line carries no indentation, so it is not a level -- letting
+ // one truncate the stack would strip the parents off every match
+ // below a blank line inside a nested block.
+ if (r.blank) return;
+ stack.length = Math.min(stack.length, r.depth);
+ stack[r.depth] = r;
+ if (!r.match) return;
+ stack.forEach(function (a) { if (a) keep[a.n] = true; });
+ for (var i = r.n; i <= r.foldTo; i++) keep[i] = true;
+ });
+ return keep;
+ }
+
+ /* ---- render ------------------------------------------------------- */
+
+ function render() {
+ if (filtering && !matches.length) {
+ filtering = false;
+ filterBtn.setAttribute('aria-pressed', 'false');
+ }
+ var keep = filtering ? keepSet() : null;
+ var hideUntil = 0;
+ R.forEach(function (r) {
+ var hidden = r.n <= hideUntil || (keep !== null && !keep[r.n]);
+ if (collapsed[r.n]) hideUntil = Math.max(hideUntil, r.foldTo);
+ r.tr.classList.toggle('is-hidden', hidden);
+ r.tr.classList.toggle('is-collapsed', !!collapsed[r.n]);
+ r.tr.classList.toggle('is-match', r.match);
+ r.tr.classList.remove('is-current');
+ var b = r.tr.querySelector('.fold');
+ if (b) b.setAttribute('aria-expanded', collapsed[r.n] ? 'false' : 'true');
+ });
+ if (cursor >= 0 && matches[cursor]) matches[cursor].tr.classList.add('is-current');
+ count.textContent = matches.length
+ ? (cursor + 1) + ' / ' + matches.length
+ : (input.value.trim() ? 'no matches' : '');
+ filterBtn.disabled = !matches.length;
+ }
+
+ function step(d) {
+ if (!matches.length) return;
+ cursor = (cursor + d + matches.length) % matches.length;
+ render();
+ matches[cursor].tr.scrollIntoView({ block: 'center' });
+ }
+
+ /* ---- toolbar ------------------------------------------------------ */
+
+ var bar = document.createElement('div');
+ bar.className = 'sbar';
+
+ var input = document.createElement('input');
+ input.type = 'search';
+ input.className = 'sbar-q';
+ input.placeholder = 'Search keys, values, or a path like a.b[0]… (/)';
+ input.title = 'Substring search, or a path such as obj.items[0].key';
+ input.setAttribute('aria-label', 'Search this file');
+
+ var scopes = document.createElement('div');
+ scopes.className = 'sbar-scope';
+ ['all', 'keys', 'values'].forEach(function (s) {
+ var b = document.createElement('button');
+ b.type = 'button';
+ b.textContent = s;
+ b.setAttribute('aria-pressed', String(s === scope));
+ b.addEventListener('click', function () {
+ scope = s;
+ Array.prototype.forEach.call(scopes.children, function (c) {
+ c.setAttribute('aria-pressed', String(c === b));
+ });
+ search(input.value);
+ });
+ scopes.appendChild(b);
+ });
+
+ var count = document.createElement('span');
+ count.className = 'sbar-count';
+
+ function chip(label, title, fn) {
+ var b = document.createElement('button');
+ b.type = 'button';
+ b.className = 'sbar-btn';
+ b.textContent = label;
+ b.title = title;
+ b.addEventListener('click', fn);
+ return b;
+ }
+
+ var prev = chip('↑', 'Previous match (Shift+Enter)', function () { step(-1); });
+ var next = chip('↓', 'Next match (Enter)', function () { step(1); });
+
+ var filterBtn = chip('filter', 'Show only matching lines and their parents', function () {
+ filtering = !filtering;
+ filterBtn.setAttribute('aria-pressed', String(filtering));
+ render();
+ });
+ filterBtn.setAttribute('aria-pressed', 'false');
+ filterBtn.disabled = true;
+
+ var allCollapsed = false;
+ var foldBtn = chip('collapse all', 'Fold every block', function () {
+ allCollapsed = !allCollapsed;
+ foldBtn.textContent = allCollapsed ? 'expand all' : 'collapse all';
+ foldAll(allCollapsed);
+ });
+
+ bar.appendChild(input);
+ bar.appendChild(scopes);
+ bar.appendChild(count);
+ bar.appendChild(prev);
+ bar.appendChild(next);
+ bar.appendChild(filterBtn);
+ if (foldable) bar.appendChild(foldBtn);
+ src.parentNode.insertBefore(bar, src);
+
+ var timer;
+ input.addEventListener('input', function () {
+ clearTimeout(timer);
+ timer = setTimeout(function () { search(input.value); }, 90);
+ });
+ input.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ clearTimeout(timer);
+ // Enter on a query the debounce hasn't run yet searches; Enter on the
+ // same query walks the results.
+ if (input.value.trim().toLowerCase() !== lastQ) search(input.value);
+ else step(e.shiftKey ? -1 : 1);
+ }
+ else if (e.key === 'Escape') { input.value = ''; search(''); input.blur(); }
+ });
+ document.addEventListener('keydown', function (e) {
+ if (e.key !== '/' || e.target === input || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return;
+ e.preventDefault();
+ input.focus();
+ input.select();
+ });
+
+ // Deep links (#L42, and the alias jump links) must survive folding.
+ window.addEventListener('hashchange', function () {
+ var m = /^#L(\d+)$/.exec(location.hash);
+ if (!m) return;
+ expose(+m[1]);
+ if (filtering) { filtering = false; filterBtn.setAttribute('aria-pressed', 'false'); }
+ render();
+ });
+
+ render();
+})();
diff --git a/static/style.css b/static/style.css
deleted file mode 100644
index 9772671..0000000
--- a/static/style.css
+++ /dev/null
@@ -1,348 +0,0 @@
-/* gsg — monospace-first UI. Every value on these pages is an identifier,
- hash, path or count, so the interface type is the same face as the code;
- the sans face appears only inside rendered README prose. Palette is a cool
- slate ramp with one indigo accent and a six-colour lane ramp that the
- commit graph and its branch chips share. */
-
-:root {
- --bg: #FAFAFC;
- --surface: #FFFFFF;
- --line: #E3E5EE;
- --line-soft: #EDEEF5;
- --fg: #14161D;
- --fg-soft: #4A5063;
- --dim: #858BA0;
- --accent: #5B4BE0;
- --accent-bg: #EEEBFE;
- --add: #1E874B;
- --add-bg: #E9F6EE;
- --del: #C4314B;
- --del-bg: #FCECEF;
-
- --lane-0: #5B4BE0;
- --lane-1: #109C8D;
- --lane-2: #C97A0E;
- --lane-3: #D2447A;
- --lane-4: #2A7CD6;
- --lane-5: #6C9B1E;
-
- --mono: ui-monospace, "SFMono-Regular", "JetBrains Mono", "Cascadia Mono",
- Menlo, Consolas, "Liberation Mono", monospace;
- --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-
- --r: 6px;
- --pad: clamp(1rem, 4vw, 2rem);
-}
-
-@media (prefers-color-scheme: dark) {
- :root {
- --bg: #0E1016;
- --surface: #151823;
- --line: #242838;
- --line-soft: #1C2030;
- --fg: #E4E7F0;
- --fg-soft: #A8AEC2;
- --dim: #6E7590;
- --accent: #9C8BFF;
- --accent-bg: #211D3D;
- --add: #4BC585;
- --add-bg: #12251A;
- --del: #FF7A90;
- --del-bg: #2A1419;
-
- --lane-0: #9C8BFF;
- --lane-1: #34C6B4;
- --lane-2: #E5A544;
- --lane-3: #F2678E;
- --lane-4: #58A6F0;
- --lane-5: #9BD45F;
- }
-}
-
-*, *::before, *::after { box-sizing: border-box; }
-
-html { -webkit-text-size-adjust: 100%; }
-
-body {
- margin: 0;
- background: var(--bg);
- color: var(--fg);
- font-family: var(--mono);
- font-size: 13.5px;
- line-height: 1.6;
- font-variant-ligatures: none;
- -webkit-font-smoothing: antialiased;
-}
-
-.wrap { max-width: 74rem; margin: 0 auto; padding-inline: var(--pad); }
-
-a { color: var(--accent); text-decoration: none; }
-a:hover { text-decoration: underline; text-underline-offset: 2px; }
-:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 2px; }
-.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
-
-/* ---------------------------------------------------------------- topbar */
-
-.topbar {
- position: sticky; top: 0; z-index: 10;
- background: color-mix(in srgb, var(--bg) 88%, transparent);
- backdrop-filter: blur(10px);
- border-bottom: 1px solid var(--line);
-}
-.bar {
- display: flex; align-items: center; gap: .65rem;
- height: 3.1rem; flex-wrap: wrap;
-}
-.brand { color: var(--fg); font-weight: 600; letter-spacing: -.01em; }
-.brand:hover { color: var(--accent); text-decoration: none; }
-.slash { color: var(--dim); }
-.repo { color: var(--fg); font-weight: 600; }
-
-.tabs { display: flex; gap: .15rem; margin-left: .6rem; }
-.tabs a {
- color: var(--fg-soft); padding: .2rem .55rem; border-radius: var(--r);
- font-size: .8125rem; letter-spacing: .02em;
-}
-.tabs a:hover { background: var(--line-soft); color: var(--fg); text-decoration: none; }
-
-.refpick { margin-left: auto; }
-.refpick select {
- font: inherit; font-size: .8125rem;
- color: var(--fg-soft); background: var(--surface);
- border: 1px solid var(--line); border-radius: var(--r);
- padding: .2rem 1.4rem .2rem .5rem; cursor: pointer;
- appearance: none;
- background-image: linear-gradient(45deg, transparent 50%, var(--dim) 50%),
- linear-gradient(135deg, var(--dim) 50%, transparent 50%);
- background-position: right .62rem center, right .42rem center;
- background-size: 4px 4px, 4px 4px;
- background-repeat: no-repeat;
-}
-.refpick select:hover { border-color: var(--accent); color: var(--fg); }
-
-/* ------------------------------------------------------------- page head */
-
-main.wrap { padding-block: 2.25rem 5rem; }
-
-.page-head { margin-bottom: 1.6rem; }
-.page-head h1 {
- margin: 0; font-size: 1.375rem; font-weight: 600; letter-spacing: -.02em;
- line-height: 1.3;
-}
-.page-head h1.subject { font-weight: 500; }
-.sub { margin: .3rem 0 0; color: var(--dim); font-size: .8125rem; }
-.sub code { color: var(--fg-soft); }
-.dots { color: var(--dim); padding: 0 .3rem; }
-
-h2 {
- margin: 2.5rem 0 .7rem;
- font-size: .75rem; font-weight: 600;
- text-transform: uppercase; letter-spacing: .1em;
- color: var(--dim);
- display: flex; align-items: baseline; gap: .75rem;
-}
-h2 .more { font-size: .6875rem; letter-spacing: .06em; margin-left: auto; }
-section:first-of-type h2 { margin-top: 1.75rem; }
-
-.crumbs { display: flex; flex-wrap: wrap; gap: .3rem; align-items: baseline;
- font-size: 1.0625rem; font-weight: 500; }
-.crumbs .here { color: var(--fg); }
-
-/* ------------------------------------------------------------------ facts */
-
-.facts {
- display: grid; grid-template-columns: max-content 1fr;
- gap: .1rem 1.5rem; margin: 0;
- padding: .85rem 1rem;
- background: var(--surface);
- border: 1px solid var(--line); border-radius: var(--r);
-}
-.facts dt { color: var(--dim); font-size: .75rem; text-transform: uppercase; letter-spacing: .08em; }
-.facts dd { margin: 0; color: var(--fg-soft); }
-.facts dd code { color: var(--fg); }
-.full-sha { word-break: break-all; }
-.clone { user-select: all; }
-
-/* ----------------------------------------------------------- latest commit */
-
-.latest {
- display: grid;
- grid-template-columns: max-content 1fr max-content;
- align-items: baseline; gap: .85rem;
- margin-top: .9rem; padding: .6rem .85rem;
- background: var(--surface);
- border: 1px solid var(--line); border-radius: var(--r);
- color: var(--fg);
-}
-.latest:hover { border-color: var(--accent); text-decoration: none; }
-.latest-sha { color: var(--accent); font-size: .8125rem; }
-.latest-subject { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.latest-meta { color: var(--dim); font-size: .75rem; white-space: nowrap; }
-
-.readme { margin-top: 1.75rem; }
-
-/* ---------------------------------------------------------------- tables */
-
-.listing {
- width: 100%; border-collapse: collapse;
- background: var(--surface);
- border: 1px solid var(--line); border-radius: var(--r);
- overflow: hidden;
-}
-.listing td {
- padding: .42rem .75rem;
- border-top: 1px solid var(--line-soft);
- vertical-align: baseline;
-}
-.listing tr:first-child td { border-top: 0; }
-.listing tr:hover td { background: var(--line-soft); }
-.listing .name { width: 100%; }
-.listing .name a { color: var(--fg); }
-.listing .name a:hover { color: var(--accent); }
-.listing .desc, .listing .who { color: var(--dim); white-space: nowrap; }
-.listing .when, .listing .num, .listing .churn, .listing .act {
- color: var(--dim); white-space: nowrap; text-align: right; font-size: .8125rem;
-}
-.listing .sha a { color: var(--accent); }
-.listing .icon { padding-right: 0; width: 1.4rem; }
-.glyph { color: var(--dim); font-size: .75rem; }
-.glyph.dir { color: var(--accent); }
-.muted { color: var(--dim); }
-.empty { color: var(--dim); text-align: center; padding: 1.6rem !important; }
-
-.ghost {
- color: var(--dim); font-size: .75rem;
- border: 1px solid var(--line); border-radius: 4px;
- padding: .05rem .38rem;
-}
-.ghost:hover { color: var(--accent); border-color: var(--accent); text-decoration: none; }
-
-.churn .add { color: var(--add); }
-.churn .del { color: var(--del); margin-left: .5rem; }
-
-.chips { display: flex; flex-wrap: wrap; gap: .4rem; list-style: none; margin: 0; padding: 0; }
-.chip {
- font-size: .75rem; padding: .1rem .45rem;
- border: 1px solid var(--line); border-radius: 4px;
- color: var(--fg-soft); background: var(--surface);
-}
-.chip b { color: var(--dim); font-weight: 400; }
-.chip-branch { color: var(--accent); background: var(--accent-bg); border-color: transparent; margin-right: .35rem; }
-.chip-tag { color: var(--lane-2); border-color: currentColor; margin-right: .35rem; }
-
-/* ----------------------------------------------------------------- graph */
-/* The signature: lane geometry comes from the module, colour from here. */
-
-table.graph { width: 100%; border-collapse: collapse; }
-table.graph td { padding: 0 .75rem; height: 30px; white-space: nowrap; vertical-align: middle; }
-table.graph tr:hover { background: var(--line-soft); }
-table.graph .rail { padding: 0; width: 1px; }
-.rail-svg { display: block; }
-table.graph .msg { width: 100%; white-space: normal; }
-table.graph .msg a { color: var(--fg); }
-table.graph .msg a:hover { color: var(--accent); }
-table.graph .who, table.graph .when { color: var(--dim); font-size: .8125rem; text-align: right; }
-
-/* ------------------------------------------------------------ source/diff */
-
-.source, .patch {
- margin-top: 1rem;
- background: var(--surface);
- border: 1px solid var(--line); border-radius: var(--r);
- overflow-x: auto;
-}
-.source table { border-collapse: collapse; width: 100%; }
-.source td { padding: 0; vertical-align: top; }
-.source .ln {
- width: 1%; padding: 0 .9rem 0 .8rem; text-align: right;
- color: var(--dim); user-select: none;
- position: sticky; left: 0; background: var(--surface);
- border-right: 1px solid var(--line-soft);
-}
-.source .ln a { color: inherit; }
-.source .lc { padding-left: .9rem; white-space: pre; font-size: .8125rem; line-height: 1.55; }
-.source tr:target .lc, .source tr:target .ln { background: var(--accent-bg); }
-
-/* Diffs mark the gutter rather than flooding the row: the signal stays
- readable when a hunk is mostly additions. */
-.patch { font-size: .8125rem; line-height: 1.55; }
-.pl { white-space: pre; padding: 0 .9rem 0 .65rem; border-left: 3px solid transparent; }
-.pl.meta { color: var(--dim); }
-.pl.hunk { color: var(--accent); background: var(--accent-bg); border-left-color: var(--accent); }
-.pl.add { color: var(--add); background: var(--add-bg); border-left-color: var(--add); }
-.pl.del { color: var(--del); background: var(--del-bg); border-left-color: var(--del); }
-
-.message {
- margin: 1rem 0 0; padding: .85rem 1rem;
- background: var(--surface); border: 1px solid var(--line); border-radius: var(--r);
- white-space: pre-wrap; color: var(--fg-soft); font-size: .8125rem;
-}
-.notice {
- padding: .6rem .85rem; border-radius: var(--r);
- background: var(--accent-bg); color: var(--fg-soft); font-size: .8125rem;
-}
-
-/* ----------------------------------------------------------------- prose */
-/* The only place the sans face appears. */
-
-.prose-wrap { margin-top: 2.5rem; }
-.prose {
- font-family: var(--sans); font-size: .9375rem; line-height: 1.7;
- color: var(--fg-soft);
- padding: 1.25rem 1.5rem;
- background: var(--surface);
- border: 1px solid var(--line); border-radius: var(--r);
-}
-.prose h1, .prose h2, .prose h3 {
- font-family: var(--sans); color: var(--fg);
- text-transform: none; letter-spacing: -.01em; display: block;
-}
-.prose h1 { font-size: 1.25rem; margin: 1.5rem 0 .5rem; }
-.prose h2 { font-size: 1.0625rem; margin: 1.5rem 0 .5rem; }
-.prose :first-child { margin-top: 0; }
-.prose h3 { font-size: .9375rem; margin: 1.25rem 0 .4rem; }
-.prose p, .prose ul, .prose ol, .prose blockquote, .prose table { margin: 0 0 1rem; }
-.prose ul, .prose ol { padding-left: 1.35rem; }
-.prose li { margin-bottom: .2rem; }
-.prose img { max-width: 100%; height: auto; border-radius: 4px; }
-.prose hr { border: 0; border-top: 1px solid var(--line); margin: 1.75rem 0; }
-.prose blockquote {
- padding: .1rem 0 .1rem 1rem; border-left: 2px solid var(--line);
- color: var(--dim);
-}
-.prose table { border-collapse: collapse; font-size: .875rem; }
-.prose th, .prose td { padding: .35rem .8rem; border: 1px solid var(--line); text-align: left; }
-.prose th { background: var(--bg); font-weight: 600; color: var(--fg); }
-.prose code, .prose pre { font-family: var(--mono); font-size: .8125rem; }
-.prose :not(pre) > code {
- padding: .1rem .3rem; background: var(--bg);
- border: 1px solid var(--line-soft); border-radius: 3px;
-}
-.prose pre { padding: .8rem; background: var(--bg); border: 1px solid var(--line-soft);
- border-radius: 4px; overflow-x: auto; line-height: 1.5; }
-.prose pre code { padding: 0; background: none; border: 0; }
-
-/* ---------------------------------------------------------------- footer */
-
-footer {
- padding-block: 1.25rem; margin-top: 3rem;
- border-top: 1px solid var(--line);
- color: var(--dim); font-size: .75rem;
- display: flex; gap: .5rem;
-}
-footer .sep { color: var(--line); }
-
-/* ----------------------------------------------------------- small screens */
-
-@media (max-width: 46rem) {
- .listing .who, .listing .desc, table.graph .who { display: none; }
- .latest { grid-template-columns: max-content 1fr; }
- .latest-meta { grid-column: 1 / -1; }
- .bar { height: auto; padding-block: .6rem; }
- .refpick { margin-left: 0; width: 100%; }
- .refpick select { width: 100%; }
-}
-
-@media (prefers-reduced-motion: reduce) {
- *, *::before, *::after { animation: none !important; transition: none !important; }
-}
diff --git a/templates/layouts/base.html b/templates/layouts/base.html
index 3819316..b782245 100644
--- a/templates/layouts/base.html
+++ b/templates/layouts/base.html
@@ -4,42 +4,72 @@
<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/style.css">
+<link rel="stylesheet" href="{{ static "css/base.css" }}">
+{{ block "styles" . }}{{ end }}
</head>
<body>
-{{ template "topbar" . }}
-<main class="wrap">{{ block "main" . }}{{ end }}</main>
+{{ template "nav" . }}
+<main class="wrap">{{ template "notices" . }}{{ block "main" . }}{{ end }}</main>
<footer class="wrap">
- <span>{{ .Site.Name }}</span>
- <span class="sep">/</span>
- <span>served from bare repositories</span>
+ <div class="footer-left">
+ <span class="logo" style="width:24px;height:24px;font-size:0.9rem;">G</span>
+ <span>{{ .Site.Name }}</span>
+ </div>
+ <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>
+ </div>
</footer>
+{{ block "scripts" . }}{{ end }}
</body>
</html>{{ end }}
-{{ define "topbar" }}
-<header class="topbar">
- <div class="wrap bar">
- <a class="brand" href="/">{{ .Site.Name }}</a>
+{{ define "nav" }}
+<nav class="nav">
+ <div class="wrap nav-inner">
+ <div class="nav-start">
+ <a class="brand" href="/">
+ <span class="logo">G</span>
+ <span>{{ .Site.Name }}</span>
+ </a>
+ {{ with .Info.Slug }}
+ <a class="repo-name nav-repo-name" href="/{{ . }}/">{{ $.Info.Title }}</a>
+ {{ end }}
+ </div>
{{ with .Info.Slug }}
- <span class="slash">/</span>
- <a class="repo" href="/{{ . }}/">{{ $.Info.Title }}</a>
<nav class="tabs">
- <a href="/{{ . }}/tree/{{ $.Ref }}">files</a>
- <a href="/{{ . }}/log/{{ $.Ref }}">log</a>
- <a href="/{{ . }}/graph/">graph</a>
- <a href="/{{ . }}/refs/">refs</a>
+ {{ range $.Nav }}<a class="tab{{ if .Active }} active{{ end }}" href="{{ .Href }}">{{ .Label }}</a>{{ end }}
</nav>
- {{ with modOr "refs" }}
- <label class="refpick">
- <span class="sr">Ref</span>
- <select onchange="location.href=this.value">
- {{ range .Branches }}<option value="/{{ $.Info.Slug }}/tree/{{ .Name }}"{{ if eq .Name $.Ref }} selected{{ end }}>{{ .Name }}</option>{{ end }}
- {{ range .Tags }}<option value="/{{ $.Info.Slug }}/tree/{{ .Name }}"{{ if eq .Name $.Ref }} selected{{ end }}>{{ .Name }}</option>{{ end }}
- </select>
- </label>
- {{ end }}
{{ end }}
+ <div class="nav-end">
+ {{ with .Info.Slug }}
+ {{ with modOr "refs" }}
+ {{/* .Ref is a SHA (or a compare range) on commit/diff/compare pages,
+ which never matches a branch or tag name. Without a matching
+ option the browser silently selects the first branch instead,
+ which misleadingly reads as "you're on this branch". Show an
+ explicit detached entry there instead of letting that happen. */}}
+ {{ $known := false }}
+ {{ range .Branches }}{{ if eq .Name $.Ref }}{{ $known = true }}{{ end }}{{ end }}
+ {{ range .Tags }}{{ if eq .Name $.Ref }}{{ $known = true }}{{ end }}{{ end }}
+ <label class="refpicker">
+ <span class="sr">Ref</span>
+ <select onchange="if(this.value) location.href=this.value">
+ {{ if not $known }}<option value="" selected disabled>{{ trunc 12 $.Ref }} (detached)</option>{{ end }}
+ {{ range .Branches }}<option value="/{{ $.Info.Slug }}/tree/{{ .Name }}"{{ if eq .Name $.Ref }} selected{{ end }}>{{ .Name }}</option>{{ end }}
+ {{ range .Tags }}<option value="/{{ $.Info.Slug }}/tree/{{ .Name }}"{{ if eq .Name $.Ref }} selected{{ end }}>{{ .Name }}</option>{{ end }}
+ </select>
+ </label>
+ {{ end }}
+ {{ end }}
+ {{/* One-sided on purpose: no "log in" link when anonymous. There is
+ no discoverable entry point into /auth/login anywhere in the UI;
+ people who need it are told the URL out of band. */}}
+ {{ with $.User }}
+ <span class="nav-user">Logged in as {{ . }} &middot; <a href="/auth/logout">log out</a></span>
+ {{ end }}
+ </div>
</div>
-</header>
+</nav>
{{ end }}
diff --git a/templates/layouts/notices.html b/templates/layouts/notices.html
new file mode 100644
index 0000000..7209fec
--- /dev/null
+++ b/templates/layouts/notices.html
@@ -0,0 +1,24 @@
+{{/* Operator notices. Global ones are always followed by the current
+ repository's, which is why the module merges them into one slice.
+
+ The .Immutable guard is not cosmetic: commit pages are served with a
+ year-long immutable cache, so a notice rendered into one would be
+ frozen in the reader's browser with no way to revalidate. Those pages
+ simply carry no banners. Guarding here rather than in the module means
+ an immutable page never even computes them. */}}
+{{ define "notices" }}
+{{ if not .Immutable }}
+{{ with modOr "notices" }}
+<div class="notices">
+ {{ range . }}
+ {{ $n := . }}
+ <div class="notice notice-{{ .Type }}">
+ {{ with .Title }}<strong class="notice-title">{{ . }}</strong>{{ end }}
+ {{ with .Body }}<span class="notice-body">{{ . }}</span>{{ end }}
+ {{ with .Link }}<a class="notice-link" href="{{ . }}">{{ with $n.LinkText }}{{ . }}{{ else }}More{{ end }}</a>{{ end }}
+ </div>
+ {{ end }}
+</div>
+{{ end }}
+{{ end }}
+{{ end }}
diff --git a/templates/layouts/partials.html b/templates/layouts/partials.html
index 127b49c..457f5a7 100644
--- a/templates/layouts/partials.html
+++ b/templates/layouts/partials.html
@@ -1,61 +1,76 @@
{{/* {{ template "tree-table" (dict "Base" "/slug" "Ref" ref "Entries" entries) }} */}}
{{ define "tree-table" }}
-<table class="listing tree">
+<div class="table-wrap">
+<table class="table">
+ <thead>
+ <tr><th>Name</th><th>Type</th><th class="text-right">Size</th></tr>
+ </thead>
<tbody>
{{ $ := . }}{{ range .Entries }}
<tr>
- <td class="icon">{{ if .IsDir }}<span class="glyph dir">▸</span>{{ else }}<span class="glyph file">·</span>{{ end }}</td>
- <td class="name">
- {{ if .IsDir }}<a class="dir" href="{{ $.Base }}/tree/{{ $.Ref }}/{{ .Path }}">{{ .Name }}</a>
+ <td>
+ {{ if .IsDir }}<a href="{{ $.Base }}/tree/{{ $.Ref }}/{{ .Path }}">{{ .Name }}</a>
{{ else }}<a href="{{ $.Base }}/blob/{{ $.Ref }}/{{ .Path }}">{{ .Name }}</a>{{ end }}
</td>
- <td class="num">{{ if .IsDir }}<span class="muted">—</span>{{ else }}{{ bytes .Size }}{{ end }}</td>
- <td class="act">{{ if not .IsDir }}<a class="ghost" href="{{ $.Base }}/raw/{{ $.Ref }}/{{ .Path }}">raw</a>{{ end }}</td>
+ <td class="text-sm">{{ if .IsDir }}<span class="chip chip-branch">dir</span>{{ else if .IsSubmodule }}<span class="chip chip-tag">submodule</span>{{ else }}<span class="chip chip-tag">file</span>{{ end }}</td>
+ <td class="text-right text-sm text-muted">{{ if .IsDir }}&mdash;{{ else }}{{ bytes .Size }}{{ end }}</td>
</tr>
{{ else }}
- <tr><td class="empty" colspan="4">This directory is empty.</td></tr>
+ <tr><td class="text-center text-muted" colspan="3">This directory is empty.</td></tr>
{{ end }}
</tbody>
</table>
+</div>
{{ end }}
+{{/* {{ template "commit-table" (dict "Base" "/slug" "Commits" commits) }} */}}
{{ define "commit-table" }}
-<table class="listing log">
+<div class="table-wrap">
+<table class="table">
+ <thead>
+ <tr><th>Hash</th><th>Subject</th><th>Author</th><th class="text-right">Date</th></tr>
+ </thead>
<tbody>
{{ $ := . }}{{ range .Commits }}
<tr>
- <td class="sha"><a href="{{ $.Base }}/commit/{{ .SHA }}">{{ .Short }}</a></td>
- <td class="name"><a class="subject" href="{{ $.Base }}/commit/{{ .SHA }}">{{ trunc 78 .Subject }}</a></td>
- <td class="who">{{ .Author }}</td>
- <td class="when"><time datetime="{{ rfc3339 .When }}">{{ date .When }}</time></td>
+ <td class="font-mono text-sm"><a href="{{ $.Base }}/commit/{{ .SHA }}">{{ .Short }}</a></td>
+ <td><a href="{{ $.Base }}/commit/{{ .SHA }}">{{ trunc 78 .Subject }}</a></td>
+ <td class="text-sm">{{ .Author }}</td>
+ <td class="text-right text-sm text-muted"><time datetime="{{ rfc3339 .When }}">{{ date .When }}</time></td>
</tr>
{{ else }}
- <tr><td class="empty" colspan="4">No commits on this ref yet.</td></tr>
+ <tr><td class="text-center text-muted" colspan="4">No commits on this ref yet.</td></tr>
{{ end }}
</tbody>
</table>
+</div>
{{ end }}
{{/* {{ template "diffstat" (dict "Base" "/slug" "Ref" sha "Diff" d) }} */}}
{{ define "diffstat" }}
{{ $ := . }}
{{ with .Diff.Stats }}
-<table class="listing stat">
+<div class="table-wrap">
+<table class="table">
+ <thead>
+ <tr><th>File</th><th>Changes</th><th></th></tr>
+ </thead>
<tbody>
{{ range . }}
<tr>
- <td class="name"><a href="{{ $.Base }}/blob/{{ $.Ref }}/{{ .Path }}">{{ .Path }}</a></td>
- <td class="churn">
- {{ if eq .Status "B" }}<span class="muted">binary</span>
- {{ else }}<span class="add">+{{ .Added }}</span><span class="del">−{{ .Deleted }}</span>{{ end }}
+ <td><a href="{{ $.Base }}/blob/{{ $.Ref }}/{{ .Path }}">{{ .Path }}</a></td>
+ <td class="text-sm">
+ {{ if eq .Status "B" }}<span class="text-muted">binary</span>
+ {{ else }}<span style="color:var(--add);">+{{ .Added }}</span> <span style="color:var(--del);">&minus;{{ .Deleted }}</span>{{ end }}
</td>
- <td class="act"><a class="ghost" href="{{ $.Base }}/raw/{{ $.Ref }}/{{ .Path }}">raw</a></td>
+ <td class="text-right"><a class="chip" href="{{ $.Base }}/raw/{{ $.Ref }}/{{ .Path }}">raw</a></td>
</tr>
{{ end }}
</tbody>
</table>
+</div>
{{ else }}
-<p class="empty">No files changed.</p>
+<p class="text-muted">No files changed.</p>
{{ end }}
-{{ if .Diff.Truncated }}<p class="notice">This diff exceeds the size limit and is shown in part. Fetch the file with <code>raw</code> to see all of it.</p>{{ end }}
+{{ if .Diff.Truncated }}<div class="notice">This diff exceeds the size limit and is shown in part. Fetch the file with <code>raw</code> to see all of it.</div>{{ end }}
{{ end }}
diff --git a/templates/views/blob.html b/templates/views/blob.html
index a7e87ab..22b89f9 100644
--- a/templates/views/blob.html
+++ b/templates/views/blob.html
@@ -1,21 +1,69 @@
{{ define "blob.html" }}{{ template "base" . }}{{ end }}
+{{ define "styles" }}<link rel="stylesheet" href="{{ static "css/blob.css" }}">{{ end }}
{{ define "main" }}
-{{ $b := mod "blob" }}{{ $slug := print "/" .Info.Slug }}
-<header class="page-head">
- <nav class="crumbs">
- <a href="{{ $slug }}/tree/{{ .Ref }}">{{ .Info.Title }}</a>
- <span class="slash">/</span><span class="here">{{ $b.Path }}</span>
- </nav>
- <p class="sub">
- <code>{{ .Ref }}</code> &middot; {{ bytes64 $b.Size }}{{ if not $b.Binary }} &middot; {{ len $b.Lines }} lines{{ end }}
- &middot; <a class="ghost" href="{{ $slug }}/raw/{{ .Ref }}/{{ $b.Path }}">raw</a>
- </p>
-</header>
-{{ if $b.Binary }}
- <p class="notice">Binary file. Fetch it with <code>raw</code>.</p>
+{{ $slug := print "/" .Info.Slug }}
+{{ $b := mod "blob" }}
+
+ <header class="hero">
+ <div class="hero-inner">
+ <h1 class="page-title">{{ $b.Path }}</h1>
+ <p class="page-subtitle">
+ <code style="color:var(--accent);">{{ .Ref }}</code>
+ {{ if not $b.Submodule }}
+ <span class="text-muted">&middot; {{ bytes64 $b.Size }}{{ if not $b.Binary }} &middot; {{ len $b.Lines }} lines{{ end }}</span>
+ <a class="chip" style="margin-left:.5rem;" href="{{ $slug }}/raw/{{ .Ref }}/{{ $b.Path }}">raw</a>
+ {{ if $b.Image }}<a class="chip" style="margin-left:.5rem;" href="{{ $slug }}/raw/{{ .Ref }}/{{ $b.Path }}" download>download</a>{{ end }}
+ {{ end }}
+ </p>
+ </div>
+ </header>
+
+{{ if $b.Submodule }}
+ <div class="notice">
+ This is a submodule, pinned at
+ <code>{{ if $b.Submodule.Remote }}{{ $b.Submodule.Remote }}:{{ end }}{{ $b.Submodule.SHA }}</code>.
+ </div>
+{{ else if $b.Preview }}
+ <figure class="blob-image">
+ <img src="{{ $slug }}/raw/{{ .Ref }}/{{ $b.Path }}" alt="{{ $b.Path }}">
+ </figure>
+{{ else if $b.Image }}
+ <div class="notice">
+ This image is {{ bytes64 $b.Size }}, over the {{ bytes64 $b.PreviewLimit }} preview limit.
+ <a href="{{ $slug }}/raw/{{ .Ref }}/{{ $b.Path }}" download>Download it</a> to view it locally.
+ </div>
+{{ else if $b.Binary }}
+ <div class="notice">This is a binary file. Fetch it with <a href="{{ $slug }}/raw/{{ .Ref }}/{{ $b.Path }}">raw</a>.</div>
{{ else }}
-<div class="source"><table><tbody>
-{{ range $b.Lines }}<tr id="L{{ .N }}"><td class="ln"><a href="#L{{ .N }}">{{ .N }}</a></td><td class="lc">{{ .Text }}</td></tr>
-{{ end }}</tbody></table></div>
+ {{ if $b.Oversize }}
+ <div class="notice">
+ This {{ if eq $b.Lang "json" }}JSON{{ else }}YAML{{ end }} file is {{ bytes64 $b.Size }},
+ over the {{ bytes64 $b.StructuredLimit }} limit for parsing. It is shown as plain numbered lines.
+ <a href="{{ $slug }}/raw/{{ .Ref }}/{{ $b.Path }}">Fetch the raw file</a> to work with it locally.
+ </div>
+ {{ end }}
+ {{ if $b.ParseErr }}
+ <div class="notice notice-warn">
+ Invalid {{ if eq $b.Lang "json" }}JSON{{ else }}YAML{{ end }}
+ {{ if $b.ParseErr.Line }}&mdash; <a href="#L{{ $b.ParseErr.Line }}">line {{ $b.ParseErr.Line }}</a>{{ end }}:
+ <code>{{ $b.ParseErr.Msg }}</code>
+ </div>
+ {{ end }}
+ <div class="source{{ if $b.Structured }} structured{{ end }}" data-lang="{{ $b.Lang }}">
+ <table><tbody>
+ {{ range $b.Lines }}
+ <tr id="L{{ .N }}" data-n="{{ .N }}"
+ {{- if .FoldTo }} data-fold-to="{{ .FoldTo }}"{{ end }}
+ {{- if .Depth }} data-depth="{{ .Depth }}"{{ end }}
+ {{- with .Key }} data-key="{{ . }}"{{ end }}
+ {{- with .Path }} data-path="{{ . }}"{{ end }}>
+ <td class="ln"><a href="#L{{ .N }}">{{ .N }}</a></td>
+ <td class="lc">{{ .Text }}{{ with .Anchor }}{{ if .Uses }}<span class="anchor-uses">{{ .Uses }} use{{ if ne .Uses 1 }}s{{ end }}</span>{{ end }}{{ end }}{{ with .Ref }}<a class="aref" href="#L{{ .Line }}" title="&amp;{{ .Name }} &mdash; line {{ .Line }}{{ with .Preview }}&#10;&#10;{{ . }}{{ end }}">&rarr;&nbsp;{{ .Line }}</a>{{ end }}</td>
+ </tr>
+ {{ end }}
+ </tbody></table>
+ </div>
{{ 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 8a8a5ea..2a1f97b 100644
--- a/templates/views/commit.html
+++ b/templates/views/commit.html
@@ -1,20 +1,53 @@
{{ define "commit.html" }}{{ template "base" . }}{{ end }}
+{{ define "styles" }}
+<link rel="stylesheet" href="{{ static "css/diff.css" }}">
+<link rel="stylesheet" href="{{ static "css/readme.css" }}">
+{{ end }}
{{ define "main" }}
-{{ $c := mod "commit" }}{{ $d := mod "diff" }}{{ $slug := print "/" .Info.Slug }}
-<header class="page-head">
- <h1 class="subject">{{ $c.Subject }}</h1>
- <p class="sub">{{ $c.Author }} committed <time datetime="{{ rfc3339 $c.When }}">{{ date $c.When }}</time></p>
-</header>
-<dl class="facts">
- <dt>commit</dt><dd><code class="full-sha">{{ $c.SHA }}</code></dd>
- {{ with $c.Parents }}<dt>parent{{ if gt (len .) 1 }}s{{ end }}</dt><dd>
- {{ range . }}<a href="{{ $slug }}/commit/{{ . }}"><code>{{ trunc 12 . }}</code></a>
- <a class="ghost" href="{{ $slug }}/compare/{{ . }}..{{ $c.SHA }}">diff</a>{{ end }}
- </dd>{{ end }}
-</dl>
-{{ with $c.Body }}<pre class="message">{{ . }}</pre>{{ end }}
+{{ $slug := print "/" .Info.Slug }}
+{{ $c := mod "commit" }}
+{{ $d := mod "diff" }}
+
+ <header class="hero">
+ <div class="hero-inner">
+ <h1 class="page-title">{{ $c.Subject }}</h1>
+ <p class="page-subtitle">
+ <code style="color:var(--accent);">{{ trunc 12 $c.SHA }}</code>
+ <span class="text-muted">&middot; {{ $c.Author }} &middot; <time datetime="{{ rfc3339 $c.When }}">{{ date $c.When }}</time></span>
+ </p>
+ </div>
+ </header>
+
+{{ with $c.Body }}
+ <div class="readme"><pre style="white-space:pre-wrap;margin:0;">{{ . }}</pre></div>
+{{ end }}
+
+<div class="chips" style="margin-bottom:1rem;">
+ {{ with $c.Parents }}
+ {{ $multi := gt (len $c.Parents) 1 }}
+ {{ range . }}
+ <a class="chip" href="{{ $slug }}/commit/{{ . }}">parent {{ trunc 12 . }}</a>
+ {{/* For a merge commit, each parent gives a genuinely different diff.
+ For an ordinary single-parent commit it's the same diff already
+ rendered below, so skip the redundant chip there. */}}
+ {{ if $multi }}<a class="chip" href="{{ $slug }}/compare/{{ . }}..{{ $c.SHA }}">diff vs {{ trunc 8 . }}</a>{{ end }}
+ {{ end }}
+ {{ end }}
+ <a class="chip" href="{{ $slug }}/tree/{{ $c.SHA }}">view tree</a>
+</div>
+
+<form class="compare-form" style="margin-bottom:1rem;"
+ onsubmit="var v=this.rev.value.trim(); if(v) location.href='{{ $slug }}/compare/'+encodeURIComponent(v)+'..{{ $c.SHA }}'; return false;">
+ <input type="text" name="rev" class="compare-input" placeholder="compare against a branch, tag, or commit&hellip;" required>
+ <button type="submit" class="chip">Compare</button>
+</form>
+
+<article class="card">
+ <header class="page-head">
+ <h2 class="page-subtitle">Changed files</h2>
+ </header>
+ {{ template "diffstat" (dict "Base" $slug "Ref" $c.SHA "Diff" $d) }}
+</article>
-<h2>Changed files</h2>
-{{ template "diffstat" (dict "Base" $slug "Ref" $c.SHA "Diff" $d) }}
-{{ with $d.Patch }}{{ patch . }}{{ end }}
+{{ with $d.Patch }}<div class="diff">{{ patch . }}</div>{{ end }}
{{ end }}
diff --git a/templates/views/compare.html b/templates/views/compare.html
index 87409db..8630cd0 100644
--- a/templates/views/compare.html
+++ b/templates/views/compare.html
@@ -1,28 +1,51 @@
{{ define "compare.html" }}{{ template "base" . }}{{ end }}
+{{ define "styles" }}<link rel="stylesheet" href="{{ static "css/diff.css" }}">{{ end }}
{{ define "main" }}
+{{ $slug := print "/" .Info.Slug }}
{{ $c := mod "compare" }}
-<header class="page-head">
- <h1>Compare</h1>
- <p class="sub">
- <code>{{ $c.Base }}</code>
- <span class="dots">{{ if $c.ThreeDot }}&hellip;{{ else }}&#8229;{{ end }}</span>
- <code>{{ $c.Head }}</code>
- </p>
-</header>
-<dl class="facts">
- <dt>base</dt><dd><a href="{{ print "/" .Info.Slug }}/commit/{{ $c.BaseSHA }}"><code>{{ trunc 12 $c.BaseSHA }}</code></a></dd>
- <dt>head</dt><dd><a href="{{ print "/" .Info.Slug }}/commit/{{ $c.HeadSHA }}"><code>{{ trunc 12 $c.HeadSHA }}</code></a></dd>
- {{ with $c.MergeBase }}<dt>merge base</dt><dd><code>{{ trunc 12 . }}</code></dd>{{ end }}
- <dt>commits</dt><dd>{{ len $c.Commits }}</dd>
-</dl>
+ <header class="hero">
+ <div class="hero-inner">
+ <h1 class="page-title">Compare</h1>
+ <p class="page-subtitle">
+ <code style="color:var(--accent);">{{ $c.Base }}</code>
+ {{ if $c.ThreeDot }}&hellip;{{ else }}..{{ end }}
+ <code style="color:var(--accent);">{{ $c.Head }}</code>
+ <span class="text-muted">&middot; {{ len $c.Commits }} commits</span>
+ </p>
+ </div>
+ </header>
-{{ template "diffstat" (dict "Base" (print "/" .Info.Slug) "Ref" $c.HeadSHA "Diff" $c.Diff) }}
+<div class="facts-bar">
+ <div class="fact">
+ <span class="fact-label">base</span>
+ <span class="fact-value"><a href="{{ $slug }}/commit/{{ $c.BaseSHA }}"><code>{{ trunc 12 $c.BaseSHA }}</code></a></span>
+ </div>
+ <div class="fact">
+ <span class="fact-label">head</span>
+ <span class="fact-value"><a href="{{ $slug }}/commit/{{ $c.HeadSHA }}"><code>{{ trunc 12 $c.HeadSHA }}</code></a></span>
+ </div>
+ {{ with $c.MergeBase }}
+ <div class="fact">
+ <span class="fact-label">merge base</span>
+ <span class="fact-value"><code>{{ trunc 12 . }}</code></span>
+ </div>
+ {{ end }}
+</div>
+
+<article class="card">
+ <header class="page-head">
+ <h2 class="page-subtitle">Changed files</h2>
+ </header>
+ {{ template "diffstat" (dict "Base" $slug "Ref" $c.HeadSHA "Diff" $c.Diff) }}
+</article>
{{ with $c.Commits }}
-<h2>Commits</h2>
-{{ template "commit-table" (dict "Base" (print "/" $.Info.Slug) "Commits" .) }}
+ <article class="card">
+ <header class="page-head"><h2 class="page-subtitle">Commits</h2></header>
+ {{ template "commit-table" (dict "Base" $slug "Commits" .) }}
+ </article>
{{ end }}
-{{ with $c.Diff.Patch }}<h2>Patch</h2>{{ patch . }}{{ end }}
+{{ with $c.Diff.Patch }}<div class="diff">{{ patch . }}</div>{{ end }}
{{ end }}
diff --git a/templates/views/graph.html b/templates/views/graph.html
index dfbf27c..c52dd0d 100644
--- a/templates/views/graph.html
+++ b/templates/views/graph.html
@@ -1,26 +1,53 @@
{{ define "graph.html" }}{{ template "base" . }}{{ 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
+ constant to snap incoming ?ofs= values onto real page boundaries. */}}
{{ $g := mod "graph" "150" }}
-<header class="page-head">
- <h1>Branch graph</h1>
- <p class="sub">{{ len $g.Rows }} commits across all refs &middot; {{ $g.Lanes }} lanes</p>
-</header>
-<table class="graph">
- <tbody>
- {{ range $g.Rows }}
- <tr>
- <td class="rail">{{ .Rail }}</td>
- <td class="msg">
- {{ range .Refs }}<span class="chip chip-{{ if eq .Kind "tag" }}tag{{ else }}branch{{ end }}">{{ .Name }}</span>{{ end }}
- <a href="{{ print "/" $.Info.Slug }}/commit/{{ .Commit.SHA }}">{{ trunc 76 .Commit.Subject }}</a>
- </td>
- <td class="who">{{ .Commit.Author }}</td>
- <td class="when"><time datetime="{{ rfc3339 .Commit.When }}">{{ date .Commit.When }}</time></td>
- </tr>
+ <header class="hero">
+ <div class="hero-inner">
+ <h1 class="page-title">Commit Graph</h1>
+ <p class="page-subtitle">
+ <span class="text-muted">{{ len $g.Rows }} commits across all refs &middot; {{ $g.Lanes }} lanes</span>
+ </p>
+ </div>
+ </header>
+
+{{ with $g.Rows }}
+<div style="overflow-x:auto;">
+ <table class="graph-table">
+ <tbody>
+ {{ range . }}
+ <tr>
+ <td class="graph-rail">{{ .Rail }}</td>
+ <td class="graph-msg">
+ {{ range .Refs }}<span class="chip chip-{{ if eq .Kind "tag" }}tag{{ else }}branch{{ end }}">{{ .Name }}</span>{{ end }}
+ <a href="{{ $slug }}/commit/{{ .Commit.SHA }}">{{ trunc 88 .Commit.Subject }}</a>
+ </td>
+ <td class="graph-who text-xs text-muted">{{ .Commit.Author }}</td>
+ <td class="graph-when text-xs text-muted"><time datetime="{{ rfc3339 .Commit.When }}">{{ date .Commit.When }}</time></td>
+ </tr>
+ {{ end }}
+ </tbody>
+ </table>
+</div>
+
+{{ else }}
+ {{ if $g.HasPrev }}
+ <div class="notice" style="text-align:center;"><span class="text-muted">No commits at this offset.</span></div>
{{ else }}
- <tr><td class="empty">No commits yet.</td></tr>
+ <div class="card" style="padding:2rem;text-align:center;">
+ <p class="muted">No commits yet. This is a fresh repository.</p>
+ </div>
{{ end }}
- </tbody>
-</table>
+{{ end }}
+
+{{ if or $g.HasPrev $g.HasNext }}
+ <nav class="pager" style="display:flex;justify-content:space-between;margin-top:1rem;">
+ <span>{{ if $g.HasPrev }}<a href="?ofs={{ $g.PrevOfs }}">&larr; Newer</a>{{ end }}</span>
+ <span>{{ if $g.HasNext }}<a href="?ofs={{ $g.NextOfs }}">Older &rarr;</a>{{ end }}</span>
+ </nav>
+{{ end }}
{{ end }}
diff --git a/templates/views/index.html b/templates/views/index.html
index d3378bf..6c31162 100644
--- a/templates/views/index.html
+++ b/templates/views/index.html
@@ -1,26 +1,45 @@
{{ define "index.html" }}{{ template "base" . }}{{ end }}
+{{ define "styles" }}<link rel="stylesheet" href="{{ static "css/repo-list.css" }}">{{ end }}
{{ define "main" }}
{{ $secs := mod "sections" }}
+
<header class="page-head">
- <h1>{{ .Site.Name }}</h1>
- <p class="sub">{{ len (mod "repos") }} repositories{{ if gt (len $secs) 1 }} in {{ len $secs }} sections{{ end }}</p>
+ <h1 class="page-title">{{ .Site.Name }}</h1>
+ <p class="page-subtitle">{{ len (mod "repos") }} repositories{{ if gt (len $secs) 1 }} in {{ len $secs }} sections{{ end }}</p>
</header>
{{ range $secs }}
{{/* An unnamed section means gsg.section is unset everywhere: render flat. */}}
- {{ with .Name }}<h2 id="{{ slug . }}">{{ . }}</h2>{{ end }}
- <table class="listing repos">
+ {{ with .Name }}<h2 id="{{ slug . }}" class="page-subtitle">{{ . }}</h2>{{ end }}
+ <div class="table-wrap">
+<table class="table table-repos">
+ <colgroup>
+ <col class="col-repo">
+ <col class="col-desc">
+ <col class="col-meta">
+ </colgroup>
+ <thead>
+ <tr>
+ <th><span class="label-full">Repository</span><span class="label-short">Repo</span></th>
+ <th>Description</th>
+ <th class="text-right"><span class="label-full">Last Updated</span><span class="label-short">Updated</span></th>
+ </tr>
+ </thead>
<tbody>
{{ range .Repos }}
<tr>
- <td class="name"><a href="/{{ .Slug }}/">{{ .Title }}</a></td>
- <td class="desc">{{ with .Description }}{{ . }}{{ else }}<span class="muted">No description set</span>{{ end }}</td>
- <td class="when">{{ with .LastCommit }}<time datetime="{{ rfc3339 . }}">{{ date . }}</time>{{ else }}<span class="muted">empty</span>{{ end }}</td>
+ <td class="repo-name">
+ <a href="/{{ .Slug }}/">{{ .Title }}</a>
+ {{ with .Owner }}<span class="repo-owner">{{ . }}</span>{{ end }}
+ </td>
+ <td class="repo-desc">{{ with .Description }}{{ . }}{{ else }}<span class="muted">No description set</span>{{ end }}</td>
+ <td class="repo-meta">{{ with .LastCommit }}<time datetime="{{ rfc3339 . }}">{{ date . }}</time>{{ else }}<span class="muted">empty</span>{{ end }}</td>
</tr>
{{ else }}
- <tr><td class="empty">No repositories yet. Create a bare repo under the configured root.</td></tr>
+ <tr><td colspan="3" class="text-center text-muted" style="padding: 2rem;">No repositories yet. Create a bare repo under the configured root.</td></tr>
{{ end }}
</tbody>
</table>
+</div>
{{ end }}
-{{ end }}
+{{ end }}
\ No newline at end of file
diff --git a/templates/views/log.html b/templates/views/log.html
index 92be263..d277823 100644
--- a/templates/views/log.html
+++ b/templates/views/log.html
@@ -1,8 +1,69 @@
{{ define "log.html" }}{{ template "base" . }}{{ end }}
{{ define "main" }}
-<header class="page-head">
- <h1>Log</h1>
- <p class="sub"><code>{{ .Ref }}</code></p>
-</header>
-{{ template "commit-table" (dict "Base" (print "/" .Info.Slug) "Commits" (mod "log" "200")) }}
+{{ $slug := print "/" .Info.Slug }}
+{{ $verify := .Info.GPGKeyring }}
+{{ $m := mod "meta" }}
+{{/* "20" here must match core.LogPageSize -- buildCtx uses that constant
+ to snap incoming ?ofs= values onto real page boundaries. */}}
+{{ $page := mod "log" "20" }}
+
+ <header class="hero">
+ <div class="hero-inner">
+ <h1 class="page-title">Commit History</h1>
+ <p class="page-subtitle">
+ Showing <span style="color:var(--accent);font-weight:600;">{{ len $page.Commits }}</span> commits
+ </p>
+ </div>
+ </header>
+
+{{/* `with` on the slice, not index 0 -- an empty repo has no commits. */}}
+{{ with $page.Commits }}
+ <div class="table-wrap">
+<table class="table">
+ <thead>
+ <tr>
+ <th>Hash</th>
+ <th>Subject</th>
+ <th>Author</th>
+ {{ if $verify }}<th>Signature</th>{{ end }}
+ <th class="text-right">Date</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{ range . }}
+ <tr>
+ <td class="font-mono text-sm"><a href="{{ $slug }}/commit/{{ .SHA }}">{{ .Short }}</a></td>
+ <td><a href="{{ $slug }}/commit/{{ .SHA }}">{{ .Subject }}</a></td>
+ <td class="text-sm">{{ .Author }}</td>
+ {{ if $verify }}
+ <td class="text-sm">
+ {{ if .Verified }}<span class="chip chip-ok" title="{{ .Signer }} &middot; {{ .KeyID }}">verified</span>
+ {{ else if .Unchecked }}<span class="chip" title="key {{ .KeyID }} not in keyring">unknown key</span>
+ {{ else if .Signed }}<span class="chip chip-bad" title="{{ .KeyID }}">{{ .SigLabel }}</span>
+ {{ else }}<span class="text-muted">&mdash;</span>{{ end }}
+ </td>
+ {{ end }}
+ <td class="text-right text-sm text-muted"><time datetime="{{ rfc3339 .When }}">{{ date .When }}</time></td>
+ </tr>
+ {{ end }}
+ </tbody>
+ </table>
+</div>
+
+{{ else }}
+ {{ if $page.HasPrev }}
+ <div class="notice" style="text-align:center;"><span class="text-muted">No commits at this offset.</span></div>
+ {{ else }}
+ <div class="card" style="padding:2rem;text-align:center;">
+ <p class="muted">No commits yet. This is a fresh repository.</p>
+ </div>
+ {{ end }}
{{ end }}
+
+{{ if or $page.HasPrev $page.HasNext }}
+ <nav class="pager" style="display:flex;justify-content:space-between;margin-top:1rem;">
+ <span>{{ if $page.HasPrev }}<a href="?ofs={{ $page.PrevOfs }}">&larr; Newer</a>{{ end }}</span>
+ <span>{{ if $page.HasNext }}<a href="?ofs={{ $page.NextOfs }}">Older &rarr;</a>{{ end }}</span>
+ </nav>
+{{ end }}
+{{ end }}
\ No newline at end of file
diff --git a/templates/views/refs.html b/templates/views/refs.html
index 6c8671d..128948c 100644
--- a/templates/views/refs.html
+++ b/templates/views/refs.html
@@ -1,34 +1,60 @@
{{ define "refs.html" }}{{ template "base" . }}{{ end }}
{{ define "main" }}
-{{ $r := mod "refs" }}{{ $slug := print "/" .Info.Slug }}
-<header class="page-head">
- <h1>Refs</h1>
- <p class="sub">{{ len $r.Branches }} branches &middot; {{ len $r.Tags }} tags</p>
-</header>
+{{ $slug := print "/" .Info.Slug }}
+{{ $r := mod "refs" }}
+
+ <header class="hero">
+ <div class="hero-inner">
+ <h1 class="page-title">References</h1>
+ <p class="page-subtitle">
+ <span class="text-muted">{{ len $r.Branches }} branches &middot; {{ len $r.Tags }} tags</span>
+ </p>
+ </div>
+ </header>
+
<section>
- <h2>Branches</h2>
- <table class="listing refs"><tbody>
- {{ range $r.Branches }}
- <tr>
- <td class="name"><a href="{{ $slug }}/tree/{{ .Name }}">{{ .Name }}</a></td>
- <td class="desc">{{ trunc 64 .Title }}</td>
- <td class="when"><time datetime="{{ rfc3339 .When }}">{{ date .When }}</time></td>
- <td class="act"><a class="ghost" href="{{ $slug }}/compare/{{ $.Ref }}...{{ .Name }}">compare</a></td>
- </tr>
- {{ else }}<tr><td class="empty" colspan="4">No branches.</td></tr>{{ end }}
- </tbody></table>
+ <h2 class="page-subtitle">Branches</h2>
+ <div class="table-wrap">
+<table class="table">
+ <thead>
+ <tr><th>Name</th><th>Subject</th><th class="text-right">Updated</th><th></th></tr>
+ </thead>
+ <tbody>
+ {{ range $r.Branches }}
+ <tr>
+ <td><a class="chip chip-branch" href="{{ $slug }}/tree/{{ .Name }}">{{ .Name }}</a></td>
+ <td class="text-sm text-muted">{{ trunc 64 .Title }}</td>
+ <td class="text-right text-sm text-muted"><time datetime="{{ rfc3339 .When }}">{{ date .When }}</time></td>
+ <td class="text-right"><a class="chip" href="{{ $slug }}/compare/{{ $.Ref }}...{{ .Name }}">compare</a></td>
+ </tr>
+ {{ else }}
+ <tr><td class="text-center text-muted" colspan="4">No branches.</td></tr>
+ {{ end }}
+ </tbody>
+ </table>
+</div>
</section>
-<section>
- <h2>Tags</h2>
- <table class="listing refs"><tbody>
- {{ range $r.Tags }}
- <tr>
- <td class="name"><a href="{{ $slug }}/tree/{{ .Name }}">{{ .Name }}</a></td>
- <td class="desc">{{ trunc 64 .Title }}</td>
- <td class="when"><time datetime="{{ rfc3339 .When }}">{{ date .When }}</time></td>
- <td class="act"><a class="ghost" href="{{ $slug }}/compare/{{ $.Ref }}...{{ .Name }}">compare</a></td>
- </tr>
- {{ else }}<tr><td class="empty" colspan="4">No tags.</td></tr>{{ end }}
- </tbody></table>
+
+<section style="margin-top:1.5rem;">
+ <h2 class="page-subtitle">Tags</h2>
+ <div class="table-wrap">
+<table class="table">
+ <thead>
+ <tr><th>Name</th><th>Subject</th><th class="text-right">Created</th><th></th></tr>
+ </thead>
+ <tbody>
+ {{ range $r.Tags }}
+ <tr>
+ <td><a class="chip chip-tag" href="{{ $slug }}/tree/{{ .Name }}">{{ .Name }}</a></td>
+ <td class="text-sm text-muted">{{ trunc 64 .Title }}</td>
+ <td class="text-right text-sm text-muted"><time datetime="{{ rfc3339 .When }}">{{ date .When }}</time></td>
+ <td class="text-right"><a class="chip" href="{{ $slug }}/compare/{{ $.Ref }}...{{ .Name }}">compare</a></td>
+ </tr>
+ {{ else }}
+ <tr><td class="text-center text-muted" colspan="4">No tags.</td></tr>
+ {{ end }}
+ </tbody>
+ </table>
+</div>
</section>
{{ end }}
diff --git a/templates/views/repo.html b/templates/views/repo.html
deleted file mode 100644
index cc4a4a1..0000000
--- a/templates/views/repo.html
+++ /dev/null
@@ -1,41 +0,0 @@
-{{ define "repo.html" }}{{ template "base" . }}{{ end }}
-{{ define "main" }}
-{{ $slug := print "/" .Info.Slug }}
-{{ $m := mod "meta" }}
-
-<header class="page-head">
- <h1>{{ $m.Title }}</h1>
- {{ with $m.Description }}<p class="sub">{{ . }}</p>{{ end }}
-</header>
-
-<dl class="facts">
- <dt>branch</dt><dd><a href="{{ $slug }}/tree/{{ $m.DefaultRef }}"><code>{{ $m.DefaultRef }}</code></a></dd>
- <dt>updated</dt><dd>{{ with $m.LastCommit }}<time datetime="{{ rfc3339 . }}">{{ date . }}</time>{{ else }}<span class="muted">never</span>{{ end }}</dd>
- {{ with $m.Section }}<dt>section</dt><dd><a href="/#{{ slug . }}">{{ . }}</a></dd>{{ end }}
- {{ with $m.Clone }}<dt>clone</dt><dd><code class="clone">{{ . }}</code></dd>{{ end }}
- {{ with $m.Owner }}<dt>owner</dt><dd>{{ . }}</dd>{{ end }}
-</dl>
-
-{{/* `with` on the slice, not index 0 -- an empty repo has no commits and
- indexing would abort the render. */}}
-{{ with mod "log" "1" }}{{ with index . 0 }}
-<a class="latest" href="{{ $slug }}/commit/{{ .SHA }}">
- <span class="latest-sha">{{ .Short }}</span>
- <span class="latest-subject">{{ trunc 88 .Subject }}</span>
- <span class="latest-meta">{{ .Author }} &middot; <time datetime="{{ rfc3339 .When }}">{{ date .When }}</time></span>
-</a>
-{{ end }}{{ end }}
-
-{{ with modOr "readme" }}
- <div class="prose readme">{{ .HTML }}</div>
-{{ else }}
- <p class="notice">No README on <code>{{ $.Ref }}</code>. Browse the <a href="{{ $slug }}/tree/{{ $.Ref }}">files</a> instead.</p>
-{{ end }}
-
-{{/* Everything else moved to its own page. Modules are lazy, so the ones
- this view no longer calls cost nothing -- to put authors back, this is
- the entire change:
- {{ with mod "authors" }}
- <ul class="chips">{{ range . }}<li class="chip">{{ .Name }} <b>{{ .Commits }}</b></li>{{ end }}</ul>
- {{ end }} */}}
-{{ end }}
diff --git a/templates/views/tree.html b/templates/views/tree.html
index 840ed10..181d8bb 100644
--- a/templates/views/tree.html
+++ b/templates/views/tree.html
@@ -1,15 +1,90 @@
{{ define "tree.html" }}{{ template "base" . }}{{ end }}
+{{ define "styles" }}
+<link rel="stylesheet" href="{{ static "css/repo.css" }}">
+<link rel="stylesheet" href="{{ static "css/readme.css" }}">
+{{ end }}
{{ define "main" }}
-{{ $t := mod "tree" }}{{ $slug := print "/" .Info.Slug }}
-<header class="page-head">
- <nav class="crumbs">
- <a href="{{ $slug }}/tree/{{ .Ref }}">{{ .Info.Title }}</a>
- {{ range $t.Crumbs }}<span class="slash">/</span><a href="{{ $slug }}/tree/{{ $.Ref }}/{{ .Path }}">{{ .Name }}</a>{{ end }}
- </nav>
- <p class="sub"><code>{{ .Ref }}</code> &middot; {{ len $t.Entries }} entries</p>
+{{ $slug := print "/" .Info.Slug }}
+{{ $root := eq .Path "" }}
+
+{{ if $root }}
+{{ $m := mod "meta" }}
+<header class="hero">
+ <div class="hero-inner">
+ <h1 class="page-title">{{ $m.Title }}</h1>
+ {{ with $m.Description }}<p class="page-subtitle">{{ . }}</p>{{ end }}
+ </div>
</header>
-{{ template "tree-table" (dict "Base" $slug "Ref" .Ref "Entries" $t.Entries) }}
+
+<div class="facts-bar">
+ <div class="fact">
+ <span class="fact-label">default branch</span>
+ <span class="fact-value"><a href="{{ $slug }}/tree/{{ $m.DefaultRef }}">{{ $m.DefaultRef }}</a></span>
+ </div>
+ <div class="fact">
+ <span class="fact-label">updated</span>
+ <span class="fact-value">{{ with $m.LastCommit }}<time datetime="{{ rfc3339 . }}">{{ date . }}</time>{{ else }}<span class="muted">never</span>{{ end }}</span>
+ </div>
+ {{ with $m.Section }}<div class="fact">
+ <span class="fact-label">section</span>
+ <span class="fact-value"><a href="/#{{ slug . }}">{{ . }}</a></span>
+ </div>{{ end }}
+ {{ with $m.Clone }}<div class="fact">
+ <span class="fact-label">clone</span>
+ <span class="fact-value"><code>{{ . }}</code></span>
+ </div>{{ end }}
+ {{ with $m.Owner }}<div class="fact">
+ <span class="fact-label">owner</span>
+ <span class="fact-value">{{ . }}</span>
+ </div>{{ end }}
+</div>
+
+{{/* `with` on .Commits, not index 0 -- an empty repo has no commits and
+ indexing would abort the render. */}}
+{{ with (mod "log" "1").Commits }}{{ with index . 0 }}
+<a class="latest-commit" href="{{ $slug }}/commit/{{ .SHA }}">
+ <span class="commit-hash">{{ .Short }}</span>
+ <span class="commit-msg">{{ trunc 88 .Subject }}</span>
+ <span class="commit-meta">{{ .Author }} &middot; <time datetime="{{ rfc3339 .When }}">{{ date .When }}</time></span>
+</a>
+{{ end }}{{ end }}
+{{ end }}
+
+{{/* An empty repo (unborn HEAD) has no commit to list a tree at -- mod
+ "tree" would 404 on it, where this page used to render fine. Only call
+ it once there's a real commit to resolve against. */}}
+{{ if .Commit }}
+{{ $tree := mod "tree" }}
+{{ if $root }}
+ <p class="text-muted">
+ <code style="color:var(--accent);">{{ .Ref }}</code>
+ &middot; {{ len $tree.Entries }} entries
+ </p>
+{{ else }}
+ <header class="hero">
+ <div class="hero-inner">
+ <h1 class="page-title">Files</h1>
+ <p class="page-subtitle">
+ <code style="color:var(--accent);">{{ .Ref }}</code>
+ <span class="text-muted">&middot; {{ len $tree.Entries }} entries{{ with $tree.Path }} in <code>{{ . }}</code>{{ end }}</span>
+ </p>
+ </div>
+ </header>
+{{ end }}
+
+{{ template "tree-table" (dict "Base" $slug "Ref" .Ref "Entries" $tree.Entries) }}
+{{ end }}
+
+{{ if $root }}
{{ with modOr "readme" }}
-<section class="prose-wrap"><div class="prose">{{ .HTML }}</div></section>
+ <div class="readme">{{ .HTML }}</div>
+{{ end }}
{{ end }}
+
+{{/* Everything else moved to its own page. Modules are lazy, so the ones
+ this view no longer calls cost nothing -- to put authors back, this is
+ the entire change:
+ {{ with mod "authors" }}
+ <ul class="chips">{{ range . }}<li class="chip">{{ .Name }} <b>{{ .Commits }}</b></li>{{ end }}</ul>
+ {{ end }} */}}
{{ end }}