Migration
This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs.
Contact me
Compare
3a50036d96eb14462e96598fd169d1c79264ac4b
…
main
· 9 commits
Commits
| Hash | Subject | Author | Date |
|---|---|---|---|
| 554bcfc6 | invalid var name and version bump | Ryan Schanzenbacher | |
| d658fcc4 | merge caching trials back in and solve conflicts | Ryan Schanzenbacher | |
| cb5a7c7c | add human readme | Ryan Schanzenbacher | |
| 79436f09 | cleanup some code that can be shared | Ryan Schanzenbacher | |
| 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 |
@@ -50,9 +50,13 @@ codebase worse.
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/access/ repo visibility + share-grant policy (imports no core)
+internal/authn/ login/logout endpoints
+internal/httpx/ shared admin/share HTTP security helpers
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
+internal/site/ the route table and route-specific request parsers
templates/ layouts/ + views/
static/ one stylesheet
```
@@ -67,6 +71,11 @@ registry. It must not know git semantics. If you find yourself importing
`gitx` into `core` for anything beyond the `Handle`/`Repo` plumbing that is
already there, stop and reconsider.
+**`internal/access`** owns the rule for whether an identity may view or
+share a repo. It deliberately imports nothing from `core`, so the 404 gate,
+the index module, share minting and share redemption all call the same
+policy. Do not grow a second visibility check elsewhere.
+
**`internal/modules`** is where features go. A module is:
```go
@@ -127,6 +136,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 +163,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`
@@ -218,7 +246,9 @@ A module for the data, a view in `templates/views/`, one entry in
template trees, because `html/template` has a single namespace for defined
blocks and two views both defining `main` would collide. That is why
`LoadTemplates` parses each view with the layouts rather than globbing
-everything together.
+everything together. If the route needs a new path grammar, declare a
+`Route.Request` parser in `internal/site`; core still resolves every ref it
+returns. Pagination is declared with `Route.PageSize`.
### Add a JSON endpoint
@@ -237,6 +267,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 +346,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.
@@ -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"]
@@ -1,256 +1,52 @@
-# gsg — a modular git browser
-
-cgit's weight, Hugo's composability, served dynamically. A **module**
-computes one piece of a view; a **route** binds a renderer to a URL pattern.
-Adding a feature means adding a module and calling it from a template —
-`internal/core` never changes.
-
-One dependency (`goldmark`, which itself has none) plus the `git` binary. Go 1.22+ (uses `net/http`
-pattern wildcards, so there is no router dependency either).
-
-```
-go build -o gsg .
-./gsg -root /srv/git -addr :8080 -name "rschanz.org"
-./gsg -root /srv/git -templates ./templates # re-parse the theme per request
-```
-
-## The idea
-
-```go
-type Module func(c *Ctx, args ...string) (any, error)
-```
-
-Templates reach modules through one funcmap entry:
-
-```gotmpl
-{{ with mod "meta" }}<h1>{{ .Title }}</h1>{{ end }}
-{{ $d := mod "diff" }}
-{{ with modOr "readme" }}{{ .HTML }}{{ end }}
-```
-
-- **Lazy.** A template that calls `mod "authors"` is what *causes* authors to
- be computed. Views that don't ask don't pay.
-- **Memoized twice.** Once per request (`Ctx.cache`), and once process-wide
- against the resolved commit SHA (`core.LRU`).
-- **No manifest.** Dependencies are declared by use. There is no module list
- to keep in sync with the templates.
-
-`internal/modules/example_authors.go` is a live demonstration: 30 lines, an
-`init()`, and one `{{ with mod "authors" }}` block. Nothing else moved.
-
-## Pages and endpoints are the same mechanism
-
-A route is `{pattern, renderer}`. `core.View` renders a template,
-`core.JSON` serializes an endpoint. Both take a `*Ctx`, both call modules.
-
-```go
-{Name: "commit", Immutable: true,
- Pattern: "GET /{repo}/commit/{rev}",
- Render: core.View{Template: "commit.html"}},
-
-{Name: "api-commit", Immutable: true,
- Pattern: "GET /{repo}/api/commit/{rev}",
- Render: core.JSON{Build: api.CommitEndpoint}},
-```
-
-### Endpoints use DTOs, not module values
-
-`internal/api` defines explicit structs and never marshals a module's return
-value. Go marshals every exported field by default, so serializing a module
-type means the day someone adds `Body` to `gitx.Commit`, commit messages are
-public. The allowlist is one readable file.
-
-```
-GET /api/repos.json site index
-GET /{repo}/api/repo.json slug, title, description, owner, section,
- clone_url, default_branch, last_commit
-GET /{repo}/api/commit/{sha} sha, short, authored, parents, and
- file/line counts — no subject, no body
-```
-
-## Routes
-
-| Route | URL |
-|---|---|
-| index | `/` |
-| repo | `/{repo}/` — default branch, last update, latest commit, README |
-| files | `/{repo}/tree/{ref}/{path}` |
-| file | `/{repo}/blob/{ref}/{path}` |
-| **raw bytes** | `/{repo}/raw/{ref}/{path}` |
-| log | `/{repo}/log/{ref}` |
-| refs | `/{repo}/refs/` |
-| commit | `/{repo}/commit/{sha}` |
-| **compare** | `/{repo}/compare/{base}..{head}` or `{base}...{head}` |
-| **branch graph** | `/{repo}/graph/` |
-
-### Compare
-
-`a..b` is the literal endpoint difference. `a...b` diffs from the merge
-base, which is what you want when reviewing a branch — it excludes changes
-that landed on the base since the branch forked. Verified against
-`git merge-base` on a diverged fixture: `topic..main` lists `topic.txt` as
-removed, `topic...main` does not.
-
-### Raw files
-
-A repository can contain an `.html` file with a `<script>` tag. Serving that
-as `text/html` on your own origin turns any push into stored XSS against the
-whole site — which is why GitHub puts raw content on
-`raw.githubusercontent.com`, a separate origin. Same-host serving is only
-safe if the response cannot be interpreted as a document, so `/raw/` always
-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.
-
-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.
-
-### Branch graph
-
-`modules/graph.go` lays out the DAG in lanes in one pass over
-`git log --all --topo-order`, which guarantees a child is emitted before its
-parents. `active` holds, per lane, the SHA that lane is waiting to draw; a
-commit claims the lane expecting it (or a fresh one), hands that lane to its
-first parent, and opens a new lane per additional parent — which is what
-makes a merge fork visually.
-
-The module emits the per-row SVG because the geometry *is* the data, but
-every stroke refers to a CSS custom property (`--lane-0` … `--lane-5`), so
-the theme still owns the palette. Row height in the SVG must stay in sync
-with `table.graph td { height }` in the stylesheet.
-
-## Caching, which is the whole reason this works dynamically
-
-Every module result is a pure function of `(repo, commit SHA, path, args)`.
-Refs move; SHAs do not. So the process-wide cache is keyed on the resolved
-SHA and needs **no invalidation logic at all** — a stale entry is impossible
-by construction.
-
-`Ctx.Vars` are part of the cache key, not just the commit. They were not
-originally, and the bug was exactly the kind this cache should be immune to:
-`/compare/a..b` and `/compare/a...b` resolve to the same head commit, so the
-second request was served the first one\'s diff. Anything that changes what a
-module computes has to be in the key.
-
-ETags are derived from the resolved SHA plus a digest of every template
-file, *not* from the response body. A conditional request costs one ref
-lookup instead of a render, and restyling the theme invalidates every ETag
-at once (without the template digest, a restyled page keeps serving 304s).
-
-Two URL classes, two policies:
-
-| URL shape | Example | Cache-Control |
-|---|---|---|
-| SHA-addressed | `/demo/commit/64b90fff…` | `max-age=31536000, immutable` |
-| ref-addressed | `/demo/tree/main` | `no-cache` (revalidate via ETag) |
-
-## Ref-vs-path ambiguity
-
-`/{repo}/tree/{rest...}` fuses them, and `gitx.SplitRefPath` separates them
-by **longest matching prefix against the repo's actual ref list**, so
-`feature/x/src/main.go` resolves correctly. Splitting at the first `/` is
-wrong because branch names contain slashes; cgit dodges this with `?h=`
-query params, GitHub does what this does.
-
-## Serving is an attack surface; static generation was not
-
-- **Ref input never reaches `git rev-parse`.** rev-parse accepts an
- expression language — `:/regex` searches history, `@{...}` reads the
- reflog — and git subcommands treat a leading `-` as an option.
- `gitx.Resolve` accepts either a hex SHA that already exists or an exact
- match against the repo's own ref list. Verified: `--upload-pack=…`,
- `:/secret`, `HEAD@{0}` and `-n` all return 404 with no side effects.
-- **Every git call is `exec.CommandContext`** with a per-request deadline
- (`-timeout`, default 10s) and a scrubbed environment
- (`GIT_CONFIG_NOSYSTEM=1`, no `HOME`, no terminal prompt).
-- **Output is capped** at `MaxGit` (8 MiB) per command; a diff that exceeds
- it renders truncated rather than eating the heap.
-
-## Layout
-
-```
-main.go flags, repo discovery, embedded assets
-internal/gitx/ the ONLY package that knows git exists
-internal/core/core.go Ctx, RepoInfo, module registry, Store
-internal/core/route.go Route, View, JSON
-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/api/ JSON DTOs — the serialization allowlist
-internal/site/routes.go the route table (the file you edit most)
-templates/ layouts/ + views/
-```
-
-`gitx` shells out to plumbing, one process per query, `git log` in a single
-pass. Swapping in go-git means rewriting that one package.
-
-## Sections
-
-`gsg.section` groups repositories on the index. The `sections` module builds
-on `repos` rather than re-walking the repo set, so the index pays for its log
-lookups once. When no repo sets a section, it returns a single unnamed group
-and the template renders a flat list — no config, no headings.
-
-```sh
-git --git-dir=/srv/git/demo.git config gsg.section "Infrastructure"
-```
-
-## README rendering
-
-`modules/markdown.go` uses goldmark with GFM, Typographer, and automatic
-heading IDs — Hugo\'s own goldmark defaults, so a README renders identically
-to the same file on your blog.
-
-`WithUnsafe` is deliberately not set, which is also Hugo\'s default. Anyone
-who can push can write a README, so raw HTML in markdown is untrusted input;
-goldmark drops it. A `<script>` committed to a README comes out as
-`<!-- raw HTML omitted -->`.
-
-Relative links are rewritten against the file\'s own directory: links become
-the blob view, images become `/raw/` (an `<img>` needs bytes, not an HTML
-page). Absolute URLs, site-absolute paths, fragments and `mailto:` are left
-alone, and anything resolving above the repo root is not rewritten.
-
-## Per-repository config
-
-Read from each repo's own git config, cgit-style, with `gitweb.*` and the
-legacy `description` file as fallbacks, so an existing cgit tree works as-is:
-
-```sh
-git --git-dir=/srv/git/demo.git config gsg.title "Demo Project"
-git --git-dir=/srv/git/demo.git config gsg.description "A demo repository"
-git --git-dir=/srv/git/demo.git config gsg.clone "git://rschanz.org/demo"
-git --git-dir=/srv/git/demo.git config gsg.hide true
-```
-
-Add a key by adding a field to `core.RepoInfo` and a line in `LoadRepoInfo`.
-
-## Theme
-
-Monospace-first: every value on these pages is an identifier, hash, path or
-count, so the interface type is the same face as the code, and the sans face
-appears only inside rendered README prose. A cool slate ramp, one indigo
-accent, and a six-colour lane ramp shared by the commit graph and its branch
-chips. Light and dark both defined via `prefers-color-scheme`.
-
-Diffs mark the gutter rather than flooding whole rows with green and red —
-the signal stays readable when a hunk is mostly additions.
-
-No webfonts; system stacks only, so there is no third-party request. All
-colour lives in `:root` custom properties in `static/style.css`.
-
-## Known gaps
-
-- **No syntax highlighting.** `blob` returns numbered plain lines; Chroma
- drops into the same module.
-- **No pagination on `log` or `graph`.** They cap at 200 and 150 commits.
-- **The graph loads all refs into memory** before laying out. Fine to a few
- thousand commits; beyond that it needs windowing.
-- **The LRU is bounded by entry count, not bytes.** A giant diff and a ref
- list cost the same slot. The `Store` interface is the only thing a
- size-aware replacement has to satisfy.
-- **Repo discovery is one level deep** and happens once at startup; a repo
- created later needs a restart.
-- **Module errors surface at render time**, not load time. Add a CI pass
- that renders every view against a fixture repo.
+# My git viewer
+
+### Why I did this
+
+So, the premise behind this project was a few different things:
+
+- I wanted a git viewer stack that, while modular, remained light.
+- I liked the idea of having reusable "templates" rather than generating the HTML dynamically in the code. This is much closer to how things like Hugo operate.
+- It needs to be easy to extend (modularity!). A core is responsible for loading a git repo's context and other request state, but all "real" functionality is implemented via modules. I could add/remove things without needing to worry (too much...) about breaking unrelated things.
+- I wanted something that was modern, but didn't require javascript to operate. All features on this site work without javascript, though your experience is improved with it.
+- I wanted to have a way to view private git repositories using systems I already have.
+ - I don't want to/have the capability to SSH clone my repos to show something off.
+ - Could I have done this in cgit? Perhaps, but it wasn't built for it.
+- And, probably most visibly, this was a trial in "vibe coding". While the idea around the design of this software was mine, I wrote very little of the code. Especially the UI, and this is for the better. I am not a graphics designer and whatever slop the LLM pumps out (with my input here and there) will be leagues better than whatever I do; for better or worse.
+
+The name "gsg" doesn't mean much (I don't name things). It're pretty much "git static generator". It was the placeholder when I was thinking of a system that was "kind of a combination of cgit's lightness and hugo's composability and templating".
+
+### The design
+
+First and foremost, the AI writeup is in ./llm_readme.md. Read that if you want all of the verbose details.
+
+Also, as alluded to above, no javascript is strictly required. The few places it is used it is just used to make the experience nicer, and a fallback always exists. For example, the JSON/YAML codepath has a JS module that allows you to search for values/keys, perform folding, etc. This is a core design tenant: JS always hydrates what exists already.
+
+(Not) Broadly speaking, as stated above, this project is split up into:
+
+- The core
+ - gitx - the module that actually interfaces with git. This transforms a context into a re-usable struct (should I ever want to use a real implementation)
+ - access - Consolidated checks for whether someone can see/not see a repo
+ - session - Owns all privileged access logic. It generates/verifies tokens that are used across the site. Everything is stateless once authenticated.
+ - authn - Route owner that defines login/logout flows. Uses session internally
+ - httpx - Shared helpers used when generating responses/evaling a req
+ - notices - Logic for me to post notices to the site or repos
+ - share - Logic for authenticated users to mint links to private repos they are allowed to. Uses same auth logic as users under the hood.
+ - api - Exposes repo details as a JSON endpoint, will be used by my main site later on
+ - core/site - All the main things needed to make this tick. Build req context, define routes and where they live, define module registration structs.
+- The modules
+ - blob - Responsible for returning files (raw or templated)
+ - commit - Render a commit
+ - compare - Render a diff between two commits/refs
+ - graph - Render commit log graph
+ - image - Image preview, defines compatible file types and max size
+ - markdown - Render markdown
+ - readme - Inherits markdown to render a readme (what you see here!)
+ - notices - Handles cache invalidation for repos that need one rendered
+ - log - View the git log
+ - repos - Aggregates the repos you may view based on session
+ - structured - Whether a file is a YAML or JSON. If so, it prepares data for JS in the frontend to perform folding and suchA
+ - tree - the directory-structure viewer
+
+As time goes on, I am sure I will find more things to add to this. This is really meant to be a project for me, not necessarily building "the next git viewer for everyone".
+
+I figured I would upload this here in case my tokens are useful for others. Enjoy!
@@ -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
+)
@@ -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=
@@ -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.
@@ -0,0 +1,82 @@
+// Package access holds the rules that decide who may see or share a
+// repository. It deliberately imports nothing from core: the visibility
+// rule is used by the 404 gate, the index module, share-link minting, and
+// share-link redemption, and putting it in one dependency-free place is
+// what keeps those four from drifting apart.
+package access
+
+import (
+ "strings"
+ "time"
+)
+
+// Policy is the authorization-relevant subset of a repository's config.
+// core.RepoInfo is the full config; Policy is what the rules below need.
+type Policy struct {
+ Slug string
+ Hidden bool
+ VisibleTo []string
+ ShareRevokedAfter time.Time
+}
+
+// Identity is the verified caller. SharedSlugs/SharedIssuedAt come from a
+// valid gsg_share cookie and are empty when the caller has no share grant.
+type Identity struct {
+ User string
+ SharedSlugs []string
+ SharedIssuedAt time.Time
+}
+
+// CanView reports whether id may see the repository at all. Public repos
+// are visible to everyone; a hidden repo is visible to an owner named in
+// gsg.visible-to or to an unrevoked share grant.
+func (p Policy) CanView(id Identity) bool {
+ if !p.Hidden {
+ return true
+ }
+ return p.canViewAsOwner(id.User) ||
+ (p.coversShare(id.SharedSlugs) && ShareLive(p.ShareRevokedAfter, id.SharedIssuedAt))
+}
+
+// CanShare reports whether user may mint a share link for this repository.
+// Only hidden repos are shareable (sharing a public repo would grant
+// nothing new), and only an owner may mint -- a share grant cannot be
+// amplified into a broader grant.
+func (p Policy) CanShare(user string) bool {
+ return p.Hidden && p.canViewAsOwner(user)
+}
+
+// canViewAsOwner matches gsg.visible-to case-insensitively. Anonymous
+// callers never match: "" would otherwise vacuous-match a blank entry.
+func (p Policy) canViewAsOwner(user string) bool {
+ if user == "" {
+ return false
+ }
+ for _, u := range p.VisibleTo {
+ if strings.EqualFold(u, user) {
+ return true
+ }
+ }
+ return false
+}
+
+func (p Policy) coversShare(slugs []string) bool {
+ for _, s := range slugs {
+ if s == p.Slug {
+ return true
+ }
+ }
+ return false
+}
+
+// ShareLive reports whether a grant issued at issuedAt survives a
+// repository's gsg.share-revoked-after value. A zero revokedAfter means no
+// revocation; a grant issued at or before the timestamp is revoked, which
+// is why the comparison is strict -- the operator's intent is "everything
+// issued before this moment is dead", and the two sides are second-granular.
+//
+// The free function is for callers (share redemption) that only have the
+// timestamp, not a full Policy.
+func ShareLive(revokedAfter, issuedAt time.Time) bool {
+ return revokedAfter.IsZero() || issuedAt.After(revokedAfter)
+}
@@ -0,0 +1,107 @@
+package access
+
+import (
+ "testing"
+ "time"
+)
+
+func TestCanView(t *testing.T) {
+ revoked := time.Unix(1_700_000_000, 0)
+ granted := revoked.Add(time.Minute)
+
+ cases := []struct {
+ name string
+ p Policy
+ id Identity
+ want bool
+ }{
+ {
+ name: "public repo",
+ p: Policy{Slug: "demo"},
+ want: true,
+ },
+ {
+ name: "hidden anonymous",
+ p: Policy{Slug: "demo", Hidden: true},
+ want: false,
+ },
+ {
+ name: "hidden owner",
+ p: Policy{Slug: "demo", Hidden: true, VisibleTo: []string{"Alice"}},
+ id: Identity{User: "alice"},
+ want: true,
+ },
+ {
+ name: "hidden non-owner",
+ p: Policy{Slug: "demo", Hidden: true, VisibleTo: []string{"alice"}},
+ id: Identity{User: "bob"},
+ want: false,
+ },
+ {
+ name: "hidden share grant",
+ p: Policy{Slug: "demo", Hidden: true},
+ id: Identity{SharedSlugs: []string{"demo"}, SharedIssuedAt: granted},
+ want: true,
+ },
+ {
+ name: "share grant for another repo",
+ p: Policy{Slug: "demo", Hidden: true},
+ id: Identity{SharedSlugs: []string{"other"}, SharedIssuedAt: granted},
+ want: false,
+ },
+ {
+ name: "share grant before revocation",
+ p: Policy{Slug: "demo", Hidden: true, ShareRevokedAfter: revoked},
+ id: Identity{SharedSlugs: []string{"demo"}, SharedIssuedAt: revoked},
+ want: false,
+ },
+ {
+ name: "share grant after revocation",
+ p: Policy{Slug: "demo", Hidden: true, ShareRevokedAfter: revoked},
+ id: Identity{SharedSlugs: []string{"demo"}, SharedIssuedAt: granted},
+ want: true,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := tc.p.CanView(tc.id); got != tc.want {
+ t.Fatalf("CanView() = %v, want %v", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestCanShare(t *testing.T) {
+ p := Policy{Slug: "demo", Hidden: true, VisibleTo: []string{"alice"}}
+
+ if !p.CanShare("alice") {
+ t.Fatal("owner should be able to share a hidden repo")
+ }
+ if p.CanShare("bob") {
+ t.Fatal("non-owner should not be able to share")
+ }
+ if p.CanShare("") {
+ t.Fatal("anonymous should not be able to share")
+ }
+ if (Policy{Slug: "public", VisibleTo: []string{"alice"}}).CanShare("alice") {
+ t.Fatal("public repo should not be shareable")
+ }
+}
+
+func TestShareLive(t *testing.T) {
+ revoked := time.Unix(1_700_000_000, 0)
+
+ if !ShareLive(time.Time{}, revoked) {
+ t.Fatal("zero revokedAfter should not revoke")
+ }
+ if ShareLive(revoked, revoked) {
+ t.Fatal("grant issued at the revocation time should be revoked")
+ }
+ if ShareLive(revoked, revoked.Add(-time.Second)) {
+ t.Fatal("grant issued before revocation should be revoked")
+ }
+ if !ShareLive(revoked, revoked.Add(time.Second)) {
+ t.Fatal("grant issued after revocation should survive")
+ }
+}
@@ -0,0 +1,79 @@
+// Package authn owns gsg's browser session endpoints. Login trusts the
+// identity header that the reverse proxy sets after forward_auth; logout
+// only clears gsg's own cookie. Keeping both here means main.go composes
+// listeners rather than implementing authentication.
+//
+// The two handlers are returned separately on purpose: login must only ever
+// be mounted on the admin listener, while logout belongs on the public one.
+// A single combined handler would make that distinction a calling
+// convention instead of a structural one.
+package authn
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "gsg/internal/session"
+)
+
+// Config is the small amount of state the session endpoints need.
+type Config struct {
+ Sessions *session.Manager
+ UserHeader string // e.g. "X-Authentik-Username"; defaults to that
+}
+
+// Handlers are mounted individually by the caller.
+type Handlers struct {
+ Login http.Handler
+ Logout http.Handler
+}
+
+// NewHandlers builds the login and logout endpoints.
+func NewHandlers(cfg Config) (Handlers, error) {
+ if cfg.Sessions == nil {
+ return Handlers{}, fmt.Errorf("authn: no session manager")
+ }
+ if cfg.UserHeader == "" {
+ cfg.UserHeader = "X-Authentik-Username"
+ }
+ return Handlers{
+ Login: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { login(cfg, w, r) }),
+ Logout: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { logout(cfg, w, r) }),
+ }, nil
+}
+
+// login mints a session from the proxy-supplied identity. Reaching this
+// handler at all is the authorization decision: the admin listener is the
+// only place it is mounted, and Caddy's forward_auth runs before it.
+func login(cfg Config, w http.ResponseWriter, r *http.Request) {
+ user := r.Header.Get(cfg.UserHeader)
+ if user == "" {
+ http.Error(w, "missing identity header", http.StatusUnauthorized)
+ return
+ }
+ http.SetCookie(w, cfg.Sessions.Issue(user))
+ http.Redirect(w, r, safeNext(r.URL.Query().Get("next")), http.StatusSeeOther)
+}
+
+// logout needs no round trip through the identity provider; it only clears
+// the cookie this process issued.
+func logout(cfg Config, w http.ResponseWriter, r *http.Request) {
+ http.SetCookie(w, cfg.Sessions.Clear())
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+// 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
+}
@@ -0,0 +1,104 @@
+package authn
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "gsg/internal/session"
+)
+
+func TestSafeNext(t *testing.T) {
+ cases := []struct {
+ in, want string
+ }{
+ {"", "/"},
+ {"/", "/"},
+ {"/demo/", "/demo/"},
+ {"/share/new?x=1", "/share/new?x=1"},
+ {"//evil.example", "/"},
+ {"https://evil.example", "/"},
+ {"http://evil.example/path", "/"},
+ {"relative", "/"},
+ }
+ for _, tc := range cases {
+ if got := safeNext(tc.in); got != tc.want {
+ t.Errorf("safeNext(%q) = %q, want %q", tc.in, got, tc.want)
+ }
+ }
+}
+
+func TestLoginRequiresIdentityHeader(t *testing.T) {
+ h := testHandlers(t)
+ req := httptest.NewRequest("GET", "/auth/login", nil)
+ rec := httptest.NewRecorder()
+ h.Login.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
+ }
+ if cookies := rec.Result().Cookies(); len(cookies) != 0 {
+ t.Fatalf("unauthenticated login set cookies: %v", cookies)
+ }
+}
+
+func TestLoginIssuesCookieAndHonorsSafeNext(t *testing.T) {
+ h := testHandlers(t)
+ req := httptest.NewRequest("GET", "/auth/login?next=/demo/", nil)
+ req.Header.Set("X-Authentik-Username", "alice")
+ rec := httptest.NewRecorder()
+ h.Login.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusSeeOther)
+ }
+ if got := rec.Header().Get("Location"); got != "/demo/" {
+ t.Fatalf("Location = %q, want /demo/", got)
+ }
+ cookies := rec.Result().Cookies()
+ if len(cookies) != 1 || cookies[0].Name != session.CookieName {
+ t.Fatalf("cookies = %v, want one %s cookie", cookies, session.CookieName)
+ }
+}
+
+func TestLoginRejectsOffsiteNext(t *testing.T) {
+ h := testHandlers(t)
+ req := httptest.NewRequest("GET", "/auth/login?next=https://evil.example/", nil)
+ req.Header.Set("X-Authentik-Username", "alice")
+ rec := httptest.NewRecorder()
+ h.Login.ServeHTTP(rec, req)
+
+ if got := rec.Header().Get("Location"); got != "/" {
+ t.Fatalf("Location = %q, want /", got)
+ }
+}
+
+func TestLogoutClearsCookie(t *testing.T) {
+ h := testHandlers(t)
+ req := httptest.NewRequest("GET", "/auth/logout", nil)
+ rec := httptest.NewRecorder()
+ h.Logout.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusSeeOther)
+ }
+ if got := rec.Header().Get("Location"); got != "/" {
+ t.Fatalf("Location = %q, want /", got)
+ }
+ cookies := rec.Result().Cookies()
+ if len(cookies) != 1 || cookies[0].Name != session.CookieName {
+ t.Fatalf("cookies = %v, want one %s cookie", cookies, session.CookieName)
+ }
+ if cookies[0].MaxAge >= 0 {
+ t.Fatalf("logout cookie MaxAge = %d, want negative", cookies[0].MaxAge)
+ }
+}
+
+func testHandlers(t *testing.T) Handlers {
+ t.Helper()
+ h, err := NewHandlers(Config{Sessions: session.NewManager()})
+ if err != nil {
+ t.Fatalf("NewHandlers: %v", err)
+ }
+ return h
+}
@@ -6,9 +6,13 @@ package core
import (
"fmt"
"sort"
+ "strconv"
"strings"
"sync"
+ "sync/atomic"
+ "time"
+ "gsg/internal/access"
"gsg/internal/gitx"
)
@@ -19,9 +23,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 the access policy
+// 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 +149,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 +162,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,13 +208,87 @@ 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
+}
+
+// AccessPolicy is the authorization-relevant subset of RepoInfo. The rules
+// themselves live in internal/access so core, modules, share minting and
+// share redemption cannot drift into slightly different answers.
+func (info RepoInfo) AccessPolicy() access.Policy {
+ return access.Policy{
+ Slug: info.Slug,
+ Hidden: info.Hidden,
+ VisibleTo: info.VisibleTo,
+ ShareRevokedAfter: info.ShareRevokedAfter,
}
}
@@ -85,15 +305,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 -- access.Policy 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 +364,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 +422,18 @@ func (c *Ctx) Derive(path string, vars map[string]string) *Ctx {
func (c *Ctx) Var(k string) string { return c.Vars[k] }
+// cacheMaxAge is how long an anonymous response for this repo may be
+// reused before revalidating. Server.buildResponseHead (the browser-facing
+// Cache-Control header) and Server.fillResponse (the server-side response
+// cache's own TTL) both read this, so the two never disagree about
+// staleness.
+func (c *Ctx) cacheMaxAge() time.Duration {
+ if c.Info.CacheMaxAge > 0 {
+ return c.Info.CacheMaxAge
+ }
+ return defaultAnonCacheMaxAge
+}
+
// ---------------------------------------------------------------- registry
// Module computes one piece of a view. Anything a template or an endpoint
@@ -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)
+ }
+}
@@ -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}} · {{.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="/">← 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."
+ }
+}
@@ -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,
@@ -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
+}
@@ -80,8 +80,51 @@ 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
+
+ // Request, when set, parses this route's path parameters. A nil parser
+ // uses the default {rest...}/{rev} handling. Parsers only split path
+ // text into ref candidates: core still calls gitx.Repo.Resolve, so user
+ // input never reaches git as raw ref syntax (see AGENTS invariant 1).
+ Request RequestFunc
+
+ // PageSize, when greater than zero, enables ?ofs= pagination at that
+ // page size and folds the normalized offset into Ctx.Vars["ofs"].
+ PageSize int
}
+// Request is the path-derived input a route hands back to core before any
+// ref is resolved. Ref is the head commit; Range is set only for routes
+// that compare two commits.
+type Request struct {
+ Ref string
+ Path string
+ Range *Range
+}
+
+// Range describes a base..head request. Ref on Request carries head.
+type Range struct {
+ Base string
+ ThreeDot bool
+}
+
+// RequestFunc parses route-specific path parameters. It must not invoke
+// git or resolve refs itself.
+type RequestFunc func(*http.Request) (Request, error)
+
type Scope int
const (
@@ -89,6 +132,16 @@ 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. Routes declare the matching PageSize,
+// and it must match the literal arg the log.html/graph.html template passes
+// 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 +176,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 +206,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
+}
@@ -1,6 +1,7 @@
package core
import (
+ "context"
"crypto/sha256"
"encoding/hex"
"errors"
@@ -8,11 +9,16 @@ import (
"io/fs"
"log/slog"
"net/http"
+ "strconv"
"strings"
"sync"
"time"
+ "gsg/internal/access"
"gsg/internal/gitx"
+ "gsg/internal/session"
+
+ "golang.org/x/sync/singleflight"
)
type Server struct {
@@ -22,31 +28,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 +82,7 @@ func (s *Server) loadTemplates() error {
if err != nil {
return err
}
- tag, err := hashFS(fsys)
+ tag, err := HashFS(fsys)
if err != nil {
return err
}
@@ -70,9 +92,13 @@ func (s *Server) loadTemplates() error {
return nil
}
-// hashFS digests every template so that editing the theme invalidates every
-// ETag at once. Without this, a restyled page keeps serving 304s.
-func hashFS(fsys fs.FS) (string, error) {
+// HashFS digests every file in fsys, path and contents both, into a short
+// stable tag. Used for the template set (so editing the theme invalidates
+// every ETag at once -- without this a restyled page keeps serving 304s)
+// and, via main.go, for the embedded static assets, so a CSS/JS edit mints
+// a new cache-busting URL instead of being masked by a year-long immutable
+// Cache-Control.
+func HashFS(fsys fs.FS) (string, error) {
h := sha256.New()
err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
@@ -99,6 +125,15 @@ func (s *Server) Handler() http.Handler {
return mux
}
+// cachedResponse is a full rendered response, header and body both, as
+// stored in respCache. expiresAt is only meaningful for entries actually
+// stored there.
+type cachedResponse struct {
+ header http.Header
+ body []byte
+ expiresAt time.Time
+}
+
func (s *Server) serve(rt Route, w http.ResponseWriter, r *http.Request) {
if s.TemplateDir != "" {
if err := s.loadTemplates(); err != nil {
@@ -107,37 +142,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,49 +209,218 @@ 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.
+ identity := access.Identity{User: s.Sessions.Verify(r)}
+ if claims, ok := s.Sessions.VerifyShareCookie(r); ok {
+ identity.SharedSlugs, identity.SharedIssuedAt = claims.Slugs, claims.IssuedAt()
+ }
+
if rt.Scope == ScopeSite {
c := NewCtx(s.Site, nil, RepoInfo{}, "")
c.Store = s.cache
+ c.User = identity.User
+ c.SharedSlugs, c.SharedIssuedAt = identity.SharedSlugs, identity.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. The rule lives
+ // in internal/access so this gate, the index module and share redemption
+ // agree about owners, grants and revocation.
+ if !h.Info.AccessPolicy().CanView(identity) {
+ 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;
- // {range} is "base..head" or "base...head".
- ref, path := "", ""
- var base string
- dots := "2"
-
- switch {
- case r.PathValue("range") != "":
- rng := r.PathValue("range")
- var head string
- if b, hd, ok := strings.Cut(rng, "..."); ok {
- base, head, dots = b, hd, "3"
- } else if b, hd, ok := strings.Cut(rng, ".."); ok {
- base, head = b, hd
- } else {
- return nil, Errorf(http.StatusBadRequest, "compare needs base..head")
- }
- ref = head
- case r.PathValue("rest") != "":
- ref, path = repo.SplitRefPath(r.PathValue("rest"))
- case r.PathValue("rev") != "":
- ref = r.PathValue("rev")
- default:
- ref = repo.HeadRef()
+ req, err := parseRequest(rt, repo, r)
+ if err != nil {
+ return nil, err
}
+ ref, path := req.Ref, req.Path
c := NewCtx(s.Site, repo, h.Info, ref)
c.Path = path
c.Store = s.cache
+ c.User = identity.User
+ c.SharedSlugs, c.SharedIssuedAt = identity.SharedSlugs, identity.SharedIssuedAt
+ c.StaticTag = s.StaticTag
if !repo.Empty() {
sha, err := repo.Resolve(ref)
@@ -201,18 +429,66 @@ func (s *Server) buildCtx(ctx contexter, rt Route, r *http.Request) (*Ctx, error
}
c.Commit = sha
}
- if base != "" {
+ if req.Range != nil {
+ base := req.Range.Base
+ dots := "2"
+ if req.Range.ThreeDot {
+ dots = "3"
+ }
baseSHA, err := repo.Resolve(base)
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.PageSize > 0 {
+ if v := r.URL.Query().Get("ofs"); v != "" {
+ if ofs, err := strconv.Atoi(v); err == nil && ofs > 0 {
+ ofs -= ofs % rt.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
}
+// parseRequest applies the route's parser, or the default {rest...}/{rev}
+// handling. Both only identify ref candidates; buildCtx below always calls
+// Repo.Resolve before anything downstream sees a commit.
+func parseRequest(rt Route, repo *gitx.Repo, r *http.Request) (Request, error) {
+ if rt.Request != nil {
+ return rt.Request(r)
+ }
+ switch {
+ case r.PathValue("rest") != "":
+ ref, path := repo.SplitRefPath(r.PathValue("rest"))
+ return Request{Ref: ref, Path: path}, nil
+ case r.PathValue("rev") != "":
+ return Request{Ref: r.PathValue("rev")}, nil
+ default:
+ return Request{Ref: repo.HeadRef()}, nil
+ }
+}
+
func (s *Server) fail(w http.ResponseWriter, r *http.Request, rt Route, err error) {
code := statusOf(err)
if errors.Is(err, gitx.ErrTruncated) {
@@ -223,7 +499,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 {
@@ -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)
+ })
+}
@@ -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 {
@@ -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")
+ }
+ })
+}
@@ -0,0 +1,85 @@
+// Package httpx holds small HTTP helpers shared by gsg's non-page
+// surfaces. The admin and share handlers must make the same origin,
+// scheme, header and remote-address decisions; keeping them here means a
+// security fix lands in one place instead of three.
+package httpx
+
+import (
+ "net"
+ "net/http"
+ "net/url"
+ "strings"
+)
+
+// SecureOptions controls the admin/share response policy. These pages are
+// deliberately not the themed site: no inline script or style, so the CSP
+// can stay tight.
+type SecureOptions struct {
+ // ScriptSrc is the CSP script-src value. Empty means "'none'"; the
+ // notices admin page needs "'self'" for admin.js, while share pages
+ // ship no JavaScript at all.
+ ScriptSrc string
+ // NoStore marks a response whose body must never be stored, even by
+ // the requesting browser -- a minted share URL is a bearer capability.
+ NoStore bool
+}
+
+// Secure sets the response headers every admin/share page must carry.
+func Secure(w http.ResponseWriter, opts SecureOptions) {
+ scriptSrc := opts.ScriptSrc
+ if scriptSrc == "" {
+ scriptSrc = "'none'"
+ }
+ 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 "+scriptSrc+
+ "; img-src 'self'; form-action 'self'; base-uri 'none'")
+ if opts.NoStore {
+ hdr.Set("Cache-Control", "no-store")
+ }
+}
+
+// SameOrigin blocks a cross-site POST even if a session cookie would
+// otherwise be sent. A valid 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
+}
+
+// IsHTTPS reports whether the browser's original request was HTTPS,
+// accounting for the reverse proxy's X-Forwarded-Proto.
+func IsHTTPS(r *http.Request) bool {
+ return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
+}
+
+// AbsoluteURL builds an absolute URL for a path on this host, using the
+// same proxy-aware scheme as IsHTTPS.
+func AbsoluteURL(r *http.Request, path string) string {
+ scheme := "http"
+ if IsHTTPS(r) {
+ scheme = "https"
+ }
+ return scheme + "://" + r.Host + path
+}
+
+// RemoteIP strips the port from r.RemoteAddr for use as a rate-limit key.
+func RemoteIP(r *http.Request) string {
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ return r.RemoteAddr
+ }
+ return host
+}
@@ -0,0 +1,98 @@
+package httpx
+
+import (
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestSecure(t *testing.T) {
+ rec := httptest.NewRecorder()
+ Secure(rec, SecureOptions{ScriptSrc: "'self'", NoStore: true})
+ h := rec.Header()
+
+ if got := h.Get("X-Content-Type-Options"); got != "nosniff" {
+ t.Errorf("X-Content-Type-Options = %q", got)
+ }
+ if got := h.Get("X-Frame-Options"); got != "DENY" {
+ t.Errorf("X-Frame-Options = %q", got)
+ }
+ if got := h.Get("Cache-Control"); got != "no-store" {
+ t.Errorf("Cache-Control = %q", got)
+ }
+ if got := h.Get("Content-Security-Policy"); got == "" ||
+ !strings.Contains(got, "script-src 'self'") || !strings.Contains(got, "base-uri 'none'") {
+ t.Errorf("Content-Security-Policy = %q", got)
+ }
+}
+
+func TestSecureDefaultsToNoScript(t *testing.T) {
+ rec := httptest.NewRecorder()
+ Secure(rec, SecureOptions{})
+ if got := rec.Header().Get("Content-Security-Policy"); !strings.Contains(got, "script-src 'none'") {
+ t.Errorf("Content-Security-Policy = %q, want script-src 'none'", got)
+ }
+ if got := rec.Header().Get("Cache-Control"); got != "" {
+ t.Errorf("Cache-Control = %q, want unset", got)
+ }
+}
+
+func TestSameOrigin(t *testing.T) {
+ cases := []struct {
+ name string
+ fetch string
+ origin string
+ want bool
+ }{
+ {name: "no headers", want: true},
+ {name: "same origin", fetch: "same-origin", origin: "https://git.example", want: true},
+ {name: "cross site", fetch: "cross-site", want: false},
+ {name: "different origin", origin: "https://evil.example", want: false},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ r := httptest.NewRequest("POST", "https://git.example/admin/notices", nil)
+ if tc.fetch != "" {
+ r.Header.Set("Sec-Fetch-Site", tc.fetch)
+ }
+ if tc.origin != "" {
+ r.Header.Set("Origin", tc.origin)
+ }
+ if got := SameOrigin(r); got != tc.want {
+ t.Fatalf("SameOrigin() = %v, want %v", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestIsHTTPSAndAbsoluteURL(t *testing.T) {
+ r := httptest.NewRequest("GET", "http://git.example/share/new", nil)
+ r.Host = "git.example"
+ if IsHTTPS(r) {
+ t.Fatal("plain request reported HTTPS")
+ }
+ if got := AbsoluteURL(r, "/share/view/token"); got != "http://git.example/share/view/token" {
+ t.Fatalf("AbsoluteURL() = %q", got)
+ }
+
+ r.Header.Set("X-Forwarded-Proto", "https")
+ if !IsHTTPS(r) {
+ t.Fatal("proxied HTTPS request not recognized")
+ }
+ if got := AbsoluteURL(r, "/share/view/token"); got != "https://git.example/share/view/token" {
+ t.Fatalf("AbsoluteURL() = %q", got)
+ }
+}
+
+func TestRemoteIP(t *testing.T) {
+ r := httptest.NewRequest("GET", "/", nil)
+ r.RemoteAddr = "203.0.113.9:54321"
+ if got := RemoteIP(r); got != "203.0.113.9" {
+ t.Fatalf("RemoteIP() = %q", got)
+ }
+ r.RemoteAddr = "unix"
+ if got := RemoteIP(r); got != "unix" {
+ t.Fatalf("RemoteIP() = %q", got)
+ }
+}
@@ -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
}
@@ -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)
+ }
+}
@@ -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
}
@@ -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
@@ -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
}
@@ -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
}
@@ -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 ""
+ }
+}
@@ -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 ""
+}
@@ -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())
+ }
+}
@@ -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
}
@@ -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
@@ -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
+}
@@ -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)
+ }
+}
@@ -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
}
@@ -3,8 +3,10 @@ package modules
import (
"fmt"
"sort"
+ "sync"
"time"
+ "gsg/internal/access"
"gsg/internal/core"
)
@@ -15,20 +17,101 @@ 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.
+ identity := access.Identity{
+ User: c.User,
+ SharedSlugs: c.SharedSlugs,
+ SharedIssuedAt: c.SharedIssuedAt,
+ }
+ var unlocked []RepoSummary
+ for _, h := range c.Site.RepoList() {
+ if !h.Info.Hidden {
+ continue
+ }
+ if h.Info.AccessPolicy().CanView(identity) {
+ 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
}
@@ -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
+ }
+}
@@ -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)
+ }
+}
@@ -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 · {{ .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>
@@ -0,0 +1,285 @@
+package notices
+
+import (
+ "crypto/rand"
+ "crypto/subtle"
+ "embed"
+ "encoding/hex"
+ "fmt"
+ "html/template"
+ "log/slog"
+ "net/http"
+ "sort"
+ "strconv"
+ "strings"
+
+ "gsg/internal/httpx"
+)
+
+//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) {
+ httpx.Secure(w, httpx.SecureOptions{ScriptSrc: "'self'"})
+ 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) {
+ httpx.Secure(w, httpx.SecureOptions{ScriptSrc: "'self'"})
+
+ // 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 !httpx.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)
+}
+
+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: httpx.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
+}
@@ -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)
+ }
+}
@@ -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
+}
@@ -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() }
@@ -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")
+ }
+}
@@ -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
+}
@@ -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 · {{ .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 — 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 — 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 — 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>
@@ -0,0 +1,388 @@
+// 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/http"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "gsg/internal/access"
+ "gsg/internal/httpx"
+ "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 })
+
+ httpx.Secure(w, httpx.SecureOptions{NoStore: true})
+ 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 !httpx.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))
+
+ // The 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.
+ httpx.Secure(w, httpx.SecureOptions{NoStore: true})
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := h.newTmpl.Execute(w, formView{
+ Site: h.cfg.SiteName, Result: httpx.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) {
+ httpx.Secure(w, httpx.SecureOptions{NoStore: true})
+ 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(httpx.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 !access.ShareLive(revokedAfter, claims.IssuedAt()) {
+ 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 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()
+ }
+}
@@ -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 · {{ .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>
@@ -0,0 +1,28 @@
+package site
+
+import (
+ "net/http"
+ "strings"
+
+ "gsg/internal/core"
+)
+
+// CompareRequest parses {range} into the base and head refs. The refs are
+// still resolved by core through gitx.Repo.Resolve; this only splits text.
+func CompareRequest(r *http.Request) (core.Request, error) {
+ rng := r.PathValue("range")
+ // Check three dots first: "a...b" also contains "..".
+ if base, head, ok := strings.Cut(rng, "..."); ok {
+ return core.Request{
+ Ref: head,
+ Range: &core.Range{Base: base, ThreeDot: true},
+ }, nil
+ }
+ if base, head, ok := strings.Cut(rng, ".."); ok {
+ return core.Request{
+ Ref: head,
+ Range: &core.Range{Base: base},
+ }, nil
+ }
+ return core.Request{}, core.Errorf(http.StatusBadRequest, "compare needs base..head")
+}
@@ -0,0 +1,71 @@
+package site
+
+import (
+ "net/http/httptest"
+ "testing"
+)
+
+func TestCompareRequest(t *testing.T) {
+ cases := []struct {
+ name string
+ rangeVal string
+ wantRef string
+ wantBase string
+ threeDot bool
+ wantErr bool
+ }{
+ {name: "two dot", rangeVal: "main..feature", wantRef: "feature", wantBase: "main"},
+ {name: "three dot", rangeVal: "main...feature", wantRef: "feature", wantBase: "main", threeDot: true},
+ {name: "refs with dots", rangeVal: "v1.0..v2.0", wantRef: "v2.0", wantBase: "v1.0"},
+ {name: "missing separator", rangeVal: "main", wantErr: true},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ req := httptest.NewRequest("GET", "/demo/compare/"+tc.rangeVal, nil)
+ req.SetPathValue("range", tc.rangeVal)
+ got, err := CompareRequest(req)
+ if tc.wantErr {
+ if err == nil {
+ t.Fatal("expected an error")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("CompareRequest: %v", err)
+ }
+ if got.Ref != tc.wantRef || got.Range == nil || got.Range.Base != tc.wantBase || got.Range.ThreeDot != tc.threeDot {
+ t.Fatalf("CompareRequest = %+v, want ref=%q base=%q threeDot=%v", got, tc.wantRef, tc.wantBase, tc.threeDot)
+ }
+ })
+ }
+}
+
+// The compare route must use the parser; the paginated routes must declare
+// their page size. Otherwise buildCtx silently falls back to defaults.
+func TestRoutesDeclareParsingMetadata(t *testing.T) {
+ routes := Routes()
+ byName := make(map[string]struct {
+ request bool
+ pageSize int
+ })
+ for _, rt := range routes {
+ byName[rt.Name] = struct {
+ request bool
+ pageSize int
+ }{
+ request: rt.Request != nil,
+ pageSize: rt.PageSize,
+ }
+ }
+
+ if !byName["compare"].request {
+ t.Error("compare route has no Request parser")
+ }
+ if byName["log"].pageSize == 0 {
+ t.Error("log route has no PageSize")
+ }
+ if byName["graph"].pageSize == 0 {
+ t.Error("graph route has no PageSize")
+ }
+}
@@ -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,15 @@ 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"},
+ PageSize: core.LogPageSize},
+
{Name: "blob",
Pattern: "GET /{repo}/blob/{rest...}",
Render: core.View{Template: "blob.html"}},
@@ -50,15 +49,29 @@ 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/{$}",
- Render: core.View{Template: "graph.html"}},
+ // 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"},
+ PageSize: core.GraphPageSize},
+
+ {Name: "refs", NavLabel: "refs", CacheableAnon: true,
+ Pattern: "GET /{repo}/refs/{rest...}",
+ Render: core.View{Template: "refs.html"}},
- // Arbitrary two-commit diff: /demo/compare/main...feature
+ // Arbitrary two-commit diff: /demo/compare/main...feature. Not
+ // CacheableAnon: a diff body is bounded only by MaxGit (16 MiB,
+ // larger than a blob preview), and every valid ref pair is a
+ // distinct key -- a repo with N refs has ~N^2 reachable ones. The
+ // response cache has no size-aware eviction, so this route would
+ // risk filling it with a few huge entries for no real hit-rate
+ // benefit (see internal/core/route.go's CacheableAnon doc).
{Name: "compare",
Pattern: "GET /{repo}/compare/{range}",
- Render: core.View{Template: "compare.html"}},
+ Render: core.View{Template: "compare.html"},
+ Request: CompareRequest},
// SHA-addressed: content can never change, so cache forever.
{Name: "commit", Immutable: true,
@@ -0,0 +1,503 @@
+# gsg — a modular git browser
+
+cgit's weight, Hugo's composability, served dynamically. A **module**
+computes one piece of a view; a **route** binds a renderer to a URL pattern.
+Adding a feature means adding a module and calling it from a template —
+`internal/core` never changes.
+
+One dependency (`goldmark`, which itself has none) plus the `git` binary. Go 1.22+ (uses `net/http`
+pattern wildcards, so there is no router dependency either).
+
+```
+go build -o gsg .
+./gsg -root /srv/git -addr :8080 -name "rschanz.org"
+./gsg -root /srv/git -templates ./templates # re-parse the theme per request
+./gsg -root /srv/git -notices /etc/gsg/notices.yaml -admin-addr 127.0.0.1:8081
+```
+
+## The idea
+
+```go
+type Module func(c *Ctx, args ...string) (any, error)
+```
+
+Templates reach modules through one funcmap entry:
+
+```gotmpl
+{{ with mod "meta" }}<h1>{{ .Title }}</h1>{{ end }}
+{{ $d := mod "diff" }}
+{{ with modOr "readme" }}{{ .HTML }}{{ end }}
+```
+
+- **Lazy.** A template that calls `mod "authors"` is what *causes* authors to
+ be computed. Views that don't ask don't pay.
+- **Memoized twice.** Once per request (`Ctx.cache`), and once process-wide
+ against the resolved commit SHA (`core.LRU`).
+- **No manifest.** Dependencies are declared by use. There is no module list
+ to keep in sync with the templates.
+
+`internal/modules/example_authors.go` is a live demonstration: 30 lines, an
+`init()`, and one `{{ with mod "authors" }}` block. Nothing else moved.
+
+## Pages and endpoints are the same mechanism
+
+A route is `{pattern, renderer}`. `core.View` renders a template,
+`core.JSON` serializes an endpoint. Both take a `*Ctx`, both call modules.
+
+```go
+{Name: "commit", Immutable: true,
+ Pattern: "GET /{repo}/commit/{rev}",
+ Render: core.View{Template: "commit.html"}},
+
+{Name: "api-commit", Immutable: true,
+ Pattern: "GET /{repo}/api/commit/{rev}",
+ Render: core.JSON{Build: api.CommitEndpoint}},
+```
+
+### Endpoints use DTOs, not module values
+
+`internal/api` defines explicit structs and never marshals a module's return
+value. Go marshals every exported field by default, so serializing a module
+type means the day someone adds `Body` to `gitx.Commit`, commit messages are
+public. The allowlist is one readable file.
+
+```
+GET /api/repos.json site index
+GET /{repo}/api/repo.json slug, title, description, owner, section,
+ clone_url, default_branch, last_commit
+GET /{repo}/api/commit/{sha} sha, short, authored, parents, and
+ file/line counts — no subject, no body
+```
+
+## Routes
+
+| Route | URL |
+|---|---|
+| index | `/` |
+| repo | `/{repo}/` — default branch, last update, latest commit, README |
+| files | `/{repo}/tree/{ref}/{path}` |
+| file | `/{repo}/blob/{ref}/{path}` |
+| **raw bytes** | `/{repo}/raw/{ref}/{path}` |
+| log | `/{repo}/log/{ref}` |
+| refs | `/{repo}/refs/` |
+| commit | `/{repo}/commit/{sha}` |
+| **compare** | `/{repo}/compare/{base}..{head}` or `{base}...{head}` |
+| **branch graph** | `/{repo}/graph/` |
+
+### Compare
+
+`a..b` is the literal endpoint difference. `a...b` diffs from the merge
+base, which is what you want when reviewing a branch — it excludes changes
+that landed on the base since the branch forked. Verified against
+`git merge-base` on a diverged fixture: `topic..main` lists `topic.txt` as
+removed, `topic...main` does not.
+
+### Raw files
+
+A repository can contain an `.html` file with a `<script>` tag. Serving that
+as `text/html` on your own origin turns any push into stored XSS against the
+whole site — which is why GitHub puts raw content on
+`raw.githubusercontent.com`, a separate origin. Same-host serving is only
+safe if the response cannot be interpreted as a document, so `/raw/` sends
+`text/plain` (or `application/octet-stream` for binaries, plus
+`Content-Disposition: attachment`), with `X-Content-Type-Options: nosniff`
+and `Content-Security-Policy: default-src \'none\'; sandbox` as a backstop.
+
+The exception is a validated raster image (PNG, JPEG, GIF, WebP, BMP, ICO,
+AVIF): when the extension and the magic bytes agree, `/raw/` declares the
+real `image/*` type inline so the blob page can preview it. An extension by
+itself is not enough — an `.png` whose contents are HTML still downloads as
+`application/octet-stream`. SVG is deliberately excluded: it is an XML
+document that can execute script, so it stays `text/plain`.
+
+If you would rather have real isolation, serve `/raw/` from a second
+hostname and the CSP becomes belt-and-braces rather than the only line.
+
+### Images and structured config files
+
+An image blob shows an inline preview with a `download` link to the same
+`/raw/` URL; images over 10 MiB skip the preview and offer the download only.
+
+JSON and YAML blobs get per-level indent guides, fold controls, key/value
+search, and YAML anchor/alias links. The search box also accepts a value
+path: typing `obj1.property3.items[4].val` resolves it against the parsed
+document and highlights that line and, for a container, its subtree. A
+query containing `.` or `[` is tried as a path first and falls back to
+plain substring search when it does not resolve, so dotted text stays
+searchable. Paths quote keys that need it (`obj1["a.b"]`) and attach indices
+directly; SVG is never previewed, and files past 512 KiB skip the parsing
+tooling entirely.
+
+### Branch graph
+
+`modules/graph.go` lays out the DAG in lanes in one pass over
+`git log --all --topo-order`, which guarantees a child is emitted before its
+parents. `active` holds, per lane, the SHA that lane is waiting to draw; a
+commit claims the lane expecting it (or a fresh one), hands that lane to its
+first parent, and opens a new lane per additional parent — which is what
+makes a merge fork visually.
+
+The module emits the per-row SVG because the geometry *is* the data, but
+every stroke refers to a CSS custom property (`--lane-0` … `--lane-5`), so
+the theme still owns the palette. Row height in the SVG must stay in sync
+with `table.graph td { height }` in the stylesheet.
+
+## Caching, which is the whole reason this works dynamically
+
+Every module result is a pure function of `(repo, commit SHA, path, args)`.
+Refs move; SHAs do not. So the process-wide cache is keyed on the resolved
+SHA and needs **no invalidation logic at all** — a stale entry is impossible
+by construction.
+
+`Ctx.Vars` are part of the cache key, not just the commit. They were not
+originally, and the bug was exactly the kind this cache should be immune to:
+`/compare/a..b` and `/compare/a...b` resolve to the same head commit, so the
+second request was served the first one\'s diff. Anything that changes what a
+module computes has to be in the key.
+
+ETags are derived from the resolved SHA plus a digest of every template
+file, *not* from the response body. A conditional request costs one ref
+lookup instead of a render, and restyling the theme invalidates every ETag
+at once (without the template digest, a restyled page keeps serving 304s).
+
+Two URL classes, two policies:
+
+| URL shape | Example | Cache-Control |
+|---|---|---|
+| SHA-addressed | `/demo/commit/64b90fff…` | `max-age=31536000, immutable` |
+| ref-addressed | `/demo/tree/main` | `no-cache` (revalidate via ETag) |
+
+## Ref-vs-path ambiguity
+
+`/{repo}/tree/{rest...}` fuses them, and `gitx.SplitRefPath` separates them
+by **longest matching prefix against the repo's actual ref list**, so
+`feature/x/src/main.go` resolves correctly. Splitting at the first `/` is
+wrong because branch names contain slashes; cgit dodges this with `?h=`
+query params, GitHub does what this does.
+
+## Serving is an attack surface; static generation was not
+
+- **Ref input never reaches `git rev-parse`.** rev-parse accepts an
+ expression language — `:/regex` searches history, `@{...}` reads the
+ reflog — and git subcommands treat a leading `-` as an option.
+ `gitx.Resolve` accepts either a hex SHA that already exists or an exact
+ match against the repo's own ref list. Verified: `--upload-pack=…`,
+ `:/secret`, `HEAD@{0}` and `-n` all return 404 with no side effects.
+- **Every git call is `exec.CommandContext`** with a per-request deadline
+ (`-timeout`, default 10s) and a scrubbed environment
+ (`GIT_CONFIG_NOSYSTEM=1`, no `HOME`, no terminal prompt).
+- **Output is capped** at `MaxGit` (8 MiB) per command; a diff that exceeds
+ it renders truncated rather than eating the heap.
+
+## Layout
+
+```
+main.go flags, repo discovery, embedded assets
+internal/gitx/ the ONLY package that knows git exists
+internal/core/core.go Ctx, RepoInfo, module registry, Store
+internal/core/route.go Route, View, JSON, route request parsing
+internal/core/server.go request -> Ctx, ETags, cache policy
+internal/core/cache.go LRU keyed on commit SHAs
+internal/core/funcs.go template funcmap (mod, modOr, dict, date, bytes)
+internal/access/ visibility and share-grant policy (imports no core)
+internal/authn/ login/logout endpoints
+internal/httpx/ shared admin/share HTTP security helpers
+internal/modules/ meta refs log tree readme repos commit diff blob notices
+internal/notices/ notices YAML store + admin UI (imports no core)
+internal/api/ JSON DTOs — the serialization allowlist
+internal/site/routes.go the route table (the file you edit most)
+internal/site/request.go route-specific path parsers (compare ranges)
+templates/ layouts/ + views/
+```
+
+`gitx` shells out to plumbing, one process per query, `git log` in a single
+pass. Swapping in go-git means rewriting that one package.
+
+## Sections
+
+`gsg.section` groups repositories on the index. The `sections` module builds
+on `repos` rather than re-walking the repo set, so the index pays for its log
+lookups once. When no repo sets a section, it returns a single unnamed group
+and the template renders a flat list — no config, no headings.
+
+```sh
+git --git-dir=/srv/git/demo.git config gsg.section "Infrastructure"
+```
+
+## Notices
+
+Operator-authored banners at the top of every page, site-wide and per
+repository. They live in one YAML file that is watched for changes, so
+editing it never needs a restart or a rebuild:
+
+```yaml
+global:
+ - type: critical # critical | warning | info | fix
+ title: Maintenance window
+ body: |
+ Saturday 02:00-04:00 UTC.
+ The site is read-only during the window.
+ link: https://status.example.org
+ link_text: status page
+repos:
+ demo:
+ - type: warning
+ title: Migrating to a new remote
+```
+
+```sh
+./gsg -root /srv/git -notices /etc/gsg/notices.yaml
+```
+
+Global notices render above every repository's; on the site index only the
+global list appears. The file is re-read on write (atomic-rename saves from
+an editor included); a parse error keeps the last good content rather than
+blanking the banners.
+
+### Editing from the web
+
+`-admin-addr` starts a second listener with a small HTML form to add, edit,
+and delete notices. It is deliberately separate from the read-only server:
+the admin routes are not reachable on the public port even if the reverse
+proxy is misconfigured.
+
+```sh
+./gsg -root /srv/git -notices /etc/gsg/notices.yaml -admin-addr 127.0.0.1:8081
+```
+
+Protect it with Caddy `forward_auth` and proxy it to the admin listener. gsg
+trusts the network in front of it, so the admin port must not be published
+directly. In Compose, put 8081 on the internal network only and pass
+`-admin-allow-remote`, because a container address is not loopback. Note
+that gsg performs no authentication itself -- the `forward_auth` block below
+is the only thing between the internet and the admin form.
+
+```caddyfile
+example.com {
+ # Admin: authenticate first, then proxy to the separate admin listener.
+ # handle, not handle_path: the admin mux matches the full /admin/...
+ # path, so stripping the prefix would 404 every request.
+ handle /admin/* {
+ forward_auth authentik:9000 {
+ uri /outpost.goauthentik.io/auth/caddy
+ copy_headers X-Authentik-Username
+ }
+ reverse_proxy gsg:8081
+ }
+
+ # Public, read-only.
+ handle {
+ reverse_proxy gsg:8080
+ }
+}
+```
+
+The `X-Authentik-Username` header is used only to attribute the mutation in
+the log; it is never consulted for authorization. By default Authentik
+decides who may reach the handler at all -- anyone forward_auth lets through
+`/admin/*` can edit notices.
+
+Mutations are POST-only, CSRF-protected (double-submit cookie plus
+`Origin`/`Sec-Fetch-Site`), and logged with the `X-Authentik-Username`
+header, which is used for audit only, never for authorization.
+
+### Gating notices editing to a group
+
+`/admin/*` is also where [Login](#login) mints the `gsg_user` cookie, so
+forward_auth for the whole prefix is usually left open to everyone you want
+able to log in (for share links, hidden-repo visibility, etc). To let more
+people log in without also handing them notices editing, add `-admin-group`:
+
+```sh
+./gsg -root /srv/git -notices /etc/gsg/notices.yaml \
+ -admin-addr 127.0.0.1:8081 -admin-group gsg-admins
+```
+
+This checks the `X-Authentik-Groups` header (override with
+`-admin-group-header`) for `gsg-admins` among its `|`-delimited entries,
+Authentik's own format for that header, and answers `403` to any request
+under `/admin/notices` from a caller that isn't a member -- before the
+handler does anything else, including setting the CSRF cookie. Add the group
+header to the same `forward_auth` block used above:
+
+```caddyfile
+ forward_auth authentik:9000 {
+ uri /outpost.goauthentik.io/auth/caddy
+ copy_headers X-Authentik-Username X-Authentik-Groups
+ }
+```
+
+Leaving `-admin-group` unset keeps today's behavior: anyone forward_auth
+admits to `/admin/*` can edit notices.
+
+**Immutable commit pages carry no notices.** `/{repo}/commit/{rev}` is
+served `Cache-Control: public, max-age=31536000, immutable`, so a banner
+rendered into it would stay frozen in the reader's browser. Notices are
+suppressed there by design; every other page picks them up on the next
+request.
+
+## Login
+
+Logging in reveals hidden repos whose `gsg.visible-to` list includes your
+username; everyone else, logged in or not, sees exactly what an anonymous
+visitor sees. It reuses the same Authentik `forward_auth` setup as the
+notices admin UI above, and needs nothing extra beyond `-admin-addr`
+(notices editing is not required — `-admin-addr` alone is enough to enable
+login).
+
+```sh
+./gsg -root /srv/git -admin-addr 127.0.0.1:8081
+```
+
+```caddyfile
+example.com {
+ # Login: same trust boundary as /admin/*, one hop mints the cookie
+ # and redirects back. This must be the exact path, not /auth/*: gsg
+ # also serves /auth/logout, but on the *public* listener, since
+ # logging out only clears gsg's own cookie and needs no Authentik
+ # round trip at all. Routing /auth/* here would send logout to the
+ # admin listener too, where no such handler exists, and gate it
+ # behind an Authentik check it doesn't need.
+ handle /auth/login {
+ forward_auth authentik:9000 {
+ uri /outpost.goauthentik.io/auth/caddy
+ copy_headers X-Authentik-Username
+ }
+ reverse_proxy gsg:8081
+ }
+
+ # Public, read-only (also handles /auth/logout, /share/new, /share/view/*).
+ handle {
+ reverse_proxy gsg:8080
+ }
+}
+```
+
+Visiting `/auth/login` (through Caddy, so `X-Authentik-Username` is set and
+trustworthy by the time gsg sees the request) mints a signed, stateless
+`gsg_user` cookie and redirects back to `/`. The cookie is verified on the
+public listener with an in-memory key — nothing is written to disk, and a
+process restart signs everyone out. `/auth/logout` clears it and needs no
+round trip through Authentik, since it only has to forget gsg's own cookie.
+
+**There is no "log in" link anywhere in the UI.** `/auth/login` is not
+discoverable from the site; people who need it are told the URL out of
+band. Once logged in, the nav bar shows who you are and a log-out link.
+
+## Share links
+
+Once logged in, `/share/new` lets you pick from your own hidden repos and
+mint a link that grants view access to exactly those repos to anyone who
+has it — no login, no Authentik involvement. It's entirely stateless: the
+link is a signed, self-contained token, and gsg keeps no record of what's
+been shared. An optional password can gate it.
+
+```
+git --git-dir=/srv/git/demo.git config gsg.share-max-age "720h"
+```
+
+`gsg.share-max-age` (Go duration syntax, default 30 days if unset) is both
+the ceiling no link's expiry may exceed and the default when the minting
+form doesn't specify one — you can always ask for less, never more. Sharing
+several repos at once uses the smallest ceiling among them.
+
+**Passwords are never stored, not even hashed.** The password is folded
+directly into the link's signature instead of being embedded in it: nobody
+holding just the URL has anything to brute-force offline, and checking a
+guess requires asking the server, which rate-limits attempts. This is a
+soft gate appropriate for a small self-hosted tool, not a cryptographic
+secret protecting high-value data.
+
+**Revocation.** A stateless token can't be individually revoked — there's
+no list of what's been issued. The one lever:
+
+```
+git --git-dir=/srv/git/demo.git config gsg.share-revoked-after "$(date +%s)"
+```
+
+Any share grant for that repo issued at or before this timestamp stops
+working immediately, including in a browser that already redeemed the
+link — not just future redemptions. Two things worth knowing before you
+rely on this:
+
+- There's no per-recipient state, so revoking means invalidating *every*
+ outstanding share of that repo, then re-sharing with whoever should still
+ have it.
+- Removing a username from `gsg.visible-to` does **not** revoke a share
+ link that user already minted for that repo. The two mechanisms are
+ independent; only `gsg.share-revoked-after` invalidates outstanding
+ shares.
+
+## README rendering
+
+`modules/markdown.go` uses goldmark with GFM, Typographer, and automatic
+heading IDs — Hugo\'s own goldmark defaults, so a README renders identically
+to the same file on your blog.
+
+`WithUnsafe` is deliberately not set, which is also Hugo\'s default. Anyone
+who can push can write a README, so raw HTML in markdown is untrusted input;
+goldmark drops it. A `<script>` committed to a README comes out as
+`<!-- raw HTML omitted -->`.
+
+Relative links are rewritten against the file\'s own directory: links become
+the blob view, images become `/raw/` (an `<img>` needs bytes, not an HTML
+page). Absolute URLs, site-absolute paths, fragments and `mailto:` are left
+alone, and anything resolving above the repo root is not rewritten.
+
+## Per-repository config
+
+Read from each repo's own git config, cgit-style, with `gitweb.*` and the
+legacy `description` file as fallbacks, so an existing cgit tree works as-is:
+
+```sh
+git --git-dir=/srv/git/demo.git config gsg.title "Demo Project"
+git --git-dir=/srv/git/demo.git config gsg.description "A demo repository"
+git --git-dir=/srv/git/demo.git config gsg.clone "git://rschanz.org/demo"
+git --git-dir=/srv/git/demo.git config gsg.hide true
+git --git-dir=/srv/git/demo.git config gsg.visible-to "alice,bob"
+git --git-dir=/srv/git/demo.git config gsg.share-max-age "720h"
+git --git-dir=/srv/git/demo.git config gsg.share-revoked-after "$(date +%s)"
+```
+
+Add a key by adding a field to `core.RepoInfo` and a line in `LoadRepoInfo`.
+
+A repo's slug is its directory name (minus `.git`), and slugs share the
+public listener's URL namespace with a few reserved top-level paths:
+`static`, `auth`, and `share`. A repo named e.g. `share.git` would collide
+with the share-link endpoints below and is not supported.
+
+`gsg.visible-to` is a comma-separated allowlist of usernames (matched
+case-insensitively against a logged-in session, see "Login" below) who may
+see this repo even though `gsg.hide` is set. It's a separate key from
+`gsg.owner` on purpose: `Owner` is a free-text display field that falls
+back to `gitweb.owner` for cgit-migrated repos, so treating it as an access
+list could silently grant visibility nobody intended.
+
+## Theme
+
+Monospace-first: every value on these pages is an identifier, hash, path or
+count, so the interface type is the same face as the code, and the sans face
+appears only inside rendered README prose. A cool slate ramp, one indigo
+accent, and a six-colour lane ramp shared by the commit graph and its branch
+chips. Light and dark both defined via `prefers-color-scheme`.
+
+Diffs mark the gutter rather than flooding whole rows with green and red —
+the signal stays readable when a hunk is mostly additions.
+
+No webfonts; system stacks only, so there is no third-party request. All
+colour lives in `:root` custom properties in `static/style.css`.
+
+## Known gaps
+
+- **No syntax highlighting.** `blob` returns numbered plain lines; Chroma
+ drops into the same module.
+- **No pagination on `log` or `graph`.** They cap at 200 and 150 commits.
+- **The graph loads all refs into memory** before laying out. Fine to a few
+ thousand commits; beyond that it needs windowing.
+- **The LRU is bounded by entry count, not bytes.** A giant diff and a ref
+ list cost the same slot. The `Store` interface is the only thing a
+ size-aware replacement has to satisfy.
+- **Repo discovery is one level deep** and happens once at startup; a repo
+ created later needs a restart.
+- **Module errors surface at render time**, not load time. Add a CI pass
+ that renders every view against a fixture repo.
@@ -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,17 @@ import (
"io/fs"
"log"
"log/slog"
+ "net"
"net/http"
"os"
- "path/filepath"
- "sort"
"time"
+ "gsg/internal/authn"
"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 +42,253 @@ 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 {
+ 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 err := core.Start(siteCfg); err != nil {
return err
}
- if len(handles) == 0 {
- return fmt.Errorf("no repositories found under %s", root)
+
+ // 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()
+ authnHandlers, err := authn.NewHandlers(authn.Config{
+ Sessions: sessions,
+ UserHeader: "X-Authentik-Username",
+ })
+ if err != nil {
+ return err
}
- siteCfg.Repos = handles
var tfs fs.FS
- if tmplDir != "" {
- tfs = os.DirFS(tmplDir)
+ 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() because core.Route's Renderer always builds a
+ // body from a Ctx, and a redirect-plus-Set-Cookie doesn't fit that shape.
+ mux.Handle("GET /auth/logout", authnHandlers.Logout)
+
+ // 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.AccessPolicy().CanShare(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.Handle("GET /auth/login", authnHandlers.Login)
+
+ // 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
+}
+
+// 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)
}
-// 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)
+// 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
}
@@ -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;
+}
@@ -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);
+}
@@ -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;
+}
@@ -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);
+}
@@ -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;
+}
@@ -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;
+}
@@ -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;
+ }
+}
@@ -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;
+ }
+}
@@ -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;
+}
@@ -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);
+})();
@@ -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();
+})();
@@ -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; }
-}
@@ -4,42 +4,72 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ block "title" . }}{{ with .Info.Title }}{{ . }} · {{ end }}{{ .Site.Name }}{{ end }}</title>
-<link rel="stylesheet" href="/static/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;">•</span>-->
+ <span>{{ .Site.Name }} v1.4</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 {{ . }} · <a href="/auth/logout">log out</a></span>
+ {{ end }}
+ </div>
</div>
-</header>
+</nav>
{{ end }}
@@ -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 }}
@@ -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 }}—{{ 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);">−{{ .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 }}
@@ -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> · {{ bytes64 $b.Size }}{{ if not $b.Binary }} · {{ len $b.Lines }} lines{{ end }}
- · <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">· {{ bytes64 $b.Size }}{{ if not $b.Binary }} · {{ 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 }}— <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="&{{ .Name }} — line {{ .Line }}{{ with .Preview }} {{ . }}{{ end }}">→ {{ .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 }}
@@ -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">· {{ $c.Author }} · <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…" 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 }}
@@ -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 }}…{{ else }}‥{{ 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 }}…{{ else }}..{{ end }}
+ <code style="color:var(--accent);">{{ $c.Head }}</code>
+ <span class="text-muted">· {{ 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 }}
@@ -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 · {{ $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 · {{ $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 }}">← Newer</a>{{ end }}</span>
+ <span>{{ if $g.HasNext }}<a href="?ofs={{ $g.NextOfs }}">Older →</a>{{ end }}</span>
+ </nav>
+{{ end }}
{{ end }}
@@ -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
@@ -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 }} · {{ .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">—</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 }}">← Newer</a>{{ end }}</span>
+ <span>{{ if $page.HasNext }}<a href="?ofs={{ $page.NextOfs }}">Older →</a>{{ end }}</span>
+ </nav>
+{{ end }}
+{{ end }}
\ No newline at end of file
@@ -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 · {{ 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 · {{ 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 }}
@@ -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 }} · <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 }}
@@ -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> · {{ 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 }} · <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>
+ · {{ 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">· {{ 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 }}