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

Compare

c0427d5fc7c0fcf8606c546071085e8af231fb81 … main · 8 commits

merge base c0427d5fc7c0…

Changed files

FileChanges
AGENTS.md +49 −3 raw
Dockerfile +16 −0 raw
README.md +52 −256 raw
go.mod +2 −1 raw
go.sum +2 −0 raw
internal/access/access.go +82 −0 raw
internal/access/access_test.go +107 −0 raw
internal/authn/authn.go +79 −0 raw
internal/authn/authn_test.go +104 −0 raw
internal/core/core.go +197 −15 raw
internal/core/core_test.go +27 −0 raw
internal/core/errorpage.go +1 −1 raw
internal/core/funcs.go +9 −1 raw
internal/core/route.go +66 −4 raw
internal/core/server.go +338 −64 raw
internal/gitx/git.go +21 −1 raw
internal/gitx/git_test.go +64 −0 raw
internal/httpx/httpx.go +85 −0 raw
internal/httpx/httpx_test.go +98 −0 raw
internal/modules/blob.go +27 −3 raw
internal/modules/image.go +72 −0 raw
internal/modules/image_test.go +99 −0 raw
internal/modules/notices.go +29 −0 raw
internal/modules/notices_test.go +88 −0 raw
internal/modules/repos.go +62 −9 raw
internal/modules/structured.go +165 −13 raw
internal/modules/structured_test.go +116 −0 raw
internal/notices/admin.html +64 −0 raw
internal/notices/handler.go +285 −0 raw
internal/notices/handler_test.go +114 −0 raw
internal/notices/notices.go +221 −0 raw
internal/notices/store.go +230 −0 raw
internal/notices/store_test.go +150 −0 raw
internal/session/session.go +280 −0 raw
internal/share/new.html +64 −0 raw
internal/share/share.go +388 −0 raw
internal/share/view.html +30 −0 raw
internal/site/request.go +28 −0 raw
internal/site/request_test.go +71 −0 raw
internal/site/routes.go +25 −14 raw
llm_readme.md +503 −0 raw
main.go +226 −26 raw
static/css/admin.css +60 −0 raw
static/css/base.css +79 −3 raw
static/css/blob.css +24 −0 raw
static/css/repo-list.css +84 −4 raw
static/css/share.css +63 −0 raw
static/js/admin.js +60 −0 raw
static/js/structured.js +75 −2 raw
templates/layouts/base.html +26 −9 raw
templates/layouts/notices.html +24 −0 raw
templates/views/blob.html +14 −3 raw
templates/views/commit.html +18 −7 raw
templates/views/compare.html +1 −1 raw
templates/views/graph.html +1 −1 raw
templates/views/index.html +9 −4 raw
templates/views/tree.html +2 −2 raw

Commits

HashSubjectAuthorDate
554bcfc6 invalid var name and version bump Ryan Schanzenbacher
d658fcc4 merge caching trials back in and solve conflicts Ryan Schanzenbacher
cb5a7c7c add human readme Ryan Schanzenbacher
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
diff --git a/AGENTS.md b/AGENTS.md
index 676ef83..34fe639 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..37babad
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,16 @@
+FROM --platform=$BUILDPLATFORM golang:1.23-alpine AS build
+WORKDIR /src
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+ARG TARGETOS=linux
+ARG TARGETARCH=amd64
+RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/gsg .
+
+FROM alpine:3.20
+RUN apk add --no-cache git gnupg
+COPY --from=build /out/gsg /usr/local/bin/gsg
+
+EXPOSE 8080
+ENTRYPOINT ["gsg"]
+CMD ["-root", "/srv/git", "-addr", ":8080"]
diff --git a/README.md b/README.md
index 4754120..1d94c39 100644
--- a/README.md
+++ b/README.md
@@ -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!
diff --git a/go.mod b/go.mod
index 503bebb..96381fd 100644
--- a/go.mod
+++ b/go.mod
@@ -1,11 +1,12 @@
module gsg
-go 1.23
+go 1.23.0
require (
github.com/alecthomas/chroma/v2 v2.24.1
github.com/fsnotify/fsnotify v1.10.1
github.com/yuin/goldmark v1.8.5
+ golang.org/x/sync v0.14.0
gopkg.in/yaml.v3 v3.0.1
)
diff --git a/go.sum b/go.sum
index 1a01f6d..f3f96d6 100644
--- a/go.sum
+++ b/go.sum
@@ -12,6 +12,8 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
+golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
diff --git a/internal/access/access.go b/internal/access/access.go
new file mode 100644
index 0000000..2a99e13
--- /dev/null
+++ b/internal/access/access.go
@@ -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)
+}
diff --git a/internal/access/access_test.go b/internal/access/access_test.go
new file mode 100644
index 0000000..0a1ee6d
--- /dev/null
+++ b/internal/access/access_test.go
@@ -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")
+ }
+}
diff --git a/internal/authn/authn.go b/internal/authn/authn.go
new file mode 100644
index 0000000..122cd6a
--- /dev/null
+++ b/internal/authn/authn.go
@@ -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
+}
diff --git a/internal/authn/authn_test.go b/internal/authn/authn_test.go
new file mode 100644
index 0000000..97f82c0
--- /dev/null
+++ b/internal/authn/authn_test.go
@@ -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
+}
diff --git a/internal/core/core.go b/internal/core/core.go
index cdc447f..8862350 100644
--- a/internal/core/core.go
+++ b/internal/core/core.go
@@ -6,10 +6,13 @@ package core
import (
"fmt"
"sort"
+ "strconv"
"strings"
"sync"
"sync/atomic"
+ "time"
+ "gsg/internal/access"
"gsg/internal/gitx"
)
@@ -20,15 +23,33 @@ type Site struct {
BaseURL string
Root string // directory containing bare repos
+ // 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 // non-hidden only, for URL routing
+ bySlug map[string]Handle // every known repo, hidden or not -- Server.buildCtx enforces visibility
}
// RepoList is every discovered repository. Site-scoped routes (the index
@@ -40,8 +61,9 @@ func (s *Site) RepoList() []Handle {
return nil
}
-// Lookup resolves a repo by slug for per-repo routes. Hidden repos are not
-// found here, the same way they were excluded from Server's old repos map.
+// 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 {
@@ -51,6 +73,22 @@ func (s *Site) Lookup(slug string) (Handle, bool) {
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.
@@ -89,11 +127,10 @@ func (s *Site) mutate(f func([]Handle) []Handle) {
bySlug := make(map[string]Handle, len(next))
for _, h := range next {
- if !h.Info.Hidden {
- bySlug[h.Info.Slug] = h
- }
+ 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.
@@ -113,6 +150,8 @@ type Handle struct {
// 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 {
@@ -124,6 +163,25 @@ type RepoInfo struct {
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
@@ -131,6 +189,13 @@ type RepoInfo struct {
// templates don't advertise a verification column that isn't backed
// by anything.
GPGKeyring string
+
+ // CacheMaxAge is gsg.cache-max-age: how long an anonymous (no session,
+ // no share grant) response for this repo may be reused -- both by the
+ // requesting browser (Cache-Control: public, max-age=...) and by gsg's
+ // own in-memory response cache (Server.respCache) -- before either has
+ // to ask again. Falls back to defaultAnonCacheMaxAge when unset.
+ CacheMaxAge time.Duration
}
func LoadRepoInfo(r *gitx.Repo) RepoInfo {
@@ -143,14 +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"),
- GPGKeyring: r.Config("gsg.gpgkeyring"),
+ 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,
}
}
@@ -167,6 +305,19 @@ 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
@@ -176,10 +327,23 @@ type Ctx struct {
// 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
}
@@ -208,6 +372,9 @@ func NewCtx(site *Site, r *gitx.Repo, info RepoInfo, ref string) *Ctx {
// 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.
//
// 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
@@ -220,7 +387,7 @@ func (c *Ctx) contentIdentity() string {
}
sort.Strings(keys)
var b strings.Builder
- b.WriteString(c.Commit + "|" + c.Info.GPGKeyring)
+ b.WriteString(c.Commit + "|" + c.Info.GPGKeyring + "|" + c.Site.Revision())
for _, k := range keys {
b.WriteString("|" + k + "=" + c.Vars[k])
}
@@ -240,6 +407,9 @@ func (c *Ctx) CacheKey(name string, args []string) string {
// 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
@@ -252,6 +422,18 @@ func (c *Ctx) Derive(path string, vars map[string]string) *Ctx {
func (c *Ctx) Var(k string) string { return c.Vars[k] }
+// cacheMaxAge is how long an anonymous response for this repo may be
+// reused before revalidating. Server.buildResponseHead (the browser-facing
+// Cache-Control header) and Server.fillResponse (the server-side response
+// cache's own TTL) both read this, so the two never disagree about
+// staleness.
+func (c *Ctx) cacheMaxAge() time.Duration {
+ if c.Info.CacheMaxAge > 0 {
+ return c.Info.CacheMaxAge
+ }
+ return defaultAnonCacheMaxAge
+}
+
// ---------------------------------------------------------------- registry
// Module computes one piece of a view. Anything a template or an endpoint
diff --git a/internal/core/core_test.go b/internal/core/core_test.go
new file mode 100644
index 0000000..9ad819a
--- /dev/null
+++ b/internal/core/core_test.go
@@ -0,0 +1,27 @@
+package core
+
+import (
+ "strings"
+ "testing"
+)
+
+// The site revision is the site-level analogue of RepoInfo.GPGKeyring: it
+// must be part of contentIdentity or a notice change cannot invalidate the
+// module cache or the response ETag.
+func TestContentIdentityIncludesSiteRevision(t *testing.T) {
+ a := NewCtx(&Site{RevFn: func() string { return "rev-a" }}, nil, RepoInfo{}, "main")
+ b := NewCtx(&Site{RevFn: func() string { return "rev-b" }}, nil, RepoInfo{}, "main")
+ a.Commit, b.Commit = "sha", "sha"
+ if a.contentIdentity() == b.contentIdentity() {
+ t.Fatal("site revision is not part of contentIdentity")
+ }
+}
+
+// Tests construct bare *Ctx values (see blob_test.go) and LoadTemplates
+// parses with &Ctx{}; contentIdentity must tolerate a nil Site.
+func TestContentIdentityNilSite(t *testing.T) {
+ got := (&Ctx{}).contentIdentity()
+ if !strings.HasPrefix(got, "|") {
+ t.Fatalf("contentIdentity() = %q", got)
+ }
+}
diff --git a/internal/core/errorpage.go b/internal/core/errorpage.go
index 365396e..1e47baa 100644
--- a/internal/core/errorpage.go
+++ b/internal/core/errorpage.go
@@ -33,7 +33,7 @@ var errorTmpl = template.Must(template.New("error").Parse(`<!doctype html>
// 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
+ Code int
Status, Message, Site string
}{
Code: code,
diff --git a/internal/core/funcs.go b/internal/core/funcs.go
index 9db614c..7414c5f 100644
--- a/internal/core/funcs.go
+++ b/internal/core/funcs.go
@@ -31,6 +31,14 @@ func FuncMap(c *Ctx) template.FuncMap {
"url": func(parts ...string) string {
return "/" + strings.Trim(strings.Join(parts, "/"), "/")
},
+ // static builds a cache-busting asset URL, e.g. "css/base.css" ->
+ // "/static/css/base.css?v=<tag>". The `v` param is what lets
+ // main.go's static handler hand out a year-long immutable
+ // Cache-Control instead of a short one -- a URL without it is
+ // still served, just not frozen for a year.
+ "static": func(p string) string {
+ return "/static/" + p + "?v=" + c.StaticTag
+ },
"dict": dict,
"date": func(t time.Time) string {
if t.IsZero() {
@@ -43,7 +51,7 @@ func FuncMap(c *Ctx) template.FuncMap {
if len(s) <= n {
return s
}
- return s[:n] + "\u2026"
+ return s[:n] + "…"
},
"bytes": humanBytes,
"patch": renderPatch,
diff --git a/internal/core/route.go b/internal/core/route.go
index 4a3800e..539b8e7 100644
--- a/internal/core/route.go
+++ b/internal/core/route.go
@@ -80,12 +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 (
@@ -95,8 +134,9 @@ const (
// Page sizes for routes that paginate via ?ofs=. Kept in core (not
// modules) so buildCtx can quantize an incoming ofs to a real page
-// boundary without an import cycle. Must match the literal args the
-// log.html/graph.html templates pass to `mod "log"`/`mod "graph"`.
+// 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
@@ -136,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" }
@@ -163,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
+}
diff --git a/internal/core/server.go b/internal/core/server.go
index adf677c..4814a62 100644
--- a/internal/core/server.go
+++ b/internal/core/server.go
@@ -1,6 +1,7 @@
package core
import (
+ "context"
"crypto/sha256"
"encoding/hex"
"errors"
@@ -13,7 +14,11 @@ import (
"sync"
"time"
+ "gsg/internal/access"
"gsg/internal/gitx"
+ "gsg/internal/session"
+
+ "golang.org/x/sync/singleflight"
)
type Server struct {
@@ -23,24 +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
+
+ // 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),
+ 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
@@ -54,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
}
@@ -64,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() {
@@ -93,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 {
@@ -101,54 +142,256 @@ func (s *Server) serve(rt Route, w http.ResponseWriter, r *http.Request) {
}
}
+ if key, ok := s.cacheEligible(rt, r); ok {
+ if cr, ok := s.respCacheGet(key); ok {
+ writeResponse(w, r, cr)
+ return
+ }
+ // singleflight collapses every request that arrives while this key
+ // is cold into one fill. That fill runs on a context detached from
+ // whichever request happened to trigger it: without that, the
+ // first caller to disconnect or time out would kill the shared
+ // git/render work and fail every other request that coalesced onto
+ // it too, even ones with their own deadline still well in the
+ // future. Bounded by s.Timeout so a genuinely stuck fill still
+ // gets reaped.
+ //
+ // The closure below captures r, the *triggering* (leader) request
+ // -- every follower that coalesces onto this Do gets a response
+ // built from the leader's request, not their own. That's only safe
+ // because cacheEligible already pinned every request-varying input
+ // that matters (user == "", no share cookie) and the key covers
+ // path+query, so leader and followers are asking an identical
+ // question. A future CacheableAnon route whose render depends on
+ // anything else request-specific (a header, say) would silently
+ // break this.
+ v, err, _ := s.sf.Do(key, func() (any, error) {
+ fillCtx, cancel := context.WithTimeout(context.Background(), s.Timeout)
+ defer cancel()
+ return s.fillResponse(reqCtx{fillCtx}, rt, r)
+ })
+ if err != nil {
+ s.fail(w, r, rt, err)
+ return
+ }
+ cr := v.(*cachedResponse)
+ s.respCache.Put(key, cr)
+ writeResponse(w, r, cr)
+ return
+ }
+
ctx, cancel := contextWithTimeout(r, s.Timeout)
defer cancel()
- c, err := s.buildCtx(ctx, rt, r)
+ c, header, err := s.buildResponseHead(ctx, rt, r)
if err != nil {
s.fail(w, r, rt, err)
return
}
+ copyHeader(w.Header(), header)
+ if match := r.Header.Get("If-None-Match"); match != "" && strings.Contains(match, header.Get("ETag")) {
+ w.WriteHeader(http.StatusNotModified)
+ return
+ }
+
+ s.mu.RLock()
+ ts := s.templates
+ s.mu.RUnlock()
+ body, err := rt.Render.Render(c, ts)
+ if err != nil {
+ s.fail(w, r, rt, err)
+ return
+ }
+ w.Header().Set("Content-Type", rt.Render.ContentType())
+ if hs, ok := rt.Render.(HeaderSetter); ok {
+ hs.Headers(c, w.Header())
+ }
+ w.Write(body)
+}
+
+// cacheEligible reports whether rt/r may be served from, and stored in,
+// the shared response cache, and if so, the key to use. Eligibility is
+// decided entirely from cheap, git-free signals -- cookies and
+// already-loaded repo config -- so a cache hit costs nothing beyond a map
+// lookup, and a miss doesn't cost anything extra either before falling
+// through to the real render.
+//
+// Two independent conditions gate it, deliberately redundant: the request
+// must carry neither a session nor a share cookie (the same
+// anonymous-and-shareless condition the Cache-Control split in
+// buildResponseHead already trusts), AND, for a repo-scoped route, that
+// repo must not be Hidden -- checked directly from RepoInfo, with no
+// reference to any cookie at all. A cache entry is shared state replayed
+// to whoever asks next, so a false negative here has a bigger blast
+// radius than a single mis-issued header would: the Hidden check means a
+// bug in cookie detection alone can never leak a hidden repo's content,
+// since that repo's responses are never eligible for the shared cache in
+// the first place.
+func (s *Server) cacheEligible(rt Route, r *http.Request) (string, bool) {
+ if !rt.CacheableAnon || s.TemplateDir != "" {
+ return "", false
+ }
+ if s.Sessions.Verify(r) != "" {
+ return "", false
+ }
+ if _, ok := s.Sessions.VerifyShareCookie(r); ok {
+ return "", false
+ }
+ if rt.Scope == ScopeRepo {
+ h, ok := s.Site.Lookup(r.PathValue("repo"))
+ if !ok || h.Info.Hidden {
+ return "", false
+ }
+ }
+ return rt.Name + "\x00" + r.URL.Path + "\x00" + r.URL.RawQuery, true
+}
+
+func (s *Server) respCacheGet(key string) (*cachedResponse, bool) {
+ v, ok := s.respCache.Get(key)
+ if !ok {
+ return nil, false
+ }
+ cr := v.(*cachedResponse)
+ if time.Now().After(cr.expiresAt) {
+ return nil, false
+ }
+ return cr, true
+}
+
+// fillResponse builds a full response for storage in respCache: header and
+// body both, since an entry has to be complete before anyone else can be
+// served from it.
+func (s *Server) fillResponse(ctx contexter, rt Route, r *http.Request) (*cachedResponse, error) {
+ c, header, err := s.buildResponseHead(ctx, rt, r)
+ if err != nil {
+ return nil, err
+ }
+ s.mu.RLock()
+ ts := s.templates
+ s.mu.RUnlock()
+ body, err := rt.Render.Render(c, ts)
+ if err != nil {
+ return nil, err
+ }
+ header.Set("Content-Type", rt.Render.ContentType())
+ if hs, ok := rt.Render.(HeaderSetter); ok {
+ hs.Headers(c, header)
+ }
+ return &cachedResponse{header: header, body: body, expiresAt: time.Now().Add(c.cacheMaxAge())}, nil
+}
+
+// buildResponseHead resolves the Ctx and computes the ETag/Cache-Control
+// header, without executing the template -- split out so a matching
+// If-None-Match can skip rendering the body entirely.
+//
+// ETag is derived from the resolved commit SHA and every other input that
+// can change what's rendered, not the response body, so a conditional
+// request costs one ref lookup instead of a render.
+func (s *Server) buildResponseHead(ctx contexter, rt Route, r *http.Request) (*Ctx, http.Header, error) {
+ c, err := s.buildCtx(ctx, rt, r)
+ if err != nil {
+ return nil, nil, err
+ }
c.Nav = BuildNav(s.Routes, rt.Name, c.Info.Slug, c.Ref)
- // ETag is derived from the resolved commit SHA, not the response body,
- // so a conditional request costs one ref lookup instead of a render.
s.mu.RLock()
tag := s.tmplTag
- ts := s.templates
s.mu.RUnlock()
// contentIdentity carries the commit, keyring, and every Var (e.g.
// compare's base ref) -- the same fragment module results are keyed
// on, so this can't drift out of sync with what CacheKey covers.
- etag := `"` + shortHash(rt.Name, r.URL.Path, r.URL.RawQuery, tag, c.contentIdentity()) + `"`
- w.Header().Set("ETag", etag)
- if rt.Immutable && c.Commit != "" {
- w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+ //
+ // 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 {
- w.Header().Set("Cache-Control", "no-cache")
+ // 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())))
+ }
}
- if match := r.Header.Get("If-None-Match"); match != "" && strings.Contains(match, etag) {
- w.WriteHeader(http.StatusNotModified)
- return
+ return c, header, nil
+}
+
+func copyHeader(dst, src http.Header) {
+ for k, vs := range src {
+ for _, v := range vs {
+ dst.Add(k, v)
+ }
}
+}
- body, err := rt.Render.Render(c, ts)
- if err != nil {
- s.fail(w, r, rt, err)
+func writeResponse(w http.ResponseWriter, r *http.Request, cr *cachedResponse) {
+ copyHeader(w.Header(), cr.header)
+ if match := r.Header.Get("If-None-Match"); match != "" && strings.Contains(match, cr.header.Get("ETag")) {
+ w.WriteHeader(http.StatusNotModified)
return
}
- w.Header().Set("Content-Type", rt.Render.ContentType())
- if hs, ok := rt.Render.(HeaderSetter); ok {
- hs.Headers(c, w.Header())
- }
- w.Write(body)
+ w.Write(cr.body)
}
func (s *Server) buildCtx(ctx contexter, rt Route, r *http.Request) (*Ctx, error) {
+ // 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
}
@@ -157,37 +400,27 @@ func (s *Server) buildCtx(ctx contexter, rt Route, r *http.Request) (*Ctx, error
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)
@@ -196,30 +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.Name == "log" || rt.Name == "graph" {
- pageSize := LogPageSize
- if rt.Name == "graph" {
- pageSize = GraphPageSize
- }
+ if rt.PageSize > 0 {
if v := r.URL.Query().Get("ofs"); v != "" {
if ofs, err := strconv.Atoi(v); err == nil && ofs > 0 {
- ofs -= ofs % pageSize // snap to a page boundary before it can become a cache key
+ 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) {
@@ -231,9 +500,14 @@ func (s *Server) fail(w http.ResponseWriter, r *http.Request, rt Route, err erro
s.Log.Info("request rejected", "route", rt.Name, "path", r.URL.Path, "status", code, "err", err)
}
- // serve() sets ETag/Cache-Control before Render runs, assuming success.
- // An error response must never inherit those, especially the
- // year-long immutable Cache-Control on commit routes.
+ // The cached-response and cache-fill paths in serve() only ever reach
+ // fail() before anything is written to w, so for those there is
+ // nothing to strip. But the plain (non-cacheable) path copies
+ // buildResponseHead's ETag/Cache-Control onto w *before* calling
+ // Render, on the assumption that Render will succeed -- if it doesn't,
+ // this Del/Set is still what prevents a failed response from going out
+ // with a stale ETag or, worse, the year-long immutable Cache-Control
+ // from a commit route.
w.Header().Del("ETag")
w.Header().Set("Cache-Control", "no-store")
diff --git a/internal/gitx/git.go b/internal/gitx/git.go
index b7b38e2..48512b5 100644
--- a/internal/gitx/git.go
+++ b/internal/gitx/git.go
@@ -6,6 +6,7 @@ package gitx
import (
"bytes"
"context"
+ "errors"
"fmt"
"os"
"os/exec"
@@ -108,7 +109,12 @@ 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")}
@@ -515,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.
diff --git a/internal/gitx/git_test.go b/internal/gitx/git_test.go
index cc3b760..73dd814 100644
--- a/internal/gitx/git_test.go
+++ b/internal/gitx/git_test.go
@@ -91,3 +91,67 @@ func TestSplitRefPath(t *testing.T) {
})
}
}
+
+// TestIsAncestor builds its own repo (rather than reusing newTestRepo,
+// whose branches all point at the same single commit) because it needs a
+// real linear ancestor/descendant pair plus a genuinely diverged pair:
+// "main" advances past the point where "other" branched off, then "other"
+// advances on its own, so main's tip and other's tip share c1 as a common
+// ancestor but are not ancestors of each other.
+func TestIsAncestor(t *testing.T) {
+ dir := t.TempDir()
+ runGit(t, dir, "init", "-q", "-b", "main")
+ runGit(t, dir, "commit", "-q", "--allow-empty", "-m", "c1")
+
+ r, err := Open(dir)
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ c1, err := r.Resolve("main")
+ if err != nil {
+ t.Fatalf("Resolve(main): %v", err)
+ }
+
+ runGit(t, dir, "branch", "other")
+ runGit(t, dir, "commit", "-q", "--allow-empty", "-m", "c2")
+ c2, err := r.Resolve("main")
+ if err != nil {
+ t.Fatalf("Resolve(main): %v", err)
+ }
+
+ runGit(t, dir, "checkout", "-q", "other")
+ runGit(t, dir, "commit", "-q", "--allow-empty", "-m", "c3-diverged")
+ c3, err := r.Resolve("other")
+ if err != nil {
+ t.Fatalf("Resolve(other): %v", err)
+ }
+
+ cases := []struct {
+ name string
+ a, b string
+ want bool
+ }{
+ {"strict ancestor", c1, c2, true},
+ {"reversed order is not an ancestor relationship", c2, c1, false},
+ {"a commit is its own ancestor", c1, c1, true},
+ {"diverged commits are not ancestors either direction", c2, c3, false},
+ {"diverged commits, reverse direction", c3, c2, false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := r.IsAncestor(tc.a, tc.b)
+ if err != nil {
+ t.Fatalf("IsAncestor(%q, %q): %v", tc.a, tc.b, err)
+ }
+ if got != tc.want {
+ t.Errorf("IsAncestor(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want)
+ }
+ })
+ }
+
+ t.Run("bogus revision is an error, not false", func(t *testing.T) {
+ if _, err := r.IsAncestor("deadbeef", c1); err == nil {
+ t.Error("IsAncestor with a bogus revision: got nil error, want non-nil")
+ }
+ })
+}
diff --git a/internal/httpx/httpx.go b/internal/httpx/httpx.go
new file mode 100644
index 0000000..c8ad9ca
--- /dev/null
+++ b/internal/httpx/httpx.go
@@ -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
+}
diff --git a/internal/httpx/httpx_test.go b/internal/httpx/httpx_test.go
new file mode 100644
index 0000000..79dca96
--- /dev/null
+++ b/internal/httpx/httpx_test.go
@@ -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)
+ }
+}
diff --git a/internal/modules/blob.go b/internal/modules/blob.go
index dab96e1..57cf2f0 100644
--- a/internal/modules/blob.go
+++ b/internal/modules/blob.go
@@ -14,6 +14,7 @@ type Blob struct {
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
@@ -30,6 +31,14 @@ func (b Blob) Structured() bool { return b.Lang != "" && !b.Oversize }
// 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.
@@ -48,6 +57,7 @@ type Line struct {
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
}
@@ -80,6 +90,13 @@ 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
@@ -113,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) }
@@ -136,5 +155,10 @@ func blobRawMod(c *core.Ctx, args ...string) (any, error) {
if len(head) > 8000 {
head = head[:8000]
}
- return RawBlob{Path: path, Data: raw, Binary: bytes.IndexByte(head, 0) >= 0}, nil
+ return RawBlob{
+ Path: path,
+ Data: raw,
+ Binary: bytes.IndexByte(head, 0) >= 0,
+ Image: imageContentType(path, raw),
+ }, nil
}
diff --git a/internal/modules/image.go b/internal/modules/image.go
new file mode 100644
index 0000000..088489b
--- /dev/null
+++ b/internal/modules/image.go
@@ -0,0 +1,72 @@
+package modules
+
+import (
+ "bytes"
+ "path"
+ "strings"
+)
+
+// maxPreviewSize bounds how large an image the blob view will render inline.
+// Past it the view keeps the download link but drops the preview, so a large
+// file is not pulled over the wire twice (once as the page's subresource,
+// once as the bytes the reader actually wanted). The /raw/ route itself is
+// not capped here: a direct request is an explicit request for the bytes,
+// and MaxGit still bounds every git read -- it must stay above this value or
+// a previewable-sized image would already be truncated by the time it gets
+// here.
+const maxPreviewSize = 10 << 20 // 10 MiB
+
+// imageContentType returns the MIME type to serve at p when its leading
+// bytes are that format and the extension agrees, and "" otherwise.
+//
+// SECURITY: this is the one place /raw/ is allowed to declare a non-text
+// Content-Type, so the rules are deliberately narrow:
+// - only raster formats are recognised. SVG is an XML document that can
+// carry <script>, so it is never in this list and keeps the text/plain
+// fallback; the danger is top-level navigation to an image/svg+xml
+// response on this origin, not <img>, where scripts do not run.
+// - the extension and the magic bytes must BOTH match, so an .png whose
+// contents are really HTML is not relabelled as an image. Relaxing the
+// current attachment rule for attacker-influenced bytes is only safe
+// because a mislabelled file fails here and keeps the download.
+// - the caller still sends X-Content-Type-Options: nosniff and the
+// sandbox CSP, so even a polyglot declared image/* cannot be re-sniffed
+// as a document.
+func imageContentType(p string, data []byte) string {
+ switch strings.ToLower(path.Ext(p)) {
+ case ".png":
+ if bytes.HasPrefix(data, []byte("\x89PNG\r\n\x1a\n")) {
+ return "image/png"
+ }
+ case ".jpg", ".jpeg":
+ if bytes.HasPrefix(data, []byte("\xff\xd8\xff")) {
+ return "image/jpeg"
+ }
+ case ".gif":
+ if bytes.HasPrefix(data, []byte("GIF87a")) || bytes.HasPrefix(data, []byte("GIF89a")) {
+ return "image/gif"
+ }
+ case ".webp":
+ if len(data) >= 12 && bytes.Equal(data[:4], []byte("RIFF")) &&
+ bytes.Equal(data[8:12], []byte("WEBP")) {
+ return "image/webp"
+ }
+ case ".bmp":
+ if bytes.HasPrefix(data, []byte("BM")) {
+ return "image/bmp"
+ }
+ case ".ico":
+ if bytes.HasPrefix(data, []byte("\x00\x00\x01\x00")) {
+ return "image/x-icon"
+ }
+ case ".avif":
+ // ISO-BMFF: "ftyp" at offset 4, major brand avif/avis at 8. A file
+ // with a compatible-but-not-major avif brand is left unlabelled
+ // rather than guessed at.
+ if len(data) >= 12 && bytes.Equal(data[4:8], []byte("ftyp")) &&
+ (bytes.Equal(data[8:12], []byte("avif")) || bytes.Equal(data[8:12], []byte("avis"))) {
+ return "image/avif"
+ }
+ }
+ return ""
+}
diff --git a/internal/modules/image_test.go b/internal/modules/image_test.go
new file mode 100644
index 0000000..ab563a0
--- /dev/null
+++ b/internal/modules/image_test.go
@@ -0,0 +1,99 @@
+package modules
+
+import "testing"
+
+// A file only earns an inline image MIME when its extension and its magic
+// bytes agree and the format is a raster one. The cases that must return ""
+// are the attacker-influenced ones: content that is not an image at all, and
+// SVG, which is an active document rather than a picture.
+func TestImageContentType(t *testing.T) {
+ png := []byte("\x89PNG\r\n\x1a\n" + "rest of the file")
+ tests := []struct {
+ name string
+ path string
+ data []byte
+ want string
+ }{
+ {"png", "logo.png", png, "image/png"},
+ {"uppercase extension", "LOGO.PNG", png, "image/png"},
+ {"jpeg", "a.jpg", []byte{0xff, 0xd8, 0xff, 0xe0}, "image/jpeg"},
+ {"jpeg long extension", "a.jpeg", []byte{0xff, 0xd8, 0xff, 0xe0}, "image/jpeg"},
+ {"gif87", "a.gif", []byte("GIF87a...."), "image/gif"},
+ {"gif89", "a.gif", []byte("GIF89a...."), "image/gif"},
+ {"webp", "a.webp", []byte("RIFF\x00\x00\x00\x00WEBPVP8 "), "image/webp"},
+ {"bmp", "a.bmp", []byte("BM...."), "image/bmp"},
+ {"ico", "a.ico", []byte{0x00, 0x00, 0x01, 0x00}, "image/x-icon"},
+ {"avif", "a.avif", []byte("\x00\x00\x00\x18ftypavif"), "image/avif"},
+
+ // HTML wearing a .png name, and a real PNG named .txt, must both
+ // fall back to the download path.
+ {"html named png", "evil.png", []byte("<html><script>x</script>"), ""},
+ {"png extension without magic", "evil.png", []byte("not an image"), ""},
+ {"magic without extension", "evil.txt", png, ""},
+ {"truncated png", "evil.png", []byte("\x89PNG"), ""},
+
+ // SVG can carry <script> and would be stored XSS if served as a
+ // document on this origin, so it is never recognised here.
+ {"svg", "evil.svg", []byte(`<svg xmlns="http://www.w3.org/2000/svg"><script/></svg>`), ""},
+ {"no extension", "Makefile", []byte("all:\n"), ""},
+ }
+ for _, tc := range tests {
+ if got := imageContentType(tc.path, tc.data); got != tc.want {
+ t.Errorf("%s: imageContentType(%q) = %q, want %q", tc.name, tc.path, got, tc.want)
+ }
+ }
+}
+
+// The blob view only renders a preview for a validated raster image, and
+// only while it is under the size cap; both facts are what tell the view
+// whether to show an <img>, a download notice, or numbered bytes.
+func TestImageBlobPreview(t *testing.T) {
+ png := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 64)...)
+ big := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, maxPreviewSize)...)
+ c := blobCtx(t, map[string]string{
+ "logo.png": string(png),
+ "big.png": string(big),
+ "evil.png": "<html>not an image</html>",
+ })
+
+ b := blobAt(t, c, "logo.png")
+ if b.Image != "image/png" || !b.Preview() || !b.Binary {
+ t.Errorf("small image: Image=%q Preview=%v Binary=%v", b.Image, b.Preview(), b.Binary)
+ }
+ if b.PreviewLimit() != maxPreviewSize {
+ t.Errorf("PreviewLimit = %d, want %d", b.PreviewLimit(), maxPreviewSize)
+ }
+
+ bigBlob := blobAt(t, c, "big.png")
+ if bigBlob.Image != "image/png" {
+ t.Errorf("large image: Image = %q, want image/png", bigBlob.Image)
+ }
+ if bigBlob.Preview() {
+ t.Error("large image was previewed past the size cap")
+ }
+
+ // Not an image, so it keeps the plain binary path rather than a preview.
+ evil := blobAt(t, c, "evil.png")
+ if evil.Image != "" || evil.Preview() {
+ t.Errorf("misnamed html: Image=%q Preview=%v, want none", evil.Image, evil.Preview())
+ }
+}
+
+// /raw/ must declare the image MIME so the preview and direct navigation
+// render a picture, and must expose it through the interface core checks.
+func TestRawBlobInlineImage(t *testing.T) {
+ png := []byte("\x89PNG\r\n\x1a\n" + "body")
+ c := blobCtx(t, map[string]string{"logo.png": string(png)})
+ c.Path = "logo.png"
+ v, err := blobRawMod(c)
+ if err != nil {
+ t.Fatal(err)
+ }
+ r, ok := v.(RawBlob)
+ if !ok {
+ t.Fatalf("got %T", v)
+ }
+ if r.InlineContentType() != "image/png" {
+ t.Errorf("InlineContentType = %q, want image/png", r.InlineContentType())
+ }
+}
diff --git a/internal/modules/notices.go b/internal/modules/notices.go
new file mode 100644
index 0000000..e0df81f
--- /dev/null
+++ b/internal/modules/notices.go
@@ -0,0 +1,29 @@
+package modules
+
+import (
+ "gsg/internal/core"
+ "gsg/internal/notices"
+)
+
+func init() { core.Register("notices", noticesMod) }
+
+// notices: the site-wide banners followed by the ones for the repository
+// being viewed, in that order. Site-scoped routes (the index) build a Ctx
+// with c.Repo == nil and an empty Info.Slug, so this must never touch
+// c.Repo and must skip the repo half when Slug is empty. A nil default
+// store (the feature is off) is an empty list, not an error -- the
+// template uses modOr and guards on Immutable, so an empty list renders
+// nothing.
+func noticesMod(c *core.Ctx, args ...string) (any, error) {
+ st := notices.Default()
+ if st == nil {
+ return []notices.Notice(nil), nil
+ }
+ snap := st.Snapshot()
+ out := make([]notices.Notice, 0, len(snap.Doc.Global)+8)
+ out = append(out, snap.Doc.Global...)
+ if c.Info.Slug != "" {
+ out = append(out, snap.Doc.Repos[c.Info.Slug]...)
+ }
+ return out, nil
+}
diff --git a/internal/modules/notices_test.go b/internal/modules/notices_test.go
new file mode 100644
index 0000000..02ff9e3
--- /dev/null
+++ b/internal/modules/notices_test.go
@@ -0,0 +1,88 @@
+package modules
+
+import (
+ "html/template"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "gsg/internal/core"
+ "gsg/internal/notices"
+)
+
+// Global notices come before the repository's own; a site-scoped Ctx (nil
+// Repo, empty Slug) gets the global list only and must not panic.
+func TestNoticesModMergesGlobalThenRepo(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "notices.yaml")
+ st, err := notices.Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(st.Close)
+ err = st.Set(notices.Document{
+ Global: []notices.Notice{{Type: notices.TypeCritical, Title: "global"}},
+ Repos: map[string][]notices.Notice{"demo": {{Type: notices.TypeInfo, Title: "repo"}}},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ notices.SetDefault(st)
+ t.Cleanup(func() { notices.SetDefault(nil) })
+
+ v, err := noticesMod(&core.Ctx{Info: core.RepoInfo{Slug: "demo"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, ok := v.([]notices.Notice)
+ if !ok {
+ t.Fatalf("notices returned %T", v)
+ }
+ if len(got) != 2 || got[0].Title != "global" || got[1].Title != "repo" {
+ t.Fatalf("got %+v", got)
+ }
+
+ v, err = noticesMod(&core.Ctx{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := v.([]notices.Notice); len(got) != 1 || got[0].Title != "global" {
+ t.Fatalf("site-scoped got %+v", got)
+ }
+}
+
+// The notices partial must render nothing on a page that will be cached
+// immutably, and must not even call the module there -- the guard wraps the
+// modOr call, not just its output.
+func TestNoticesPartialSuppressesImmutable(t *testing.T) {
+ src, err := os.ReadFile("../../templates/layouts/notices.html")
+ if err != nil {
+ t.Fatal(err)
+ }
+ calls := 0
+ tmpl, err := template.New("notices.html").Funcs(template.FuncMap{
+ "modOr": func(string, ...string) any {
+ calls++
+ return []notices.Notice{{Type: notices.TypeInfo, Title: "banner"}}
+ },
+ }).Parse(string(src))
+ if err != nil {
+ t.Fatal(err)
+ }
+ render := func(immutable bool) string {
+ var sb strings.Builder
+ if err := tmpl.ExecuteTemplate(&sb, "notices", struct{ Immutable bool }{immutable}); err != nil {
+ t.Fatal(err)
+ }
+ return sb.String()
+ }
+
+ got := render(true)
+ if strings.Contains(got, "banner") || calls != 0 {
+ t.Fatalf("immutable page rendered notices (calls=%d): %q", calls, got)
+ }
+ got = render(false)
+ if !strings.Contains(got, "banner") || calls != 1 {
+ t.Fatalf("non-immutable page did not render (calls=%d): %q", calls, got)
+ }
+}
diff --git a/internal/modules/repos.go b/internal/modules/repos.go
index 66bc6f6..75d57c8 100644
--- a/internal/modules/repos.go
+++ b/internal/modules/repos.go
@@ -6,6 +6,7 @@ import (
"sync"
"time"
+ "gsg/internal/access"
"gsg/internal/core"
)
@@ -32,14 +33,19 @@ var reposCache struct {
val []RepoSummary
}
-// 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) {
+// 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, nil
+ return out
}
reposCache.mu.Unlock()
@@ -49,17 +55,64 @@ func reposMod(c *core.Ctx, args ...string) (any, error) {
if h.Info.Hidden {
continue
}
- 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
- }
- out = append(out, s)
+ 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))
+ }
+ }
+ if len(unlocked) == 0 {
+ return base, nil
+ }
+
+ out := make([]RepoSummary, 0, len(base)+len(unlocked))
+ out = append(out, base...)
+ out = append(out, unlocked...)
+ sort.Slice(out, func(i, j int) bool { return out[i].LastCommit.After(out[j].LastCommit) })
return out, nil
}
diff --git a/internal/modules/structured.go b/internal/modules/structured.go
index b3f1093..f26045f 100644
--- a/internal/modules/structured.go
+++ b/internal/modules/structured.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"html/template"
"regexp"
+ "sort"
"strconv"
"strings"
@@ -161,9 +162,10 @@ func annotateStructure(lines []Line, raw []byte, lang string) {
}
}
if lang != "yaml" {
+ annotateJSONPaths(lines, raw)
return
}
- anchors, refs := yamlAnchors(src)
+ anchors, refs, paths := yamlAnnotations(src)
for n, a := range anchors {
if n >= 1 && n <= len(lines) {
lines[n-1].Anchor = a
@@ -174,6 +176,11 @@ func annotateStructure(lines []Line, raw []byte, lang string) {
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
@@ -236,27 +243,29 @@ var (
jsonKeyRe = regexp.MustCompile(`^\s*"((?:[^"\\]|\\.)*)"\s*:`)
)
-// yamlAnchors resolves every alias in the file to the line its anchor is
-// declared on, keyed by 1-based line number. 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 yamlAnchors(src [][]byte) (map[int]*Anchor, map[int]*StructRef) {
+// 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)
+ walkYAML(&doc, src, anchors, refs, paths, "")
}
- return anchors, refs
+ return anchors, refs, paths
}
-func walkYAML(n *yaml.Node, src [][]byte, anchors map[int]*Anchor, refs map[int]*StructRef) {
+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 {
@@ -274,9 +283,43 @@ func walkYAML(n *yaml.Node, src [][]byte, anchors map[int]*Anchor, refs map[int]
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
}
- for _, c := range n.Content {
- walkYAML(c, src, anchors, refs)
+
+ 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)
}
}
@@ -362,3 +405,112 @@ func withIndentGuides(lines []template.HTML, unit int) []template.HTML {
}
return out
}
+
+// --------------------------------------------------------------- paths
+
+// A path query names a value the way obj1.property3.items[4].val does.
+// Both the server (here) and static/js/structured.js write the same
+// canonical form so a query can be compared as a string: simple keys are
+// dotted, indices attach directly, and anything else is bracket-quoted.
+var (
+ simplePathKey = regexp.MustCompile(`^[A-Za-z0-9_$-]+$`)
+ pathEscaper = strings.NewReplacer(`\`, `\\`, `"`, `\"`)
+)
+
+func joinPath(parent, key string) string {
+ if simplePathKey.MatchString(key) {
+ if parent == "" {
+ return key
+ }
+ return parent + "." + key
+ }
+ return parent + `["` + pathEscaper.Replace(key) + `"]`
+}
+
+func joinIndex(parent string, i int) string {
+ return parent + "[" + strconv.Itoa(i) + "]"
+}
+
+// recordPath keeps the last path recorded for a line. A line can carry more
+// than one path (JSON/YAML flow style puts several values on one row), and
+// the last one parsed is the deepest -- the one a reader naming a full path
+// is most likely to mean. A container query still reaches its children by
+// the boundary-aware prefix match in structured.js, so dropping the shallow
+// path costs nothing there.
+func recordPath(paths map[int]string, line int, path string) {
+ if path == "" || line <= 0 {
+ return
+ }
+ paths[line] = path
+}
+
+// annotateJSONPaths records each line's canonical value path. It reuses
+// encoding/json's own decoder rather than a second parser, so the offsets it
+// sees are exactly the ones the format defines.
+func annotateJSONPaths(lines []Line, raw []byte) {
+ paths := jsonPaths(raw)
+ for i := range lines {
+ if p, ok := paths[lines[i].N]; ok {
+ lines[i].Path = p
+ }
+ }
+}
+
+func jsonPaths(raw []byte) map[int]string {
+ paths := map[int]string{}
+ lineOf := newLineIndex(raw)
+ dec := json.NewDecoder(bytes.NewReader(raw))
+
+ // walk consumes one JSON value at path. Objects record the path on the
+ // key's line and recurse into the value; arrays record it on each
+ // element's line. A malformed document simply stops -- the parse-error
+ // banner owns reporting that.
+ var walk func(path string)
+ walk = func(path string) {
+ tok, err := dec.Token()
+ if err != nil {
+ return
+ }
+ recordPath(paths, lineOf(dec.InputOffset()), path)
+ d, ok := tok.(json.Delim)
+ if !ok {
+ return
+ }
+ switch d {
+ case '{':
+ for dec.More() {
+ kt, err := dec.Token()
+ if err != nil {
+ return
+ }
+ key, _ := kt.(string)
+ child := joinPath(path, key)
+ recordPath(paths, lineOf(dec.InputOffset()), child)
+ walk(child)
+ }
+ dec.Token() // consume '}'
+ case '[':
+ for i := 0; dec.More(); i++ {
+ walk(joinIndex(path, i))
+ }
+ dec.Token() // consume ']'
+ }
+ }
+ walk("")
+ return paths
+}
+
+// newLineIndex maps a byte offset to a 1-based line number. Newline
+// positions are found once and binary-searched per token, so indexing a
+// whole file stays O(n log n) rather than counting from zero each time.
+func newLineIndex(raw []byte) func(int64) int {
+ var newlines []int64
+ for i, b := range raw {
+ if b == '\n' {
+ newlines = append(newlines, int64(i))
+ }
+ }
+ return func(off int64) int {
+ return sort.Search(len(newlines), func(i int) bool { return newlines[i] >= off }) + 1
+ }
+}
diff --git a/internal/modules/structured_test.go b/internal/modules/structured_test.go
index 4da0c4f..37b6277 100644
--- a/internal/modules/structured_test.go
+++ b/internal/modules/structured_test.go
@@ -156,3 +156,119 @@ func TestMinifiedJSONHasNoFolds(t *testing.T) {
t.Errorf("FoldTo = %d, want 0", ls[0].FoldTo)
}
}
+
+// Nested arrays of objects are the shape a path query like
+// obj1.property3.items[4].val is written against.
+func TestJSONPaths(t *testing.T) {
+ const src = `{
+ "obj1": {
+ "property3": {
+ "items": [
+ {
+ "val": 0
+ },
+ {
+ "val": 1
+ }
+ ]
+ }
+ }
+}`
+ ls := lines(src)
+ annotateStructure(ls, []byte(src), "json")
+
+ want := map[int]string{
+ 2: "obj1",
+ 3: "obj1.property3",
+ 4: "obj1.property3.items",
+ 5: "obj1.property3.items[0]",
+ 6: "obj1.property3.items[0].val",
+ 8: "obj1.property3.items[1]",
+ 9: "obj1.property3.items[1].val",
+ }
+ for _, l := range ls {
+ if got, ok := want[l.N]; ok {
+ if l.Path != got {
+ t.Errorf("line %d Path = %q, want %q", l.N, l.Path, got)
+ }
+ } else if l.Path != "" {
+ t.Errorf("line %d Path = %q, want none", l.N, l.Path)
+ }
+ }
+}
+
+// A flow-style row carries several paths at once; the deepest is kept, so
+// a query naming the final member still resolves. The shallow ones stay
+// reachable when a container query prefix-matches the deep path.
+func TestJSONFlowPathKeepsDeepest(t *testing.T) {
+ const src = `{"a": {"b": 1}}`
+ ls := lines(src)
+ annotateStructure(ls, []byte(src), "json")
+ if ls[0].Path != "a.b" {
+ t.Errorf("Path = %q, want a.b", ls[0].Path)
+ }
+}
+
+func TestYAMLPaths(t *testing.T) {
+ ls := lines(sampleYAML)
+ annotateStructure(ls, []byte(sampleYAML), "yaml")
+
+ want := map[int]string{
+ 1: "defaults",
+ 2: "defaults.adapter",
+ 3: "defaults.pool",
+ 5: "development",
+ 6: "development", // merge key keeps the parent path
+ 7: "development.database",
+ 9: "test",
+ 10: "test",
+ 11: "test.database",
+ 12: "test.hosts",
+ 13: "test.hosts[0]",
+ 14: "test.hosts[1]",
+ }
+ for _, l := range ls {
+ if got, ok := want[l.N]; ok {
+ if l.Path != got {
+ t.Errorf("line %d Path = %q, want %q", l.N, l.Path, got)
+ }
+ } else if l.Path != "" {
+ t.Errorf("line %d Path = %q, want none", l.N, l.Path)
+ }
+ }
+}
+
+func TestYAMLSequenceOfMappingsPaths(t *testing.T) {
+ const src = `obj1:
+ property3:
+ items:
+ - val: 0
+ - val: 1
+`
+ ls := lines(src)
+ annotateStructure(ls, []byte(src), "yaml")
+ if ls[3].Path != "obj1.property3.items[0].val" {
+ t.Errorf("line 4 Path = %q", ls[3].Path)
+ }
+ if ls[4].Path != "obj1.property3.items[1].val" {
+ t.Errorf("line 5 Path = %q", ls[4].Path)
+ }
+}
+
+func TestPathKeyQuoting(t *testing.T) {
+ tests := []struct{ parent, key, want string }{
+ {"", "obj1", "obj1"},
+ {"obj1", "items", "obj1.items"},
+ {"obj1", "a.b", `obj1["a.b"]`},
+ {"", "weird key", `["weird key"]`},
+ {"obj1", `quo"te`, `obj1["quo\"te"]`},
+ }
+ for _, tc := range tests {
+ if got := joinPath(tc.parent, tc.key); got != tc.want {
+ t.Errorf("joinPath(%q, %q) = %q, want %q", tc.parent, tc.key, got, tc.want)
+ }
+ }
+ if got := joinIndex("obj1.items", 4); got != "obj1.items[4]" {
+ t.Errorf("joinIndex = %q", got)
+ }
+}
diff --git a/internal/notices/admin.html b/internal/notices/admin.html
new file mode 100644
index 0000000..e67b63b
--- /dev/null
+++ b/internal/notices/admin.html
@@ -0,0 +1,64 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Notices &middot; {{ .Site }}</title>
+<link rel="stylesheet" href="/static/css/base.css">
+<link rel="stylesheet" href="/static/css/admin.css">
+<script src="/static/js/admin.js" defer></script>
+</head>
+<body>
+<main class="wrap">
+ <header class="hero">
+ <h1 class="page-title">Notices</h1>
+ <p class="page-subtitle">Global notices render above every repository's.</p>
+ </header>
+
+ {{ if .Saved }}<div class="notice notice-fix">Saved.</div>{{ end }}
+ {{ if .Error }}<div class="notice notice-critical">
+ <strong class="notice-title">Not saved</strong>
+ <pre class="notice-body">{{ .Error }}</pre>
+ </div>{{ end }}
+
+ <form method="post" action="/admin/notices" class="admin-form">
+ <input type="hidden" name="csrf" value="{{ .CSRF }}">
+ <div class="table-wrap">
+ <table class="table admin-table">
+ <thead>
+ <tr>
+ <th>Scope</th><th>Type</th><th>Title</th><th>Body</th>
+ <th>Link</th><th>Link text</th><th>Remove</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{ range $i, $row := .Rows }}
+ <tr>
+ <td><select name="scope">
+ <option value="global"{{ if eq $row.Scope "global" }} selected{{ end }}>global</option>
+ {{ range $.Repos }}<option value="{{ . }}"{{ if eq $row.Scope . }} selected{{ end }}>{{ . }}</option>{{ end }}
+ </select></td>
+ <td><select name="type">
+ <option value="critical"{{ if eq $row.Type "critical" }} selected{{ end }}>critical</option>
+ <option value="warning"{{ if eq $row.Type "warning" }} selected{{ end }}>warning</option>
+ <option value="info"{{ if eq $row.Type "info" }} selected{{ end }}>info</option>
+ <option value="fix"{{ if eq $row.Type "fix" }} selected{{ end }}>fix</option>
+ </select></td>
+ <td><input name="title" value="{{ $row.Title }}"></td>
+ <td><textarea name="body" rows="2">{{ $row.Body }}</textarea></td>
+ <td><input name="link" value="{{ $row.Link }}"></td>
+ <td><input name="link_text" value="{{ $row.LinkText }}"></td>
+ <td class="admin-del"><input type="checkbox" name="del" value="{{ $i }}"></td>
+ </tr>
+ {{ end }}
+ </tbody>
+ </table>
+ </div>
+ <p class="admin-actions">
+ <button class="chip admin-save" type="submit">Save notices</button>
+ <button class="chip js-only" type="button" id="add-notice">Add notice</button>
+ </p>
+ </form>
+</main>
+</body>
+</html>
diff --git a/internal/notices/handler.go b/internal/notices/handler.go
new file mode 100644
index 0000000..5c467b0
--- /dev/null
+++ b/internal/notices/handler.go
@@ -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
+}
diff --git a/internal/notices/handler_test.go b/internal/notices/handler_test.go
new file mode 100644
index 0000000..e076a20
--- /dev/null
+++ b/internal/notices/handler_test.go
@@ -0,0 +1,114 @@
+package notices
+
+import (
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "testing"
+)
+
+func testHandler(t *testing.T, cfg HandlerConfig) http.Handler {
+ t.Helper()
+ store, err := Open(filepath.Join(t.TempDir(), "notices.yaml"))
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ t.Cleanup(store.Close)
+ cfg.Store = store
+ if cfg.Logger == nil {
+ cfg.Logger = slog.Default()
+ }
+ h, err := NewHandler(cfg)
+ if err != nil {
+ t.Fatalf("NewHandler: %v", err)
+ }
+ return h
+}
+
+// Without RequiredGroup, the handler behaves exactly as it always did:
+// reaching it at all is enough, regardless of any group header.
+func TestHandlerNoGroupGateByDefault(t *testing.T) {
+ h := testHandler(t, HandlerConfig{})
+
+ req := httptest.NewRequest("GET", "/admin/notices", nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+}
+
+// With RequiredGroup set, a caller whose GroupHeader doesn't list it is
+// refused before the handler does anything else -- no CSRF cookie, no page.
+func TestHandlerRejectsMissingGroup(t *testing.T) {
+ h := testHandler(t, HandlerConfig{RequiredGroup: "gsg-admins"})
+
+ cases := []struct {
+ name string
+ groups string
+ }{
+ {"no header", ""},
+ {"different group", "some-other-group"},
+ {"prefix collision", "gsg-admins-extra"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ req := httptest.NewRequest("GET", "/admin/notices", nil)
+ if tc.groups != "" {
+ req.Header.Set("X-Authentik-Groups", tc.groups)
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
+ }
+ if rec.Result().Cookies() != nil && len(rec.Result().Cookies()) != 0 {
+ t.Errorf("rejected request set cookies: %v", rec.Result().Cookies())
+ }
+ })
+ }
+}
+
+// A member -- anywhere in the "|"-delimited list, with incidental
+// whitespace -- is let through.
+func TestHandlerAllowsMemberGroup(t *testing.T) {
+ h := testHandler(t, HandlerConfig{RequiredGroup: "gsg-admins"})
+
+ cases := []string{
+ "gsg-admins",
+ "other-group|gsg-admins",
+ "gsg-admins|other-group",
+ "other-group| gsg-admins |another",
+ }
+ for _, groups := range cases {
+ req := httptest.NewRequest("GET", "/admin/notices", nil)
+ req.Header.Set("X-Authentik-Groups", groups)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Errorf("groups=%q: status = %d, want %d", groups, rec.Code, http.StatusOK)
+ }
+ }
+}
+
+// A custom GroupHeader is honored instead of the Authentik default.
+func TestHandlerCustomGroupHeader(t *testing.T) {
+ h := testHandler(t, HandlerConfig{RequiredGroup: "gsg-admins", GroupHeader: "X-Custom-Groups"})
+
+ req := httptest.NewRequest("GET", "/admin/notices", nil)
+ req.Header.Set("X-Authentik-Groups", "gsg-admins") // wrong header, should not count
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("status = %d, want %d (wrong header should not authorize)", rec.Code, http.StatusForbidden)
+ }
+
+ req = httptest.NewRequest("GET", "/admin/notices", nil)
+ req.Header.Set("X-Custom-Groups", "gsg-admins")
+ rec = httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+}
diff --git a/internal/notices/notices.go b/internal/notices/notices.go
new file mode 100644
index 0000000..c855f67
--- /dev/null
+++ b/internal/notices/notices.go
@@ -0,0 +1,221 @@
+// Package notices holds operator-authored banners shown at the top of every
+// page: a site-wide list plus a list per repository. Content lives in one
+// YAML file that is watched for changes, so editing it never needs a
+// rebuild, and the optional admin listener can rewrite it.
+//
+// This package deliberately does not import internal/core: it is plain
+// config, and keeping it dependency-free lets both the module and the admin
+// handler reach it without an import cycle.
+package notices
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "net/url"
+ "sort"
+ "strings"
+ "unicode/utf8"
+
+ "gopkg.in/yaml.v3"
+)
+
+// Severities. The value doubles as the CSS class suffix, so it is validated
+// on every load and save; a typo must not become an unstyled banner.
+const (
+ TypeCritical = "critical"
+ TypeWarning = "warning"
+ TypeInfo = "info"
+ TypeFix = "fix"
+)
+
+// Notice is one banner. Link and LinkText are optional; an empty Link
+// renders no anchor.
+type Notice struct {
+ Type string `yaml:"type"`
+ Title string `yaml:"title,omitempty"`
+ Body string `yaml:"body,omitempty"`
+ Link string `yaml:"link,omitempty"`
+ LinkText string `yaml:"link_text,omitempty"`
+}
+
+// Document is the on-disk shape. Global renders above every repo's list.
+type Document struct {
+ Global []Notice `yaml:"global,omitempty"`
+ Repos map[string][]Notice `yaml:"repos,omitempty"`
+}
+
+// Snapshot is an immutable document plus the content hash identifying it.
+// They are carried together so a reader can never see data and revision
+// disagree.
+type Snapshot struct {
+ Doc Document
+ Rev string
+}
+
+const (
+ maxNotices = 200
+ maxPerRepo = 50
+ maxTitle = 200
+ maxBody = 4000
+ maxLink = 2048
+ maxLinkText = 120
+)
+
+// typeAliases make hand-editing forgiving without letting an invalid type
+// reach the template.
+var typeAliases = map[string]string{
+ "critical": TypeCritical,
+ "warning": TypeWarning,
+ "warn": TypeWarning,
+ "info": TypeInfo,
+ "informational": TypeInfo,
+ "information": TypeInfo,
+ "fix": TypeFix,
+ "fixed": TypeFix,
+}
+
+func canonicalType(s string) (string, bool) {
+ t, ok := typeAliases[strings.ToLower(strings.TrimSpace(s))]
+ return t, ok
+}
+
+// validSlug mirrors a repository directory name: one path element, no
+// separators. It keeps the repos map keys from becoming junk.
+func validSlug(s string) bool {
+ if s == "" || s == "." || s == ".." {
+ return false
+ }
+ for _, r := range s {
+ switch {
+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
+ r == '.', r == '_', r == '-':
+ default:
+ return false
+ }
+ }
+ return true
+}
+
+// checkLink allows a site-relative path and the http/https/mailto schemes.
+// html/template would neutralise a javascript: URL anyway, but rejecting it
+// here turns a silent #ZgotmplZ into a visible form error.
+func checkLink(raw string) error {
+ if strings.HasPrefix(raw, "//") || strings.ContainsAny(raw, "\\\x00\r\n") {
+ return fmt.Errorf("link %q is not allowed", raw)
+ }
+ u, err := url.Parse(raw)
+ if err != nil {
+ return fmt.Errorf("link %q: %w", raw, err)
+ }
+ if !u.IsAbs() {
+ return nil // relative path
+ }
+ switch strings.ToLower(u.Scheme) {
+ case "http", "https", "mailto":
+ return nil
+ default:
+ return fmt.Errorf("link %q: scheme %q is not allowed", raw, u.Scheme)
+ }
+}
+
+// validate trims and canonicalises in place and returns every problem it
+// found, so the admin form can show all of them at once.
+func validate(doc *Document) error {
+ var errs []error
+ total := 0
+ check := func(list []Notice, where string) []Notice {
+ for i := range list {
+ n := &list[i]
+ total++
+ if t, ok := canonicalType(n.Type); ok {
+ n.Type = t
+ } else {
+ errs = append(errs, fmt.Errorf("%s[%d]: type %q must be one of critical, warning, info, fix", where, i, n.Type))
+ }
+ n.Title = strings.TrimSpace(n.Title)
+ n.Body = strings.TrimRight(n.Body, "\n")
+ n.Link = strings.TrimSpace(n.Link)
+ n.LinkText = strings.TrimSpace(n.LinkText)
+ if n.Title == "" && n.Body == "" {
+ errs = append(errs, fmt.Errorf("%s[%d]: needs a title or a body", where, i))
+ }
+ switch {
+ case utf8.RuneCountInString(n.Title) > maxTitle:
+ errs = append(errs, fmt.Errorf("%s[%d]: title longer than %d characters", where, i, maxTitle))
+ case utf8.RuneCountInString(n.Body) > maxBody:
+ errs = append(errs, fmt.Errorf("%s[%d]: body longer than %d characters", where, i, maxBody))
+ case utf8.RuneCountInString(n.LinkText) > maxLinkText:
+ errs = append(errs, fmt.Errorf("%s[%d]: link text longer than %d characters", where, i, maxLinkText))
+ }
+ if len(n.Link) > maxLink {
+ errs = append(errs, fmt.Errorf("%s[%d]: link longer than %d bytes", where, i, maxLink))
+ }
+ if n.Link != "" {
+ if err := checkLink(n.Link); err != nil {
+ errs = append(errs, fmt.Errorf("%s[%d]: %w", where, i, err))
+ }
+ }
+ }
+ return list
+ }
+
+ doc.Global = check(doc.Global, "global")
+ slugs := make([]string, 0, len(doc.Repos))
+ for slug := range doc.Repos {
+ slugs = append(slugs, slug)
+ }
+ sort.Strings(slugs)
+ for _, slug := range slugs {
+ if !validSlug(slug) {
+ errs = append(errs, fmt.Errorf("repos: %q is not a valid repository slug", slug))
+ continue
+ }
+ if len(doc.Repos[slug]) > maxPerRepo {
+ errs = append(errs, fmt.Errorf("repos.%s: more than %d notices", slug, maxPerRepo))
+ }
+ doc.Repos[slug] = check(doc.Repos[slug], "repos."+slug)
+ }
+ if total > maxNotices {
+ errs = append(errs, fmt.Errorf("more than %d notices in total", maxNotices))
+ }
+ return errors.Join(errs...)
+}
+
+// parse decodes strictly: an unknown key is a typo the operator should see,
+// not something silently ignored. An empty file is an empty document.
+func parse(b []byte) (Document, error) {
+ var doc Document
+ dec := yaml.NewDecoder(bytes.NewReader(b))
+ dec.KnownFields(true)
+ if err := dec.Decode(&doc); err != nil {
+ if errors.Is(err, io.EOF) {
+ return Document{Repos: map[string][]Notice{}}, nil
+ }
+ return Document{}, fmt.Errorf("notices: %w", err)
+ }
+ if err := validate(&doc); err != nil {
+ return Document{}, err
+ }
+ if doc.Repos == nil {
+ doc.Repos = map[string][]Notice{}
+ }
+ return doc, nil
+}
+
+// marshal assumes the document has already been through validate, which is
+// what canonicalises severity aliases and trims fields. Both callers (parse
+// and Store.Set) validate first.
+func marshal(doc Document) ([]byte, error) {
+ var buf bytes.Buffer
+ enc := yaml.NewEncoder(&buf)
+ enc.SetIndent(2)
+ if err := enc.Encode(doc); err != nil {
+ return nil, err
+ }
+ if err := enc.Close(); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
diff --git a/internal/notices/store.go b/internal/notices/store.go
new file mode 100644
index 0000000..bfb04bc
--- /dev/null
+++ b/internal/notices/store.go
@@ -0,0 +1,230 @@
+package notices
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/fsnotify/fsnotify"
+)
+
+// Store owns one notices file. Reads are lock-free (an atomic snapshot);
+// writes and the watcher's reloads serialise on mu so a web save and an
+// external edit can never interleave.
+type Store struct {
+ path string
+ dir string
+
+ mu sync.Mutex
+ cur atomic.Pointer[Snapshot]
+
+ fsw *fsnotify.Watcher
+ done chan struct{}
+ closeOnce sync.Once
+}
+
+// Open loads the file (an absent file is an empty document) and starts
+// watching its directory. A parse error is fatal at startup so a typo is
+// visible immediately; later reload errors keep the last good snapshot.
+func Open(path string) (*Store, error) {
+ abs, err := filepath.Abs(path)
+ if err != nil {
+ return nil, err
+ }
+ dir := filepath.Dir(abs)
+ if fi, err := os.Stat(dir); err != nil || !fi.IsDir() {
+ return nil, fmt.Errorf("notices: directory %s does not exist", dir)
+ }
+
+ s := &Store{path: abs, dir: dir, done: make(chan struct{})}
+ snap, err := readSnapshot(abs)
+ if err != nil {
+ return nil, err
+ }
+ s.cur.Store(snap)
+
+ fsw, err := fsnotify.NewWatcher()
+ if err != nil {
+ return nil, fmt.Errorf("notices watch: %w", err)
+ }
+ if err := fsw.Add(dir); err != nil {
+ fsw.Close()
+ return nil, fmt.Errorf("notices watch: %w", err)
+ }
+ s.fsw = fsw
+ go s.watch()
+ return s, nil
+}
+
+// readSnapshot reads and hashes the file. A missing file is an empty
+// document, not an error -- the admin UI creates it on first save.
+func readSnapshot(path string) (*Snapshot, error) {
+ b, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ b = nil
+ } else {
+ return nil, fmt.Errorf("notices: %w", err)
+ }
+ }
+ doc, err := parse(b)
+ if err != nil {
+ return nil, err
+ }
+ return &Snapshot{Doc: doc, Rev: hash(b)}, nil
+}
+
+func hash(b []byte) string {
+ sum := sha256.Sum256(b)
+ return hex.EncodeToString(sum[:])[:16]
+}
+
+// Snapshot is the current document. The returned value shares its backing
+// slices and map with the stored one; callers must not mutate it.
+func (s *Store) Snapshot() Snapshot {
+ p := s.cur.Load()
+ if p == nil {
+ return Snapshot{}
+ }
+ return *p
+}
+
+// Revision is the content hash. O(1); safe to call on every request.
+func (s *Store) Revision() string {
+ p := s.cur.Load()
+ if p == nil {
+ return ""
+ }
+ return p.Rev
+}
+
+// Set validates, writes atomically (temp file in the same directory, fsync,
+// rename, 0600), and publishes the new snapshot immediately, so the next
+// request sees it without waiting for the watcher.
+func (s *Store) Set(doc Document) error {
+ if err := validate(&doc); err != nil {
+ return err
+ }
+ b, err := marshal(doc)
+ if err != nil {
+ return err
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ tmp, err := os.CreateTemp(s.dir, ".notices-*.tmp")
+ if err != nil {
+ return fmt.Errorf("notices: %w", err)
+ }
+ tmpName := tmp.Name()
+ defer os.Remove(tmpName) // no-op once the rename succeeds
+ if err := tmp.Chmod(0o600); err != nil {
+ tmp.Close()
+ return err
+ }
+ if _, err := tmp.Write(b); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Sync(); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ if err := os.Rename(tmpName, s.path); err != nil {
+ return err
+ }
+
+ s.cur.Store(&Snapshot{Doc: doc, Rev: hash(b)})
+ return nil
+}
+
+// watch is the only goroutine that touches the debounce timer, so no lock
+// is needed for it. Events for our own ".notices-*.tmp" files are ignored;
+// only the real path triggers a reload.
+func (s *Store) watch() {
+ const debounce = 200 * time.Millisecond
+ var timer *time.Timer
+ var timerC <-chan time.Time
+
+ for {
+ select {
+ case ev, ok := <-s.fsw.Events:
+ if !ok {
+ return
+ }
+ if filepath.Clean(ev.Name) != s.path {
+ continue
+ }
+ if timer == nil {
+ timer = time.NewTimer(debounce)
+ } else {
+ if !timer.Stop() {
+ select {
+ case <-timer.C:
+ default:
+ }
+ }
+ timer.Reset(debounce)
+ }
+ timerC = timer.C
+ case <-timerC:
+ timerC = nil
+ s.reload()
+ case err, ok := <-s.fsw.Errors:
+ if !ok {
+ return
+ }
+ slog.Warn("notices watch error", "err", err)
+ case <-s.done:
+ if timer != nil {
+ timer.Stop()
+ }
+ return
+ }
+ }
+}
+
+// reload re-reads the file and swaps the snapshot only when the content
+// hash moved. A parse error keeps the previous snapshot, so a half-written
+// or mistyped file never blanks the banners.
+func (s *Store) reload() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ snap, err := readSnapshot(s.path)
+ if err != nil {
+ slog.Warn("notices: keeping previous snapshot", "err", err)
+ return
+ }
+ if cur := s.cur.Load(); cur != nil && cur.Rev == snap.Rev {
+ return
+ }
+ s.cur.Store(snap)
+ slog.Info("notices reloaded", "revision", snap.Rev)
+}
+
+func (s *Store) Close() {
+ s.closeOnce.Do(func() {
+ close(s.done)
+ if s.fsw != nil {
+ s.fsw.Close()
+ }
+ })
+}
+
+// def is the process-wide store the `notices` module reads. A nil default
+// means the feature is off; the module returns an empty list, not an error.
+var def atomic.Pointer[Store]
+
+func SetDefault(s *Store) { def.Store(s) }
+func Default() *Store { return def.Load() }
diff --git a/internal/notices/store_test.go b/internal/notices/store_test.go
new file mode 100644
index 0000000..48ed6b7
--- /dev/null
+++ b/internal/notices/store_test.go
@@ -0,0 +1,150 @@
+package notices
+
+import (
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func TestParseRejectsUnknownField(t *testing.T) {
+ _, err := parse([]byte("global:\n - type: info\n subject: hi\n"))
+ if err == nil {
+ t.Fatal("unknown field accepted")
+ }
+ if !strings.Contains(err.Error(), "subject") {
+ t.Fatalf("error does not name the field: %v", err)
+ }
+}
+
+func TestParseRejectsBadType(t *testing.T) {
+ _, err := parse([]byte("global:\n - type: critcal\n title: hi\n"))
+ if err == nil || !strings.Contains(err.Error(), "critcal") {
+ t.Fatalf("bad type: err = %v", err)
+ }
+}
+
+func TestParseEmpty(t *testing.T) {
+ for _, in := range [][]byte{nil, []byte(""), []byte("{}\n")} {
+ doc, err := parse(in)
+ if err != nil {
+ t.Fatalf("parse(%q): %v", in, err)
+ }
+ if len(doc.Global) != 0 || len(doc.Repos) != 0 {
+ t.Fatalf("parse(%q) = %+v, want empty", in, doc)
+ }
+ if doc.Repos == nil {
+ t.Fatalf("parse(%q): Repos is nil", in)
+ }
+ }
+}
+
+func TestTypeAliasesAndTrimming(t *testing.T) {
+ doc, err := parse([]byte("global:\n - type: informational\n title: ' spaced '\n"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ n := doc.Global[0]
+ if n.Type != TypeInfo || n.Title != "spaced" {
+ t.Fatalf("got %+v", n)
+ }
+}
+
+func TestLinkSchemes(t *testing.T) {
+ cases := []struct {
+ link string
+ ok bool
+ }{
+ {"/status", true},
+ {"https://example.org/a", true},
+ {"mailto:ops@example.org", true},
+ {"javascript:alert(1)", false},
+ {"//evil.example", false},
+ {"ftp://example.org", false},
+ }
+ for _, tc := range cases {
+ doc := Document{Global: []Notice{{Type: TypeInfo, Title: "x", Link: tc.link}}}
+ err := validate(&doc)
+ if tc.ok && err != nil {
+ t.Errorf("link %q rejected: %v", tc.link, err)
+ }
+ if !tc.ok && err == nil {
+ t.Errorf("link %q accepted", tc.link)
+ }
+ }
+}
+
+func TestSetRoundTripAndRevision(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "notices.yaml")
+ st, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(st.Close)
+
+ if st.Revision() == "" {
+ t.Fatal("empty store has no revision")
+ }
+ doc := Document{
+ Global: []Notice{{Type: TypeCritical, Title: "Outage", Body: "line\nline"}},
+ Repos: map[string][]Notice{"demo": {{Type: "informational", Title: "Repo"}}},
+ }
+ if err := st.Set(doc); err != nil {
+ t.Fatal(err)
+ }
+ // informational must have been canonicalised in the published snapshot.
+ if got := st.Snapshot().Doc.Repos["demo"][0].Type; got != TypeInfo {
+ t.Fatalf("published type = %q, want %q", got, TypeInfo)
+ }
+ rev := st.Revision()
+
+ fi, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if perm := fi.Mode().Perm(); perm != 0o600 {
+ t.Errorf("file mode = %o, want 600", perm)
+ }
+
+ again, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(again.Close)
+ if !reflect.DeepEqual(st.Snapshot().Doc, again.Snapshot().Doc) {
+ t.Fatalf("round trip mismatch:\n%+v\n%+v", st.Snapshot().Doc, again.Snapshot().Doc)
+ }
+
+ // A no-op save is byte-identical and keeps the revision.
+ if err := st.Set(doc); err != nil {
+ t.Fatal(err)
+ }
+ if st.Revision() != rev {
+ t.Fatalf("no-op save changed revision %q -> %q", rev, st.Revision())
+ }
+
+ // A content change moves it.
+ doc.Global[0].Body = "different"
+ if err := st.Set(doc); err != nil {
+ t.Fatal(err)
+ }
+ if st.Revision() == rev {
+ t.Fatal("content change kept the same revision")
+ }
+}
+
+func TestOpenMissingFileAndBadDir(t *testing.T) {
+ dir := t.TempDir()
+ st, err := Open(filepath.Join(dir, "notices.yaml"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(st.Close)
+ if len(st.Snapshot().Doc.Global) != 0 {
+ t.Fatal("missing file is not empty")
+ }
+ if _, err := Open(filepath.Join(dir, "nope", "notices.yaml")); err == nil {
+ t.Fatal("missing directory accepted")
+ }
+}
diff --git a/internal/session/session.go b/internal/session/session.go
new file mode 100644
index 0000000..d78a27f
--- /dev/null
+++ b/internal/session/session.go
@@ -0,0 +1,280 @@
+// Package session mints and verifies the signed, stateless cookies gsg
+// uses to recognize a logged-in browser. There is no server-side session
+// store: everything needed to verify a cookie travels inside it, HMAC-signed
+// against a secret that lives only in this process's memory. A restart
+// invalidates every outstanding session -- an accepted trade-off for a
+// login this lightweight.
+//
+// The master secret is never used to sign anything directly. Each purpose
+// (today: the user session cookie; later: share-link tokens) gets its own
+// key derived from the secret via deriveKey, so a value signed for one
+// purpose can never be replayed as another -- that's enforced by
+// construction, not by a discriminator field a bug could skip.
+package session
+
+import (
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/subtle"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ // CookieName is the browser cookie carrying the signed session.
+ CookieName = "gsg_user"
+
+ // TTL is how long a session lasts after login before it must be
+ // re-established. Embedded inside the signed payload, not left to the
+ // cookie's own Max-Age/Expires alone, so a copied cookie can't outlive
+ // what the server considers valid.
+ TTL = 30 * 24 * time.Hour
+
+ // ShareCookieName is the browser cookie a redeemed share link mints.
+ ShareCookieName = "gsg_share"
+
+ purposeSession = "session"
+ purposeShareURL = "share-url"
+ purposeShareCookie = "share-cookie"
+)
+
+// Manager issues and verifies gsg's signed cookies. Safe for concurrent
+// use; a nil *Manager verifies every request as anonymous, so wiring it up
+// is optional.
+type Manager struct {
+ secret []byte
+}
+
+// NewManager generates a fresh in-memory secret.
+func NewManager() *Manager {
+ secret := make([]byte, 32)
+ if _, err := rand.Read(secret); err != nil {
+ panic("session: failed to generate secret: " + err.Error())
+ }
+ return &Manager{secret: secret}
+}
+
+// deriveKey derives a purpose-scoped signing key from the master secret.
+// This is a single-step HMAC-based derivation (not full RFC 5869 HKDF),
+// which is sufficient here: the input is already a uniformly random 32-byte
+// secret, so there's no entropy-extraction step to do, only labeling.
+func (m *Manager) deriveKey(purpose string) []byte {
+ mac := hmac.New(sha256.New, m.secret)
+ mac.Write([]byte(purpose))
+ return mac.Sum(nil)
+}
+
+// Issue mints a signed session cookie for username.
+func (m *Manager) Issue(username string) *http.Cookie {
+ exp := time.Now().Add(TTL).Unix()
+ payload := username + "|" + strconv.FormatInt(exp, 10)
+ return &http.Cookie{
+ Name: CookieName,
+ Value: sign(m.deriveKey(purposeSession), payload, ""),
+ Path: "/",
+ HttpOnly: true,
+ Secure: true,
+ SameSite: http.SameSiteLaxMode,
+ Expires: time.Unix(exp, 0),
+ }
+}
+
+// Verify reads and checks the session cookie on r, returning the
+// authenticated username, or "" if there is none, it's malformed, it's
+// signed for a different purpose, or it has expired.
+func (m *Manager) Verify(r *http.Request) string {
+ if m == nil {
+ return ""
+ }
+ c, err := r.Cookie(CookieName)
+ if err != nil {
+ return ""
+ }
+ payload, ok := verify(m.deriveKey(purposeSession), c.Value, "")
+ if !ok {
+ return ""
+ }
+ user, expStr, ok := strings.Cut(payload, "|")
+ if !ok {
+ return ""
+ }
+ exp, err := strconv.ParseInt(expStr, 10, 64)
+ if err != nil || time.Now().Unix() > exp {
+ return ""
+ }
+ return user
+}
+
+// Clear returns a cookie that immediately expires the session.
+func (m *Manager) Clear() *http.Cookie {
+ return &http.Cookie{
+ Name: CookieName,
+ Value: "",
+ Path: "/",
+ HttpOnly: true,
+ Secure: true,
+ SameSite: http.SameSiteLaxMode,
+ MaxAge: -1,
+ }
+}
+
+// ShareClaims is the payload embedded in both a share-URL token and the
+// gsg_share cookie minted on redemption. Slugs is the set of repo slugs the
+// grant unlocks; IAT/Exp are unix seconds. Pwd records only whether the
+// URL token requires a password -- it carries no password material itself.
+type ShareClaims struct {
+ V int `json:"v"`
+ Slugs []string `json:"slugs"`
+ IAT int64 `json:"iat"`
+ Exp int64 `json:"exp"`
+ Pwd bool `json:"pwd"`
+}
+
+func (c ShareClaims) expired() bool { return c.Exp != 0 && time.Now().Unix() > c.Exp }
+
+// IssuedAt is c.IAT as a time.Time, for comparison against a repo's
+// gsg.share-revoked-after.
+func (c ShareClaims) IssuedAt() time.Time { return time.Unix(c.IAT, 0) }
+
+// NewShareToken signs a share-URL token. password is "" for an unprotected
+// link; when set, it is folded into the signature, never stored in the
+// token -- see the package doc.
+func (m *Manager) NewShareToken(slugs []string, ttl time.Duration, password string) (string, ShareClaims) {
+ now := time.Now()
+ claims := ShareClaims{V: 1, Slugs: slugs, IAT: now.Unix(), Exp: now.Add(ttl).Unix(), Pwd: password != ""}
+ b, _ := json.Marshal(claims) // ShareClaims always marshals; no error path
+ return sign(m.deriveKey(purposeShareURL), string(b), password), claims
+}
+
+// PeekShareToken decodes a share-URL token's claims WITHOUT verifying its
+// signature. It exists only so the redemption page can decide whether to
+// show a password prompt before it knows the password; the result must
+// never be used to grant access -- only VerifyShareToken's result may do
+// that.
+func PeekShareToken(tok string) (ShareClaims, bool) {
+ encPayload, _, found := strings.Cut(tok, ".")
+ if !found {
+ return ShareClaims{}, false
+ }
+ b, err := dec(encPayload)
+ if err != nil {
+ return ShareClaims{}, false
+ }
+ var c ShareClaims
+ if err := json.Unmarshal(b, &c); err != nil || c.V != 1 {
+ return ShareClaims{}, false
+ }
+ return c, true
+}
+
+// VerifyShareToken checks a share-URL token against the password the
+// visitor supplied (pass "" for a token that doesn't require one) and
+// returns its claims. Wrong password, tampering, or expiry all fail the
+// same way -- there is nothing here for an attacker to distinguish.
+func (m *Manager) VerifyShareToken(tok, password string) (ShareClaims, bool) {
+ payload, ok := verify(m.deriveKey(purposeShareURL), tok, password)
+ if !ok {
+ return ShareClaims{}, false
+ }
+ var c ShareClaims
+ if err := json.Unmarshal([]byte(payload), &c); err != nil || c.V != 1 || c.expired() {
+ return ShareClaims{}, false
+ }
+ return c, true
+}
+
+// IssueShareCookie mints the gsg_share cookie from already-verified claims.
+// IAT and Exp are copied verbatim from the URL token, not restamped to
+// "now": the revocation check compares a repo's gsg.share-revoked-after
+// against IAT, and resetting it here would let an already-redeemed browser
+// outlive a revocation aimed at it.
+func (m *Manager) IssueShareCookie(claims ShareClaims) *http.Cookie {
+ b, _ := json.Marshal(claims)
+ return &http.Cookie{
+ Name: ShareCookieName,
+ Value: sign(m.deriveKey(purposeShareCookie), string(b), ""),
+ Path: "/",
+ HttpOnly: true,
+ Secure: true,
+ SameSite: http.SameSiteLaxMode,
+ Expires: time.Unix(claims.Exp, 0),
+ }
+}
+
+// VerifyShareCookie reads and checks the gsg_share cookie on r.
+func (m *Manager) VerifyShareCookie(r *http.Request) (ShareClaims, bool) {
+ if m == nil {
+ return ShareClaims{}, false
+ }
+ c, err := r.Cookie(ShareCookieName)
+ if err != nil {
+ return ShareClaims{}, false
+ }
+ payload, ok := verify(m.deriveKey(purposeShareCookie), c.Value, "")
+ if !ok {
+ return ShareClaims{}, false
+ }
+ var claims ShareClaims
+ if err := json.Unmarshal([]byte(payload), &claims); err != nil || claims.V != 1 || claims.expired() {
+ return ShareClaims{}, false
+ }
+ return claims, true
+}
+
+// sign produces "base64url(payload).base64url(hmac-sha256(payload||0||extra))".
+// Compact and self-contained like a JWT, without pulling in a JWT library or
+// its algorithm-confusion surface (alg:none, HS/RS key confusion) -- the key
+// and the hash are fixed by this package, not negotiable per token. extra is
+// "" for every purpose except a password-protected share-URL token, where
+// it's the password: folding it into the signature input (rather than
+// storing a hash of it in the payload) means nobody holding just the token
+// can crack it offline, and a guess can only be checked by asking the
+// server, which can rate-limit attempts.
+func sign(key []byte, payload, extra string) string {
+ mac := hmac.New(sha256.New, key)
+ mac.Write([]byte(payload))
+ mac.Write([]byte{0})
+ mac.Write([]byte(extra))
+ return enc(payload) + "." + enc(string(mac.Sum(nil)))
+}
+
+// verify checks tok against key and extra and, on success, returns the
+// payload.
+func verify(key []byte, tok, extra string) (payload string, ok bool) {
+ encPayload, encSig, found := strings.Cut(tok, ".")
+ if !found {
+ return "", false
+ }
+ payloadBytes, err := dec(encPayload)
+ if err != nil {
+ return "", false
+ }
+ sig, err := dec(encSig)
+ if err != nil {
+ return "", false
+ }
+ mac := hmac.New(sha256.New, key)
+ mac.Write(payloadBytes)
+ mac.Write([]byte{0})
+ mac.Write([]byte(extra))
+ if subtle.ConstantTimeCompare(sig, mac.Sum(nil)) != 1 {
+ return "", false
+ }
+ return string(payloadBytes), true
+}
+
+func enc(s string) string { return base64.RawURLEncoding.EncodeToString([]byte(s)) }
+
+func dec(s string) ([]byte, error) {
+ b, err := base64.RawURLEncoding.DecodeString(s)
+ if err != nil {
+ return nil, fmt.Errorf("session: bad encoding: %w", err)
+ }
+ return b, nil
+}
diff --git a/internal/share/new.html b/internal/share/new.html
new file mode 100644
index 0000000..5651865
--- /dev/null
+++ b/internal/share/new.html
@@ -0,0 +1,64 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Share &middot; {{ .Site }}</title>
+<link rel="stylesheet" href="/static/css/base.css">
+<link rel="stylesheet" href="/static/css/share.css">
+</head>
+<body>
+<main class="wrap">
+ <header class="hero">
+ <h1 class="page-title">Share a hidden repository</h1>
+ <p class="page-subtitle">Anyone with the link can view exactly the repositories you select below &mdash; no login required.</p>
+ </header>
+
+ {{ if .Result }}
+ <div class="notice notice-fix">
+ <strong class="notice-title">Link ready</strong>
+ <p class="share-link"><a href="{{ .Result }}">{{ .Result }}</a></p>
+ <p class="share-hint">Send this URL to whoever needs it. If you set a password, tell them separately &mdash; it isn't part of the link.</p>
+ </div>
+ <p><a href="/share/new">Create another</a></p>
+ {{ else }}
+ {{ if .Error }}
+ <div class="notice notice-critical">
+ <strong class="notice-title">Not created</strong>
+ <p>{{ .Error }}</p>
+ </div>
+ {{ end }}
+
+ {{ if not .Repos }}
+ <p>You don't have any hidden repositories to share.</p>
+ {{ else }}
+ <form method="post" action="/share/new" class="share-form">
+ <fieldset class="share-repos">
+ <legend>Repositories</legend>
+ {{ $sel := .Selected }}
+ {{ range .Repos }}
+ <label class="share-repo">
+ <input type="checkbox" name="repo" value="{{ .Slug }}"{{ if index $sel .Slug }} checked{{ end }}>
+ <span class="share-repo-title">{{ .Title }}</span>
+ <span class="share-maxage">up to {{ humanDuration .MaxAge }}</span>
+ </label>
+ {{ end }}
+ </fieldset>
+
+ <label class="share-field">
+ <span>Password (optional)</span>
+ <input type="password" name="password" autocomplete="new-password">
+ </label>
+
+ <label class="share-field">
+ <span>Expires in (optional &mdash; e.g. "24h", "168h")</span>
+ <input type="text" name="ttl" placeholder="defaults to the shortest selected repo's ceiling">
+ </label>
+
+ <button class="chip admin-save" type="submit">Create link</button>
+ </form>
+ {{ end }}
+ {{ end }}
+</main>
+</body>
+</html>
diff --git a/internal/share/share.go b/internal/share/share.go
new file mode 100644
index 0000000..b4c1aeb
--- /dev/null
+++ b/internal/share/share.go
@@ -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()
+ }
+}
diff --git a/internal/share/view.html b/internal/share/view.html
new file mode 100644
index 0000000..6bb909f
--- /dev/null
+++ b/internal/share/view.html
@@ -0,0 +1,30 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Share link &middot; {{ .Site }}</title>
+<link rel="stylesheet" href="/static/css/base.css">
+<link rel="stylesheet" href="/static/css/share.css">
+</head>
+<body>
+<main class="wrap">
+ <header class="hero">
+ <h1 class="page-title">Password required</h1>
+ <p class="page-subtitle">This share link is password-protected.</p>
+ </header>
+
+ {{ if .Error }}
+ <div class="notice notice-critical"><p>{{ .Error }}</p></div>
+ {{ end }}
+
+ <form method="post" action="{{ .Action }}" class="share-form">
+ <label class="share-field">
+ <span>Password</span>
+ <input type="password" name="password" autofocus>
+ </label>
+ <button class="chip admin-save" type="submit">View</button>
+ </form>
+</main>
+</body>
+</html>
diff --git a/internal/site/request.go b/internal/site/request.go
new file mode 100644
index 0000000..cec7a5c
--- /dev/null
+++ b/internal/site/request.go
@@ -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")
+}
diff --git a/internal/site/request_test.go b/internal/site/request_test.go
new file mode 100644
index 0000000..8ef4afe
--- /dev/null
+++ b/internal/site/request_test.go
@@ -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")
+ }
+}
diff --git a/internal/site/routes.go b/internal/site/routes.go
index 6d31993..7bfc97c 100644
--- a/internal/site/routes.go
+++ b/internal/site/routes.go
@@ -10,7 +10,7 @@ import (
func Routes() []core.Route {
return []core.Route{
// ---- site-scoped -------------------------------------------------
- {Name: "index", Scope: core.ScopeSite,
+ {Name: "index", Scope: core.ScopeSite, CacheableAnon: true,
Pattern: "GET /{$}",
Render: core.View{Template: "index.html"}},
@@ -21,7 +21,7 @@ func Routes() []core.Route {
// ---- per repository ----------------------------------------------
// NavLabel order below is the order tabs appear in the repo-scoped
// nav (top bar and page-nav chips) -- see core.BuildNav.
- {Name: "repo",
+ {Name: "repo", CacheableAnon: true,
Pattern: "GET /{repo}/{$}",
Render: core.View{Template: "tree.html"}},
@@ -31,13 +31,14 @@ func Routes() []core.Route {
// {rest...} fuses ref and path; SplitRefPath separates them by
// longest-matching ref, so feature/x/src/main.go works.
- {Name: "tree", NavLabel: "files",
+ {Name: "tree", NavLabel: "files", CacheableAnon: true,
Pattern: "GET /{repo}/tree/{rest...}",
Render: core.View{Template: "tree.html"}},
- {Name: "log", NavLabel: "log",
- Pattern: "GET /{repo}/log/{rest...}",
- Render: core.View{Template: "log.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...}",
@@ -48,19 +49,29 @@ func Routes() []core.Route {
Pattern: "GET /{repo}/raw/{rest...}",
Render: core.Raw{Module: "blobraw"}},
- // The DAG across every ref.
- {Name: "graph", NavLabel: "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",
- Pattern: "GET /{repo}/refs/{$}",
+ {Name: "refs", NavLabel: "refs", CacheableAnon: true,
+ Pattern: "GET /{repo}/refs/{rest...}",
Render: core.View{Template: "refs.html"}},
- // Arbitrary two-commit diff: /demo/compare/main...feature
+ // Arbitrary two-commit diff: /demo/compare/main...feature. Not
+ // CacheableAnon: a diff body is bounded only by MaxGit (16 MiB,
+ // larger than a blob preview), and every valid ref pair is a
+ // distinct key -- a repo with N refs has ~N^2 reachable ones. The
+ // response cache has no size-aware eviction, so this route would
+ // risk filling it with a few huge entries for no real hit-rate
+ // benefit (see internal/core/route.go's CacheableAnon doc).
{Name: "compare",
Pattern: "GET /{repo}/compare/{range}",
- Render: core.View{Template: "compare.html"}},
+ Render: core.View{Template: "compare.html"},
+ Request: CompareRequest},
// SHA-addressed: content can never change, so cache forever.
{Name: "commit", Immutable: true,
diff --git a/llm_readme.md b/llm_readme.md
new file mode 100644
index 0000000..7758ed9
--- /dev/null
+++ b/llm_readme.md
@@ -0,0 +1,503 @@
+# gsg — a modular git browser
+
+cgit's weight, Hugo's composability, served dynamically. A **module**
+computes one piece of a view; a **route** binds a renderer to a URL pattern.
+Adding a feature means adding a module and calling it from a template —
+`internal/core` never changes.
+
+One dependency (`goldmark`, which itself has none) plus the `git` binary. Go 1.22+ (uses `net/http`
+pattern wildcards, so there is no router dependency either).
+
+```
+go build -o gsg .
+./gsg -root /srv/git -addr :8080 -name "rschanz.org"
+./gsg -root /srv/git -templates ./templates # re-parse the theme per request
+./gsg -root /srv/git -notices /etc/gsg/notices.yaml -admin-addr 127.0.0.1:8081
+```
+
+## The idea
+
+```go
+type Module func(c *Ctx, args ...string) (any, error)
+```
+
+Templates reach modules through one funcmap entry:
+
+```gotmpl
+{{ with mod "meta" }}<h1>{{ .Title }}</h1>{{ end }}
+{{ $d := mod "diff" }}
+{{ with modOr "readme" }}{{ .HTML }}{{ end }}
+```
+
+- **Lazy.** A template that calls `mod "authors"` is what *causes* authors to
+ be computed. Views that don't ask don't pay.
+- **Memoized twice.** Once per request (`Ctx.cache`), and once process-wide
+ against the resolved commit SHA (`core.LRU`).
+- **No manifest.** Dependencies are declared by use. There is no module list
+ to keep in sync with the templates.
+
+`internal/modules/example_authors.go` is a live demonstration: 30 lines, an
+`init()`, and one `{{ with mod "authors" }}` block. Nothing else moved.
+
+## Pages and endpoints are the same mechanism
+
+A route is `{pattern, renderer}`. `core.View` renders a template,
+`core.JSON` serializes an endpoint. Both take a `*Ctx`, both call modules.
+
+```go
+{Name: "commit", Immutable: true,
+ Pattern: "GET /{repo}/commit/{rev}",
+ Render: core.View{Template: "commit.html"}},
+
+{Name: "api-commit", Immutable: true,
+ Pattern: "GET /{repo}/api/commit/{rev}",
+ Render: core.JSON{Build: api.CommitEndpoint}},
+```
+
+### Endpoints use DTOs, not module values
+
+`internal/api` defines explicit structs and never marshals a module's return
+value. Go marshals every exported field by default, so serializing a module
+type means the day someone adds `Body` to `gitx.Commit`, commit messages are
+public. The allowlist is one readable file.
+
+```
+GET /api/repos.json site index
+GET /{repo}/api/repo.json slug, title, description, owner, section,
+ clone_url, default_branch, last_commit
+GET /{repo}/api/commit/{sha} sha, short, authored, parents, and
+ file/line counts — no subject, no body
+```
+
+## Routes
+
+| Route | URL |
+|---|---|
+| index | `/` |
+| repo | `/{repo}/` — default branch, last update, latest commit, README |
+| files | `/{repo}/tree/{ref}/{path}` |
+| file | `/{repo}/blob/{ref}/{path}` |
+| **raw bytes** | `/{repo}/raw/{ref}/{path}` |
+| log | `/{repo}/log/{ref}` |
+| refs | `/{repo}/refs/` |
+| commit | `/{repo}/commit/{sha}` |
+| **compare** | `/{repo}/compare/{base}..{head}` or `{base}...{head}` |
+| **branch graph** | `/{repo}/graph/` |
+
+### Compare
+
+`a..b` is the literal endpoint difference. `a...b` diffs from the merge
+base, which is what you want when reviewing a branch — it excludes changes
+that landed on the base since the branch forked. Verified against
+`git merge-base` on a diverged fixture: `topic..main` lists `topic.txt` as
+removed, `topic...main` does not.
+
+### Raw files
+
+A repository can contain an `.html` file with a `<script>` tag. Serving that
+as `text/html` on your own origin turns any push into stored XSS against the
+whole site — which is why GitHub puts raw content on
+`raw.githubusercontent.com`, a separate origin. Same-host serving is only
+safe if the response cannot be interpreted as a document, so `/raw/` sends
+`text/plain` (or `application/octet-stream` for binaries, plus
+`Content-Disposition: attachment`), with `X-Content-Type-Options: nosniff`
+and `Content-Security-Policy: default-src \'none\'; sandbox` as a backstop.
+
+The exception is a validated raster image (PNG, JPEG, GIF, WebP, BMP, ICO,
+AVIF): when the extension and the magic bytes agree, `/raw/` declares the
+real `image/*` type inline so the blob page can preview it. An extension by
+itself is not enough — an `.png` whose contents are HTML still downloads as
+`application/octet-stream`. SVG is deliberately excluded: it is an XML
+document that can execute script, so it stays `text/plain`.
+
+If you would rather have real isolation, serve `/raw/` from a second
+hostname and the CSP becomes belt-and-braces rather than the only line.
+
+### Images and structured config files
+
+An image blob shows an inline preview with a `download` link to the same
+`/raw/` URL; images over 10 MiB skip the preview and offer the download only.
+
+JSON and YAML blobs get per-level indent guides, fold controls, key/value
+search, and YAML anchor/alias links. The search box also accepts a value
+path: typing `obj1.property3.items[4].val` resolves it against the parsed
+document and highlights that line and, for a container, its subtree. A
+query containing `.` or `[` is tried as a path first and falls back to
+plain substring search when it does not resolve, so dotted text stays
+searchable. Paths quote keys that need it (`obj1["a.b"]`) and attach indices
+directly; SVG is never previewed, and files past 512 KiB skip the parsing
+tooling entirely.
+
+### Branch graph
+
+`modules/graph.go` lays out the DAG in lanes in one pass over
+`git log --all --topo-order`, which guarantees a child is emitted before its
+parents. `active` holds, per lane, the SHA that lane is waiting to draw; a
+commit claims the lane expecting it (or a fresh one), hands that lane to its
+first parent, and opens a new lane per additional parent — which is what
+makes a merge fork visually.
+
+The module emits the per-row SVG because the geometry *is* the data, but
+every stroke refers to a CSS custom property (`--lane-0` … `--lane-5`), so
+the theme still owns the palette. Row height in the SVG must stay in sync
+with `table.graph td { height }` in the stylesheet.
+
+## Caching, which is the whole reason this works dynamically
+
+Every module result is a pure function of `(repo, commit SHA, path, args)`.
+Refs move; SHAs do not. So the process-wide cache is keyed on the resolved
+SHA and needs **no invalidation logic at all** — a stale entry is impossible
+by construction.
+
+`Ctx.Vars` are part of the cache key, not just the commit. They were not
+originally, and the bug was exactly the kind this cache should be immune to:
+`/compare/a..b` and `/compare/a...b` resolve to the same head commit, so the
+second request was served the first one\'s diff. Anything that changes what a
+module computes has to be in the key.
+
+ETags are derived from the resolved SHA plus a digest of every template
+file, *not* from the response body. A conditional request costs one ref
+lookup instead of a render, and restyling the theme invalidates every ETag
+at once (without the template digest, a restyled page keeps serving 304s).
+
+Two URL classes, two policies:
+
+| URL shape | Example | Cache-Control |
+|---|---|---|
+| SHA-addressed | `/demo/commit/64b90fff…` | `max-age=31536000, immutable` |
+| ref-addressed | `/demo/tree/main` | `no-cache` (revalidate via ETag) |
+
+## Ref-vs-path ambiguity
+
+`/{repo}/tree/{rest...}` fuses them, and `gitx.SplitRefPath` separates them
+by **longest matching prefix against the repo's actual ref list**, so
+`feature/x/src/main.go` resolves correctly. Splitting at the first `/` is
+wrong because branch names contain slashes; cgit dodges this with `?h=`
+query params, GitHub does what this does.
+
+## Serving is an attack surface; static generation was not
+
+- **Ref input never reaches `git rev-parse`.** rev-parse accepts an
+ expression language — `:/regex` searches history, `@{...}` reads the
+ reflog — and git subcommands treat a leading `-` as an option.
+ `gitx.Resolve` accepts either a hex SHA that already exists or an exact
+ match against the repo's own ref list. Verified: `--upload-pack=…`,
+ `:/secret`, `HEAD@{0}` and `-n` all return 404 with no side effects.
+- **Every git call is `exec.CommandContext`** with a per-request deadline
+ (`-timeout`, default 10s) and a scrubbed environment
+ (`GIT_CONFIG_NOSYSTEM=1`, no `HOME`, no terminal prompt).
+- **Output is capped** at `MaxGit` (8 MiB) per command; a diff that exceeds
+ it renders truncated rather than eating the heap.
+
+## Layout
+
+```
+main.go flags, repo discovery, embedded assets
+internal/gitx/ the ONLY package that knows git exists
+internal/core/core.go Ctx, RepoInfo, module registry, Store
+internal/core/route.go Route, View, JSON, route request parsing
+internal/core/server.go request -> Ctx, ETags, cache policy
+internal/core/cache.go LRU keyed on commit SHAs
+internal/core/funcs.go template funcmap (mod, modOr, dict, date, bytes)
+internal/access/ visibility and share-grant policy (imports no core)
+internal/authn/ login/logout endpoints
+internal/httpx/ shared admin/share HTTP security helpers
+internal/modules/ meta refs log tree readme repos commit diff blob notices
+internal/notices/ notices YAML store + admin UI (imports no core)
+internal/api/ JSON DTOs — the serialization allowlist
+internal/site/routes.go the route table (the file you edit most)
+internal/site/request.go route-specific path parsers (compare ranges)
+templates/ layouts/ + views/
+```
+
+`gitx` shells out to plumbing, one process per query, `git log` in a single
+pass. Swapping in go-git means rewriting that one package.
+
+## Sections
+
+`gsg.section` groups repositories on the index. The `sections` module builds
+on `repos` rather than re-walking the repo set, so the index pays for its log
+lookups once. When no repo sets a section, it returns a single unnamed group
+and the template renders a flat list — no config, no headings.
+
+```sh
+git --git-dir=/srv/git/demo.git config gsg.section "Infrastructure"
+```
+
+## Notices
+
+Operator-authored banners at the top of every page, site-wide and per
+repository. They live in one YAML file that is watched for changes, so
+editing it never needs a restart or a rebuild:
+
+```yaml
+global:
+ - type: critical # critical | warning | info | fix
+ title: Maintenance window
+ body: |
+ Saturday 02:00-04:00 UTC.
+ The site is read-only during the window.
+ link: https://status.example.org
+ link_text: status page
+repos:
+ demo:
+ - type: warning
+ title: Migrating to a new remote
+```
+
+```sh
+./gsg -root /srv/git -notices /etc/gsg/notices.yaml
+```
+
+Global notices render above every repository's; on the site index only the
+global list appears. The file is re-read on write (atomic-rename saves from
+an editor included); a parse error keeps the last good content rather than
+blanking the banners.
+
+### Editing from the web
+
+`-admin-addr` starts a second listener with a small HTML form to add, edit,
+and delete notices. It is deliberately separate from the read-only server:
+the admin routes are not reachable on the public port even if the reverse
+proxy is misconfigured.
+
+```sh
+./gsg -root /srv/git -notices /etc/gsg/notices.yaml -admin-addr 127.0.0.1:8081
+```
+
+Protect it with Caddy `forward_auth` and proxy it to the admin listener. gsg
+trusts the network in front of it, so the admin port must not be published
+directly. In Compose, put 8081 on the internal network only and pass
+`-admin-allow-remote`, because a container address is not loopback. Note
+that gsg performs no authentication itself -- the `forward_auth` block below
+is the only thing between the internet and the admin form.
+
+```caddyfile
+example.com {
+ # Admin: authenticate first, then proxy to the separate admin listener.
+ # handle, not handle_path: the admin mux matches the full /admin/...
+ # path, so stripping the prefix would 404 every request.
+ handle /admin/* {
+ forward_auth authentik:9000 {
+ uri /outpost.goauthentik.io/auth/caddy
+ copy_headers X-Authentik-Username
+ }
+ reverse_proxy gsg:8081
+ }
+
+ # Public, read-only.
+ handle {
+ reverse_proxy gsg:8080
+ }
+}
+```
+
+The `X-Authentik-Username` header is used only to attribute the mutation in
+the log; it is never consulted for authorization. By default Authentik
+decides who may reach the handler at all -- anyone forward_auth lets through
+`/admin/*` can edit notices.
+
+Mutations are POST-only, CSRF-protected (double-submit cookie plus
+`Origin`/`Sec-Fetch-Site`), and logged with the `X-Authentik-Username`
+header, which is used for audit only, never for authorization.
+
+### Gating notices editing to a group
+
+`/admin/*` is also where [Login](#login) mints the `gsg_user` cookie, so
+forward_auth for the whole prefix is usually left open to everyone you want
+able to log in (for share links, hidden-repo visibility, etc). To let more
+people log in without also handing them notices editing, add `-admin-group`:
+
+```sh
+./gsg -root /srv/git -notices /etc/gsg/notices.yaml \
+ -admin-addr 127.0.0.1:8081 -admin-group gsg-admins
+```
+
+This checks the `X-Authentik-Groups` header (override with
+`-admin-group-header`) for `gsg-admins` among its `|`-delimited entries,
+Authentik's own format for that header, and answers `403` to any request
+under `/admin/notices` from a caller that isn't a member -- before the
+handler does anything else, including setting the CSRF cookie. Add the group
+header to the same `forward_auth` block used above:
+
+```caddyfile
+ forward_auth authentik:9000 {
+ uri /outpost.goauthentik.io/auth/caddy
+ copy_headers X-Authentik-Username X-Authentik-Groups
+ }
+```
+
+Leaving `-admin-group` unset keeps today's behavior: anyone forward_auth
+admits to `/admin/*` can edit notices.
+
+**Immutable commit pages carry no notices.** `/{repo}/commit/{rev}` is
+served `Cache-Control: public, max-age=31536000, immutable`, so a banner
+rendered into it would stay frozen in the reader's browser. Notices are
+suppressed there by design; every other page picks them up on the next
+request.
+
+## Login
+
+Logging in reveals hidden repos whose `gsg.visible-to` list includes your
+username; everyone else, logged in or not, sees exactly what an anonymous
+visitor sees. It reuses the same Authentik `forward_auth` setup as the
+notices admin UI above, and needs nothing extra beyond `-admin-addr`
+(notices editing is not required — `-admin-addr` alone is enough to enable
+login).
+
+```sh
+./gsg -root /srv/git -admin-addr 127.0.0.1:8081
+```
+
+```caddyfile
+example.com {
+ # Login: same trust boundary as /admin/*, one hop mints the cookie
+ # and redirects back. This must be the exact path, not /auth/*: gsg
+ # also serves /auth/logout, but on the *public* listener, since
+ # logging out only clears gsg's own cookie and needs no Authentik
+ # round trip at all. Routing /auth/* here would send logout to the
+ # admin listener too, where no such handler exists, and gate it
+ # behind an Authentik check it doesn't need.
+ handle /auth/login {
+ forward_auth authentik:9000 {
+ uri /outpost.goauthentik.io/auth/caddy
+ copy_headers X-Authentik-Username
+ }
+ reverse_proxy gsg:8081
+ }
+
+ # Public, read-only (also handles /auth/logout, /share/new, /share/view/*).
+ handle {
+ reverse_proxy gsg:8080
+ }
+}
+```
+
+Visiting `/auth/login` (through Caddy, so `X-Authentik-Username` is set and
+trustworthy by the time gsg sees the request) mints a signed, stateless
+`gsg_user` cookie and redirects back to `/`. The cookie is verified on the
+public listener with an in-memory key — nothing is written to disk, and a
+process restart signs everyone out. `/auth/logout` clears it and needs no
+round trip through Authentik, since it only has to forget gsg's own cookie.
+
+**There is no "log in" link anywhere in the UI.** `/auth/login` is not
+discoverable from the site; people who need it are told the URL out of
+band. Once logged in, the nav bar shows who you are and a log-out link.
+
+## Share links
+
+Once logged in, `/share/new` lets you pick from your own hidden repos and
+mint a link that grants view access to exactly those repos to anyone who
+has it — no login, no Authentik involvement. It's entirely stateless: the
+link is a signed, self-contained token, and gsg keeps no record of what's
+been shared. An optional password can gate it.
+
+```
+git --git-dir=/srv/git/demo.git config gsg.share-max-age "720h"
+```
+
+`gsg.share-max-age` (Go duration syntax, default 30 days if unset) is both
+the ceiling no link's expiry may exceed and the default when the minting
+form doesn't specify one — you can always ask for less, never more. Sharing
+several repos at once uses the smallest ceiling among them.
+
+**Passwords are never stored, not even hashed.** The password is folded
+directly into the link's signature instead of being embedded in it: nobody
+holding just the URL has anything to brute-force offline, and checking a
+guess requires asking the server, which rate-limits attempts. This is a
+soft gate appropriate for a small self-hosted tool, not a cryptographic
+secret protecting high-value data.
+
+**Revocation.** A stateless token can't be individually revoked — there's
+no list of what's been issued. The one lever:
+
+```
+git --git-dir=/srv/git/demo.git config gsg.share-revoked-after "$(date +%s)"
+```
+
+Any share grant for that repo issued at or before this timestamp stops
+working immediately, including in a browser that already redeemed the
+link — not just future redemptions. Two things worth knowing before you
+rely on this:
+
+- There's no per-recipient state, so revoking means invalidating *every*
+ outstanding share of that repo, then re-sharing with whoever should still
+ have it.
+- Removing a username from `gsg.visible-to` does **not** revoke a share
+ link that user already minted for that repo. The two mechanisms are
+ independent; only `gsg.share-revoked-after` invalidates outstanding
+ shares.
+
+## README rendering
+
+`modules/markdown.go` uses goldmark with GFM, Typographer, and automatic
+heading IDs — Hugo\'s own goldmark defaults, so a README renders identically
+to the same file on your blog.
+
+`WithUnsafe` is deliberately not set, which is also Hugo\'s default. Anyone
+who can push can write a README, so raw HTML in markdown is untrusted input;
+goldmark drops it. A `<script>` committed to a README comes out as
+`<!-- raw HTML omitted -->`.
+
+Relative links are rewritten against the file\'s own directory: links become
+the blob view, images become `/raw/` (an `<img>` needs bytes, not an HTML
+page). Absolute URLs, site-absolute paths, fragments and `mailto:` are left
+alone, and anything resolving above the repo root is not rewritten.
+
+## Per-repository config
+
+Read from each repo's own git config, cgit-style, with `gitweb.*` and the
+legacy `description` file as fallbacks, so an existing cgit tree works as-is:
+
+```sh
+git --git-dir=/srv/git/demo.git config gsg.title "Demo Project"
+git --git-dir=/srv/git/demo.git config gsg.description "A demo repository"
+git --git-dir=/srv/git/demo.git config gsg.clone "git://rschanz.org/demo"
+git --git-dir=/srv/git/demo.git config gsg.hide true
+git --git-dir=/srv/git/demo.git config gsg.visible-to "alice,bob"
+git --git-dir=/srv/git/demo.git config gsg.share-max-age "720h"
+git --git-dir=/srv/git/demo.git config gsg.share-revoked-after "$(date +%s)"
+```
+
+Add a key by adding a field to `core.RepoInfo` and a line in `LoadRepoInfo`.
+
+A repo's slug is its directory name (minus `.git`), and slugs share the
+public listener's URL namespace with a few reserved top-level paths:
+`static`, `auth`, and `share`. A repo named e.g. `share.git` would collide
+with the share-link endpoints below and is not supported.
+
+`gsg.visible-to` is a comma-separated allowlist of usernames (matched
+case-insensitively against a logged-in session, see "Login" below) who may
+see this repo even though `gsg.hide` is set. It's a separate key from
+`gsg.owner` on purpose: `Owner` is a free-text display field that falls
+back to `gitweb.owner` for cgit-migrated repos, so treating it as an access
+list could silently grant visibility nobody intended.
+
+## Theme
+
+Monospace-first: every value on these pages is an identifier, hash, path or
+count, so the interface type is the same face as the code, and the sans face
+appears only inside rendered README prose. A cool slate ramp, one indigo
+accent, and a six-colour lane ramp shared by the commit graph and its branch
+chips. Light and dark both defined via `prefers-color-scheme`.
+
+Diffs mark the gutter rather than flooding whole rows with green and red —
+the signal stays readable when a hunk is mostly additions.
+
+No webfonts; system stacks only, so there is no third-party request. All
+colour lives in `:root` custom properties in `static/style.css`.
+
+## Known gaps
+
+- **No syntax highlighting.** `blob` returns numbered plain lines; Chroma
+ drops into the same module.
+- **No pagination on `log` or `graph`.** They cap at 200 and 150 commits.
+- **The graph loads all refs into memory** before laying out. Fine to a few
+ thousand commits; beyond that it needs windowing.
+- **The LRU is bounded by entry count, not bytes.** A giant diff and a ref
+ list cost the same slot. The `Store` interface is the only thing a
+ size-aware replacement has to satisfy.
+- **Repo discovery is one level deep** and happens once at startup; a repo
+ created later needs a restart.
+- **Module errors surface at render time**, not load time. Add a CI pass
+ that renders every view against a fixture repo.
diff --git a/main.go b/main.go
index 2faa1d3..f5818ee 100644
--- a/main.go
+++ b/main.go
@@ -2,27 +2,37 @@
//
// 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 (
"embed"
"flag"
+ "fmt"
"io/fs"
"log"
"log/slog"
+ "net"
"net/http"
"os"
"time"
+ "gsg/internal/authn"
"gsg/internal/core"
_ "gsg/internal/modules" // registers the built-in modules
+ "gsg/internal/notices"
+ "gsg/internal/session"
+ "gsg/internal/share"
"gsg/internal/site"
)
@@ -32,63 +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}
+ 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
}
+ // 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
+ }
+
var tfs fs.FS
- var err error
- 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(siteCfg.RepoList()),
+ "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 <-errc
+}
+
+// cachedStatic serves fsys with a Cache-Control that depends on whether the
+// request carries the `v` cache-busting query param the `static` template
+// func mints (see internal/core/funcs.go). Requests that carry it are
+// addressed by content hash, so they're frozen for a year; everything else
+// -- share/notices/error-page assets, which don't go through that funcmap
+// and so never get a `v` -- still gets a real improvement over no
+// Cache-Control at all, just a short one, in case a template edit changes
+// what a bare URL should serve.
+func cachedStatic(fsys fs.FS) http.Handler {
+ fileServer := http.FileServer(http.FS(fsys))
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Query().Get("v") != "" {
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+ } else {
+ w.Header().Set("Cache-Control", "public, max-age=600")
+ }
+ fileServer.ServeHTTP(w, r)
+ })
+ return http.StripPrefix("/static/", h)
+}
+
+// isLoopbackAddr is a blunt guard, not a security boundary: it stops the
+// admin listener from silently binding 0.0.0.0 outside a deliberate opt-in.
+func isLoopbackAddr(addr string) bool {
+ host, _, err := net.SplitHostPort(addr)
+ if err != nil {
+ host = addr
+ }
+ switch host {
+ case "127.0.0.1", "::1", "localhost":
+ return true
+ default:
+ return false
}
- return hs.ListenAndServe()
}
diff --git a/static/css/admin.css b/static/css/admin.css
new file mode 100644
index 0000000..2ed9feb
--- /dev/null
+++ b/static/css/admin.css
@@ -0,0 +1,60 @@
+/* Admin surface only. Reuses base.css tokens and components; nothing here
+ is loaded by the public pages. No literal colours -- everything comes
+ from the :root custom properties. */
+
+.admin-table td,
+.admin-table th { vertical-align: top; }
+
+.admin-table select,
+.admin-table input,
+.admin-table textarea {
+ font: inherit;
+ font-size: .8rem;
+ color: var(--fg);
+ background: var(--bg-elev);
+ border: 1px solid var(--line);
+ border-radius: var(--r-sm);
+ padding: .25rem .45rem;
+ width: 100%;
+ min-width: 6rem;
+}
+
+.admin-table textarea { resize: vertical; }
+
+.admin-table select:focus-visible,
+.admin-table input:focus-visible,
+.admin-table textarea:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: -2px;
+}
+
+.admin-table .admin-del,
+.admin-table .admin-del input {
+ width: auto;
+ min-width: 0;
+ text-align: center;
+}
+
+.admin-save { cursor: pointer; font-size: .8rem; padding: .4rem .9rem; }
+
+/* Progressive enhancement: the Add button is only useful with admin.js, so
+ it stays hidden until that script adds .has-js to <body>. The checkbox is
+ always visible without JS. */
+.js-only { display: none; }
+.has-js .js-only { display: inline-flex; }
+
+.admin-actions {
+ display: flex;
+ gap: .5rem;
+ align-items: center;
+ margin-top: .75rem;
+}
+
+.row-remove { cursor: pointer; }
+
+.notice-error,
+.notice pre {
+ margin: .35rem 0 0;
+ white-space: pre-wrap;
+ font: inherit;
+}
diff --git a/static/css/base.css b/static/css/base.css
index c52f976..2d3aff4 100644
--- a/static/css/base.css
+++ b/static/css/base.css
@@ -35,6 +35,17 @@
--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;
@@ -67,7 +78,7 @@
--pad: clamp(.85rem, 2.5vw, 1.5rem);
}
-@media (prefers-color-scheme: light) {
+/**@media (prefers-color-scheme: light) {
:root {
--bg: #F3F4F6;
--bg-elev: #FFFFFF;
@@ -83,12 +94,16 @@
--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 */
@@ -151,7 +166,16 @@ a:hover { text-decoration: underline; }
.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;
@@ -237,6 +261,11 @@ main.wrap { padding-top: 1.25rem; padding-bottom: 2rem; }
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;
@@ -350,6 +379,32 @@ main.wrap { padding-top: 1.25rem; padding-bottom: 2rem; }
.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 {
@@ -384,6 +439,17 @@ footer a { color: var(--accent); }
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; }
@@ -405,7 +471,17 @@ footer a { color: var(--accent); }
flex-wrap: wrap;
height: auto; padding-block: .5rem;
}
- .nav-start { flex: 1 1 auto; }
+ .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;
diff --git a/static/css/blob.css b/static/css/blob.css
index 8d15f4d..f367717 100644
--- a/static/css/blob.css
+++ b/static/css/blob.css
@@ -142,3 +142,27 @@
border-color: var(--accent);
}
.sbar-btn:disabled { opacity: .45; cursor: default; }
+
+/* Image preview. The bytes come from /raw/, which serves validated raster
+ formats inline; the frame keeps a large image from widening the page and
+ the checkerboard keeps a transparent PNG legible in both themes. */
+.blob-image {
+ margin: .75rem 0;
+ padding: .75rem;
+ background: var(--surface-2);
+ border: 1px solid var(--line);
+ border-radius: var(--r-sm);
+ text-align: center;
+ overflow: auto;
+}
+.blob-image img {
+ max-width: 100%;
+ height: auto;
+ background-image:
+ linear-gradient(45deg, var(--line-soft) 25%, transparent 25%),
+ linear-gradient(-45deg, var(--line-soft) 25%, transparent 25%),
+ linear-gradient(45deg, transparent 75%, var(--line-soft) 75%),
+ linear-gradient(-45deg, transparent 75%, var(--line-soft) 75%);
+ background-size: 16px 16px;
+ background-position: 0 0, 0 8px, 8px -8px, -8px 0;
+}
diff --git a/static/css/repo-list.css b/static/css/repo-list.css
index 9b0858f..c8b4395 100644
--- a/static/css/repo-list.css
+++ b/static/css/repo-list.css
@@ -1,14 +1,21 @@
/* 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: 2fr 3fr auto;
+ 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 {
@@ -25,17 +32,90 @@
.repo-desc {
color: var(--fg-muted);
font-size: .85rem;
+ overflow-wrap: anywhere;
}
.repo-meta {
color: var(--fg-subtle);
font-size: .8rem;
text-align: right;
- white-space: nowrap;
+ overflow-wrap: break-word;
}
@media (max-width: 640px) {
.repo-row {
- grid-template-columns: 1fr;
- gap: 0.5rem;
+ grid-template-columns: minmax(0, 1fr);
+ gap: .5rem;
+ }
+ .repo-meta { text-align: left; }
+}
+
+/* ---- Table variant (.table-repos) --------------------------------------
+ Each section is its own <table>. Fixed layout with shared percentages
+ keeps the columns aligned across sections. Two requirements:
+
+ - The table needs a non-auto width. Without it, browsers fall back to
+ auto layout (CSS 2.1 §17.5.2.1), which sizes columns from content
+ per table and produces the cross-section drift.
+ - The percentages must sum to 100%.
+
+ col-meta at 17% fits "2026-09-03 15:04" on one line down to roughly a
+ 900px viewport. Narrower than that, the cell wraps at the space between
+ date and time. This only works because the cell isn't `nowrap`:
+ overflow-wrap never applies where white-space forbids wrapping. */
+.table-repos {
+ table-layout: fixed;
+ width: 100%;
+}
+.table-repos .col-repo { width: 25%; }
+.table-repos .col-desc { width: 58%; }
+.table-repos .col-meta { width: 17%; }
+.table-repos td:last-child { white-space: normal; }
+
+/* Only the full header labels are shown. The narrow layout below hides the
+ header row visually, so the short labels are no longer used; delete
+ .label-short from the markup and this rule can go. */
+.table-repos .label-short { display: none; }
+
+/* Below 640px, columns are dropped and each row is stacked:
+
+ name ............ date
+ description
+
+ Once nothing sits side by side, cross-section alignment no longer
+ matters. The header is hidden visually but stays in the accessibility
+ tree. Changing `display` on table elements can remove their implicit
+ table roles in some engines, so add role="table" / "row" / "cell" in
+ the markup if screen-reader semantics matter. */
+@media (max-width: 640px) {
+ .table-repos,
+ .table-repos tbody,
+ .table-repos td {
+ display: block;
+ width: auto;
+ }
+ .table-repos thead {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ }
+ .table-repos tr {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ grid-template-areas:
+ "repo meta"
+ "desc desc";
+ gap: .25rem .75rem;
+ padding: .6rem 0;
+ }
+ .table-repos td:nth-child(1) { grid-area: repo; }
+ .table-repos td:nth-child(2) { grid-area: desc; }
+ .table-repos td:nth-child(3) {
+ grid-area: meta;
+ text-align: right;
+ /* Safe to keep on one line here: the `auto` track sizes to fit it. */
+ white-space: nowrap;
}
}
diff --git a/static/css/share.css b/static/css/share.css
new file mode 100644
index 0000000..82bb555
--- /dev/null
+++ b/static/css/share.css
@@ -0,0 +1,63 @@
+/* Share link pages: mint form, password prompt. Small and self-contained,
+ built on base.css's tokens rather than introducing new ones. */
+
+.share-form {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ max-width: 32rem;
+ margin-top: 1rem;
+}
+
+.share-repos {
+ border: 1px solid var(--line);
+ border-radius: var(--r-sm);
+ padding: .75rem 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: .5rem;
+}
+
+.share-repos legend {
+ padding: 0 .4rem;
+ color: var(--fg-muted);
+ font-size: .8rem;
+}
+
+.share-repo {
+ display: flex;
+ align-items: baseline;
+ gap: .5rem;
+}
+
+.share-repo-title { color: var(--fg); }
+
+.share-maxage {
+ color: var(--fg-muted);
+ font-size: .78rem;
+ margin-left: auto;
+}
+
+.share-field {
+ display: flex;
+ flex-direction: column;
+ gap: .3rem;
+ font-size: .85rem;
+ color: var(--fg-muted);
+}
+
+.share-field input {
+ font: inherit;
+ color: var(--fg);
+ background: var(--bg);
+ border: 1px solid var(--line);
+ border-radius: var(--r-sm);
+ padding: .4rem .6rem;
+}
+
+.share-link { word-break: break-all; }
+
+.share-hint {
+ color: var(--fg-muted);
+ font-size: .82rem;
+}
diff --git a/static/js/admin.js b/static/js/admin.js
new file mode 100644
index 0000000..41bf282
--- /dev/null
+++ b/static/js/admin.js
@@ -0,0 +1,60 @@
+// Progressive enhancement for the notices admin form.
+//
+// Without JavaScript the server-rendered checkbox removes a row and the
+// single blank row is how you add one. This script only makes those two
+// actions interactive: it turns the checkbox into a Remove button and
+// clones the blank row for an Add button. It never changes what the form
+// submits, so a JS-on submit and a JS-off submit build the same document.
+(function () {
+ 'use strict';
+
+ var form = document.querySelector('form.admin-form');
+ if (!form) return;
+ document.body.classList.add('has-js');
+
+ var tbody = form.querySelector('tbody');
+ if (!tbody) return;
+
+ // Capture the server-rendered blank "add" row before anyone types in it,
+ // and use its markup as the template for rows the Add button creates.
+ var seed = tbody.lastElementChild;
+ if (!seed) return;
+ var seedHTML = seed.outerHTML;
+
+ function enhance(row) {
+ var cell = row.querySelector('.admin-del');
+ if (!cell || cell.dataset.enhanced) return;
+ cell.dataset.enhanced = '1';
+
+ // The checkbox stays in the DOM (hidden) so a form that is somehow
+ // submitted without JS still means what it did before.
+ var box = cell.querySelector('input[type=checkbox]');
+ if (box) box.hidden = true;
+
+ var btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'chip row-remove';
+ btn.textContent = 'Remove';
+ btn.addEventListener('click', function () {
+ row.parentNode.removeChild(row);
+ });
+ cell.appendChild(btn);
+ }
+
+ function addRow() {
+ var wrap = document.createElement('tbody');
+ wrap.innerHTML = seedHTML;
+ var row = wrap.firstElementChild;
+ row.querySelectorAll('input, textarea').forEach(function (el) {
+ if (el.type === 'checkbox') el.checked = false;
+ else el.value = '';
+ });
+ tbody.appendChild(row);
+ enhance(row);
+ }
+
+ Array.prototype.forEach.call(tbody.querySelectorAll('tr'), enhance);
+
+ var add = document.getElementById('add-notice');
+ if (add) add.addEventListener('click', addRow);
+})();
diff --git a/static/js/structured.js b/static/js/structured.js
index 6a28d5c..22ce34b 100644
--- a/static/js/structured.js
+++ b/static/js/structured.js
@@ -43,6 +43,7 @@
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(),
@@ -105,11 +106,82 @@
}
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 = q !== '' && hay(r).indexOf(q) >= 0;
+ 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); });
@@ -177,7 +249,8 @@
var input = document.createElement('input');
input.type = 'search';
input.className = 'sbar-q';
- input.placeholder = 'Search keys and values… (/)';
+ 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');
diff --git a/templates/layouts/base.html b/templates/layouts/base.html
index 6f5c0f2..36a7382 100644
--- a/templates/layouts/base.html
+++ b/templates/layouts/base.html
@@ -4,21 +4,21 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ block "title" . }}{{ with .Info.Title }}{{ . }} &middot; {{ end }}{{ .Site.Name }}{{ end }}</title>
-<link rel="stylesheet" href="/static/css/base.css">
+<link rel="stylesheet" href="{{ static "css/base.css" }}">
{{ block "styles" . }}{{ end }}
</head>
<body>
{{ template "nav" . }}
-<main class="wrap">{{ block "main" . }}{{ end }}</main>
+<main class="wrap">{{ template "notices" . }}{{ block "main" . }}{{ end }}</main>
<footer class="wrap">
<div class="footer-left">
<span class="logo" style="width:24px;height:24px;font-size:0.9rem;">G</span>
<span>{{ .Site.Name }}</span>
</div>
<div class="footer-right">
- <!--<span>served from bare repositories</span>-->
- <span style="color:var(--line);margin:0 .5rem;">&bull;</span>
- <span>{{ .Site.Name }} v1.0</span>
+ <!--<span>served from bare repositories</span>
+ <span style="color:var(--line);margin:0 .5rem;">&bull;</span>-->
+ <span>{{ .Site.Name }} v1.4</span>
</div>
</footer>
{{ block "scripts" . }}{{ end }}
@@ -41,18 +41,35 @@
<nav class="tabs">
{{ range $.Nav }}<a class="tab{{ if .Active }} active{{ end }}" href="{{ .Href }}">{{ .Label }}</a>{{ end }}
</nav>
- <div class="nav-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="location.href=this.value">
+ <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 }}
- </div>
- {{ end }}
+ {{ end }}
+ {{/* One-sided on purpose: no "log in" link when anonymous. There is
+ no discoverable entry point into /auth/login anywhere in the UI;
+ people who need it are told the URL out of band. */}}
+ {{ with $.User }}
+ <span class="nav-user">Logged in as {{ . }} &middot; <a href="/auth/logout">log out</a></span>
+ {{ end }}
+ </div>
</div>
</nav>
{{ end }}
diff --git a/templates/layouts/notices.html b/templates/layouts/notices.html
new file mode 100644
index 0000000..7209fec
--- /dev/null
+++ b/templates/layouts/notices.html
@@ -0,0 +1,24 @@
+{{/* Operator notices. Global ones are always followed by the current
+ repository's, which is why the module merges them into one slice.
+
+ The .Immutable guard is not cosmetic: commit pages are served with a
+ year-long immutable cache, so a notice rendered into one would be
+ frozen in the reader's browser with no way to revalidate. Those pages
+ simply carry no banners. Guarding here rather than in the module means
+ an immutable page never even computes them. */}}
+{{ define "notices" }}
+{{ if not .Immutable }}
+{{ with modOr "notices" }}
+<div class="notices">
+ {{ range . }}
+ {{ $n := . }}
+ <div class="notice notice-{{ .Type }}">
+ {{ with .Title }}<strong class="notice-title">{{ . }}</strong>{{ end }}
+ {{ with .Body }}<span class="notice-body">{{ . }}</span>{{ end }}
+ {{ with .Link }}<a class="notice-link" href="{{ . }}">{{ with $n.LinkText }}{{ . }}{{ else }}More{{ end }}</a>{{ end }}
+ </div>
+ {{ end }}
+</div>
+{{ end }}
+{{ end }}
+{{ end }}
diff --git a/templates/views/blob.html b/templates/views/blob.html
index f1e82e9..22b89f9 100644
--- a/templates/views/blob.html
+++ b/templates/views/blob.html
@@ -1,5 +1,5 @@
{{ define "blob.html" }}{{ template "base" . }}{{ end }}
-{{ define "styles" }}<link rel="stylesheet" href="/static/css/blob.css">{{ end }}
+{{ define "styles" }}<link rel="stylesheet" href="{{ static "css/blob.css" }}">{{ end }}
{{ define "main" }}
{{ $slug := print "/" .Info.Slug }}
{{ $b := mod "blob" }}
@@ -12,6 +12,7 @@
{{ if not $b.Submodule }}
<span class="text-muted">&middot; {{ bytes64 $b.Size }}{{ if not $b.Binary }} &middot; {{ len $b.Lines }} lines{{ end }}</span>
<a class="chip" style="margin-left:.5rem;" href="{{ $slug }}/raw/{{ .Ref }}/{{ $b.Path }}">raw</a>
+ {{ if $b.Image }}<a class="chip" style="margin-left:.5rem;" href="{{ $slug }}/raw/{{ .Ref }}/{{ $b.Path }}" download>download</a>{{ end }}
{{ end }}
</p>
</div>
@@ -22,6 +23,15 @@
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 }}
@@ -45,7 +55,8 @@
<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 .Key }} data-key="{{ . }}"{{ end }}
+ {{- with .Path }} data-path="{{ . }}"{{ end }}>
<td class="ln"><a href="#L{{ .N }}">{{ .N }}</a></td>
<td class="lc">{{ .Text }}{{ with .Anchor }}{{ if .Uses }}<span class="anchor-uses">{{ .Uses }} use{{ if ne .Uses 1 }}s{{ end }}</span>{{ end }}{{ end }}{{ with .Ref }}<a class="aref" href="#L{{ .Line }}" title="&amp;{{ .Name }} &mdash; line {{ .Line }}{{ with .Preview }}&#10;&#10;{{ . }}{{ end }}">&rarr;&nbsp;{{ .Line }}</a>{{ end }}</td>
</tr>
@@ -55,4 +66,4 @@
{{ end }}
{{ end }}
-{{ define "scripts" }}{{ if (mod "blob").Structured }}<script src="/static/js/structured.js" defer></script>{{ end }}{{ end }}
+{{ define "scripts" }}{{ if (mod "blob").Structured }}<script src="{{ static "js/structured.js" }}" defer></script>{{ end }}{{ end }}
diff --git a/templates/views/commit.html b/templates/views/commit.html
index f2562fc..2a1f97b 100644
--- a/templates/views/commit.html
+++ b/templates/views/commit.html
@@ -1,7 +1,7 @@
{{ define "commit.html" }}{{ template "base" . }}{{ end }}
{{ define "styles" }}
-<link rel="stylesheet" href="/static/css/diff.css">
-<link rel="stylesheet" href="/static/css/readme.css">
+<link rel="stylesheet" href="{{ static "css/diff.css" }}">
+<link rel="stylesheet" href="{{ static "css/readme.css" }}">
{{ end }}
{{ define "main" }}
{{ $slug := print "/" .Info.Slug }}
@@ -22,14 +22,25 @@
<div class="readme"><pre style="white-space:pre-wrap;margin:0;">{{ . }}</pre></div>
{{ end }}
-{{ with $c.Parents }}
- <div class="chips" style="margin-bottom:1rem;">
+<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>
- <a class="chip" href="{{ $slug }}/compare/{{ . }}..{{ $c.SHA }}">diff</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 }}
- </div>
-{{ end }}
+ {{ end }}
+ <a class="chip" href="{{ $slug }}/tree/{{ $c.SHA }}">view tree</a>
+</div>
+
+<form class="compare-form" style="margin-bottom:1rem;"
+ onsubmit="var v=this.rev.value.trim(); if(v) location.href='{{ $slug }}/compare/'+encodeURIComponent(v)+'..{{ $c.SHA }}'; return false;">
+ <input type="text" name="rev" class="compare-input" placeholder="compare against a branch, tag, or commit&hellip;" required>
+ <button type="submit" class="chip">Compare</button>
+</form>
<article class="card">
<header class="page-head">
diff --git a/templates/views/compare.html b/templates/views/compare.html
index 8bdc698..8630cd0 100644
--- a/templates/views/compare.html
+++ b/templates/views/compare.html
@@ -1,5 +1,5 @@
{{ define "compare.html" }}{{ template "base" . }}{{ end }}
-{{ define "styles" }}<link rel="stylesheet" href="/static/css/diff.css">{{ end }}
+{{ define "styles" }}<link rel="stylesheet" href="{{ static "css/diff.css" }}">{{ end }}
{{ define "main" }}
{{ $slug := print "/" .Info.Slug }}
{{ $c := mod "compare" }}
diff --git a/templates/views/graph.html b/templates/views/graph.html
index 57deca2..c52dd0d 100644
--- a/templates/views/graph.html
+++ b/templates/views/graph.html
@@ -1,5 +1,5 @@
{{ define "graph.html" }}{{ template "base" . }}{{ end }}
-{{ define "styles" }}<link rel="stylesheet" href="/static/css/graph.css">{{ end }}
+{{ define "styles" }}<link rel="stylesheet" href="{{ static "css/graph.css" }}">{{ end }}
{{ define "main" }}
{{ $slug := print "/" .Info.Slug }}
{{/* "150" here must match core.GraphPageSize -- buildCtx uses that
diff --git a/templates/views/index.html b/templates/views/index.html
index fafbfa4..6c31162 100644
--- a/templates/views/index.html
+++ b/templates/views/index.html
@@ -1,5 +1,5 @@
{{ define "index.html" }}{{ template "base" . }}{{ end }}
-{{ define "styles" }}<link rel="stylesheet" href="/static/css/repo-list.css">{{ end }}
+{{ define "styles" }}<link rel="stylesheet" href="{{ static "css/repo-list.css" }}">{{ end }}
{{ define "main" }}
{{ $secs := mod "sections" }}
@@ -12,12 +12,17 @@
{{/* An unnamed section means gsg.section is unset everywhere: render flat. */}}
{{ with .Name }}<h2 id="{{ slug . }}" class="page-subtitle">{{ . }}</h2>{{ end }}
<div class="table-wrap">
-<table class="table">
+<table class="table table-repos">
+ <colgroup>
+ <col class="col-repo">
+ <col class="col-desc">
+ <col class="col-meta">
+ </colgroup>
<thead>
<tr>
- <th>Repository</th>
+ <th><span class="label-full">Repository</span><span class="label-short">Repo</span></th>
<th>Description</th>
- <th class="text-right">Last Updated</th>
+ <th class="text-right"><span class="label-full">Last Updated</span><span class="label-short">Updated</span></th>
</tr>
</thead>
<tbody>
diff --git a/templates/views/tree.html b/templates/views/tree.html
index c50b83f..181d8bb 100644
--- a/templates/views/tree.html
+++ b/templates/views/tree.html
@@ -1,7 +1,7 @@
{{ define "tree.html" }}{{ template "base" . }}{{ end }}
{{ define "styles" }}
-<link rel="stylesheet" href="/static/css/repo.css">
-<link rel="stylesheet" href="/static/css/readme.css">
+<link rel="stylesheet" href="{{ static "css/repo.css" }}">
+<link rel="stylesheet" href="{{ static "css/readme.css" }}">
{{ end }}
{{ define "main" }}
{{ $slug := print "/" .Info.Slug }}