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

Compare

38fa4e6cd9bb9dbf0ae23ef02e50b1c75cd84ebb … main · 7 commits

merge base 38fa4e6cd9bb…

Changed files

Commits

HashSubjectAuthorDate
554bcfc6 invalid var name and version bump Ryan Schanzenbacher
d658fcc4 merge caching trials back in and solve conflicts Ryan Schanzenbacher
cb5a7c7c add human readme Ryan Schanzenbacher
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
diff --git a/AGENTS.md b/AGENTS.md
index ee02972..34fe639 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -50,10 +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
```
@@ -68,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
@@ -238,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
diff --git a/README.md b/README.md
index 989bc2b..1d94c39 100644
--- a/README.md
+++ b/README.md
@@ -1,362 +1,52 @@
-# gsg — a modular git browser
-
-cgit's weight, Hugo's composability, served dynamically. A **module**
-computes one piece of a view; a **route** binds a renderer to a URL pattern.
-Adding a feature means adding a module and calling it from a template —
-`internal/core` never changes.
-
-One dependency (`goldmark`, which itself has none) plus the `git` binary. Go 1.22+ (uses `net/http`
-pattern wildcards, so there is no router dependency either).
-
-```
-go build -o gsg .
-./gsg -root /srv/git -addr :8080 -name "rschanz.org"
-./gsg -root /srv/git -templates ./templates # re-parse the theme per request
-./gsg -root /srv/git -notices /etc/gsg/notices.yaml -admin-addr 127.0.0.1:8081
-```
-
-## The idea
-
-```go
-type Module func(c *Ctx, args ...string) (any, error)
-```
-
-Templates reach modules through one funcmap entry:
-
-```gotmpl
-{{ with mod "meta" }}<h1>{{ .Title }}</h1>{{ end }}
-{{ $d := mod "diff" }}
-{{ with modOr "readme" }}{{ .HTML }}{{ end }}
-```
-
-- **Lazy.** A template that calls `mod "authors"` is what *causes* authors to
- be computed. Views that don't ask don't pay.
-- **Memoized twice.** Once per request (`Ctx.cache`), and once process-wide
- against the resolved commit SHA (`core.LRU`).
-- **No manifest.** Dependencies are declared by use. There is no module list
- to keep in sync with the templates.
-
-`internal/modules/example_authors.go` is a live demonstration: 30 lines, an
-`init()`, and one `{{ with mod "authors" }}` block. Nothing else moved.
-
-## Pages and endpoints are the same mechanism
-
-A route is `{pattern, renderer}`. `core.View` renders a template,
-`core.JSON` serializes an endpoint. Both take a `*Ctx`, both call modules.
-
-```go
-{Name: "commit", Immutable: true,
- Pattern: "GET /{repo}/commit/{rev}",
- Render: core.View{Template: "commit.html"}},
-
-{Name: "api-commit", Immutable: true,
- Pattern: "GET /{repo}/api/commit/{rev}",
- Render: core.JSON{Build: api.CommitEndpoint}},
-```
-
-### Endpoints use DTOs, not module values
-
-`internal/api` defines explicit structs and never marshals a module's return
-value. Go marshals every exported field by default, so serializing a module
-type means the day someone adds `Body` to `gitx.Commit`, commit messages are
-public. The allowlist is one readable file.
-
-```
-GET /api/repos.json site index
-GET /{repo}/api/repo.json slug, title, description, owner, section,
- clone_url, default_branch, last_commit
-GET /{repo}/api/commit/{sha} sha, short, authored, parents, and
- file/line counts — no subject, no body
-```
-
-## Routes
-
-| Route | URL |
-|---|---|
-| index | `/` |
-| repo | `/{repo}/` — default branch, last update, latest commit, README |
-| files | `/{repo}/tree/{ref}/{path}` |
-| file | `/{repo}/blob/{ref}/{path}` |
-| **raw bytes** | `/{repo}/raw/{ref}/{path}` |
-| log | `/{repo}/log/{ref}` |
-| refs | `/{repo}/refs/` |
-| commit | `/{repo}/commit/{sha}` |
-| **compare** | `/{repo}/compare/{base}..{head}` or `{base}...{head}` |
-| **branch graph** | `/{repo}/graph/` |
-
-### Compare
-
-`a..b` is the literal endpoint difference. `a...b` diffs from the merge
-base, which is what you want when reviewing a branch — it excludes changes
-that landed on the base since the branch forked. Verified against
-`git merge-base` on a diverged fixture: `topic..main` lists `topic.txt` as
-removed, `topic...main` does not.
-
-### Raw files
-
-A repository can contain an `.html` file with a `<script>` tag. Serving that
-as `text/html` on your own origin turns any push into stored XSS against the
-whole site — which is why GitHub puts raw content on
-`raw.githubusercontent.com`, a separate origin. Same-host serving is only
-safe if the response cannot be interpreted as a document, so `/raw/` sends
-`text/plain` (or `application/octet-stream` for binaries, plus
-`Content-Disposition: attachment`), with `X-Content-Type-Options: nosniff`
-and `Content-Security-Policy: default-src \'none\'; sandbox` as a backstop.
-
-The exception is a validated raster image (PNG, JPEG, GIF, WebP, BMP, ICO,
-AVIF): when the extension and the magic bytes agree, `/raw/` declares the
-real `image/*` type inline so the blob page can preview it. An extension by
-itself is not enough — an `.png` whose contents are HTML still downloads as
-`application/octet-stream`. SVG is deliberately excluded: it is an XML
-document that can execute script, so it stays `text/plain`.
-
-If you would rather have real isolation, serve `/raw/` from a second
-hostname and the CSP becomes belt-and-braces rather than the only line.
-
-### Images and structured config files
-
-An image blob shows an inline preview with a `download` link to the same
-`/raw/` URL; images over 2 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
-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 notices
-internal/notices/ notices YAML store + admin UI (imports no core)
-internal/api/ JSON DTOs — the serialization allowlist
-internal/site/routes.go the route table (the file you edit most)
-templates/ layouts/ + views/
-```
-
-`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. Authentik decides who may
-reach the handler at all.
-
-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.
-
-**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.
-
-## 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 509d656..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"
)
@@ -30,13 +33,23 @@ type Site struct {
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
@@ -48,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 {
@@ -60,13 +74,19 @@ func (s *Site) Lookup(slug string) (Handle, bool) {
}
// Revision is the site-level analogue of RepoInfo.GPGKeyring: a token for
-// inputs that change outside a commit. Nil-safe because tests construct
-// bare *Ctx values and LoadTemplates parses with &Ctx{}.
+// 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 || s.RevFn == nil {
+ if s == nil {
return ""
}
- return s.RevFn()
+ 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
@@ -107,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.
@@ -131,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 {
@@ -142,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
@@ -149,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 {
@@ -161,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,
}
}
@@ -185,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
@@ -206,6 +339,11 @@ type Ctx struct {
// Commit is set.
Store Store
+ // StaticTag is Server.StaticTag, threaded through so the `static`
+ // template func can build cache-busting asset URLs without a package
+ // import cycle back to Server.
+ StaticTag string
+
mu sync.Mutex
cache map[string]result
}
@@ -269,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
@@ -281,6 +422,18 @@ func (c *Ctx) Derive(path string, vars map[string]string) *Ctx {
func (c *Ctx) Var(k string) string { return c.Vars[k] }
+// cacheMaxAge is how long an anonymous response for this repo may be
+// reused before revalidating. Server.buildResponseHead (the browser-facing
+// Cache-Control header) and Server.fillResponse (the server-side response
+// cache's own TTL) both read this, so the two never disagree about
+// staleness.
+func (c *Ctx) cacheMaxAge() time.Duration {
+ if c.Info.CacheMaxAge > 0 {
+ return c.Info.CacheMaxAge
+ }
+ return defaultAnonCacheMaxAge
+}
+
// ---------------------------------------------------------------- registry
// Module computes one piece of a view. Anything a template or an endpoint
diff --git a/internal/core/funcs.go b/internal/core/funcs.go
index 9db614c..7414c5f 100644
--- a/internal/core/funcs.go
+++ b/internal/core/funcs.go
@@ -31,6 +31,14 @@ func FuncMap(c *Ctx) template.FuncMap {
"url": func(parts ...string) string {
return "/" + strings.Trim(strings.Join(parts, "/"), "/")
},
+ // static builds a cache-busting asset URL, e.g. "css/base.css" ->
+ // "/static/css/base.css?v=<tag>". The `v` param is what lets
+ // main.go's static handler hand out a year-long immutable
+ // Cache-Control instead of a short one -- a URL without it is
+ // still served, just not frozen for a year.
+ "static": func(p string) string {
+ return "/static/" + p + "?v=" + c.StaticTag
+ },
"dict": dict,
"date": func(t time.Time) string {
if t.IsZero() {
@@ -43,7 +51,7 @@ func FuncMap(c *Ctx) template.FuncMap {
if len(s) <= n {
return s
}
- return s[:n] + "\u2026"
+ return s[:n] + "…"
},
"bytes": humanBytes,
"patch": renderPatch,
diff --git a/internal/core/route.go b/internal/core/route.go
index dba5f32..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
diff --git a/internal/core/server.go b/internal/core/server.go
index 4c5cd65..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)
+ //
+ // 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 {
- w.Header().Set("Cache-Control", "public, max-age=31536000, 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,7 +429,12 @@ 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)
@@ -217,26 +455,40 @@ func (s *Server) buildCtx(ctx contexter, rt Route, r *http.Request) (*Ctx, error
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
- // serve() reads the same field, so page chrome that must not be frozen
- // into a year-long response is suppressed by exactly the condition that
- // makes the response year-long.
+ // buildResponseHead reads the same field, so page chrome that must not
+ // be frozen into a year-long response is suppressed by exactly the
+ // condition that makes the response year-long.
c.Immutable = rt.Immutable && c.Commit != ""
return c, nil
}
+// 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) {
@@ -248,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/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/image.go b/internal/modules/image.go
index 288e206..088489b 100644
--- a/internal/modules/image.go
+++ b/internal/modules/image.go
@@ -11,8 +11,10 @@ import (
// 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.
-const maxPreviewSize = 2 << 20 // 2 MiB
+// 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.
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/notices/handler.go b/internal/notices/handler.go
index 5be09e3..5c467b0 100644
--- a/internal/notices/handler.go
+++ b/internal/notices/handler.go
@@ -9,10 +9,11 @@ import (
"html/template"
"log/slog"
"net/http"
- "net/url"
"sort"
"strconv"
"strings"
+
+ "gsg/internal/httpx"
)
//go:embed admin.html
@@ -26,7 +27,19 @@ type HandlerConfig struct {
Repos []string
SiteName string
UserHeader string // e.g. "X-Authentik-Username"; logging only, never authz
- Logger *slog.Logger
+
+ // 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 {
@@ -63,6 +76,9 @@ func NewHandler(cfg HandlerConfig) (http.Handler, error) {
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)
@@ -75,7 +91,44 @@ func NewHandler(cfg HandlerConfig) (http.Handler, error) {
mux.HandleFunc("GET /admin/{$}", h.index)
mux.HandleFunc("GET /admin/notices", h.get)
mux.HandleFunc("POST /admin/notices", h.post)
- return mux, nil
+
+ 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) {
@@ -83,7 +136,7 @@ func (h *handler) index(w http.ResponseWriter, r *http.Request) {
}
func (h *handler) get(w http.ResponseWriter, r *http.Request) {
- h.secure(w)
+ httpx.Secure(w, httpx.SecureOptions{ScriptSrc: "'self'"})
tok, err := ensureCSRF(w, r)
if err != nil {
http.Error(w, "cannot start session", http.StatusInternalServerError)
@@ -93,12 +146,12 @@ func (h *handler) get(w http.ResponseWriter, r *http.Request) {
}
func (h *handler) post(w http.ResponseWriter, r *http.Request) {
- h.secure(w)
+ 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 !sameOrigin(r) {
+ 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"))
@@ -133,16 +186,6 @@ func (h *handler) post(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/notices?saved=1", http.StatusSeeOther)
}
-// secure sets the admin response headers. style-src 'self' only: the page
-// ships no inline script or style, so the CSP can stay tight.
-func (h *handler) secure(w http.ResponseWriter) {
- hdr := w.Header()
- hdr.Set("X-Content-Type-Options", "nosniff")
- hdr.Set("X-Frame-Options", "DENY")
- hdr.Set("Content-Security-Policy",
- "default-src 'none'; style-src 'self'; script-src 'self'; img-src 'self'; form-action 'self'; base-uri 'none'")
-}
-
func (h *handler) render(w http.ResponseWriter, status int, csrf, errMsg string, saved bool) {
snap := h.cfg.Store.Snapshot()
rows := make([]adminRow, 0, len(snap.Doc.Global)+2)
@@ -226,7 +269,7 @@ func ensureCSRF(w http.ResponseWriter, r *http.Request) (string, error) {
tok := hex.EncodeToString(b)
http.SetCookie(w, &http.Cookie{
Name: csrfCookie, Value: tok, Path: "/admin",
- HttpOnly: true, Secure: isHTTPS(r), SameSite: http.SameSiteStrictMode,
+ HttpOnly: true, Secure: httpx.IsHTTPS(r), SameSite: http.SameSiteStrictMode,
MaxAge: 86400,
})
return tok, nil
@@ -240,25 +283,3 @@ func checkCSRF(r *http.Request) bool {
got := r.PostForm.Get("csrf")
return subtle.ConstantTimeCompare([]byte(c.Value), []byte(got)) == 1
}
-
-func isHTTPS(r *http.Request) bool {
- return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
-}
-
-// sameOrigin blocks a cross-site POST even if the auth cookie were sent.
-// Forward auth proves the browser holds a session; it does not prove the
-// request came from our own page.
-func sameOrigin(r *http.Request) bool {
- switch r.Header.Get("Sec-Fetch-Site") {
- case "", "same-origin", "none":
- default:
- return false
- }
- if o := r.Header.Get("Origin"); o != "" {
- u, err := url.Parse(o)
- if err != nil || !strings.EqualFold(u.Host, r.Host) {
- return false
- }
- }
- return true
-}
diff --git a/internal/notices/handler_test.go b/internal/notices/handler_test.go
new file mode 100644
index 0000000..e076a20
--- /dev/null
+++ b/internal/notices/handler_test.go
@@ -0,0 +1,114 @@
+package notices
+
+import (
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "testing"
+)
+
+func testHandler(t *testing.T, cfg HandlerConfig) http.Handler {
+ t.Helper()
+ store, err := Open(filepath.Join(t.TempDir(), "notices.yaml"))
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ t.Cleanup(store.Close)
+ cfg.Store = store
+ if cfg.Logger == nil {
+ cfg.Logger = slog.Default()
+ }
+ h, err := NewHandler(cfg)
+ if err != nil {
+ t.Fatalf("NewHandler: %v", err)
+ }
+ return h
+}
+
+// Without RequiredGroup, the handler behaves exactly as it always did:
+// reaching it at all is enough, regardless of any group header.
+func TestHandlerNoGroupGateByDefault(t *testing.T) {
+ h := testHandler(t, HandlerConfig{})
+
+ req := httptest.NewRequest("GET", "/admin/notices", nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+}
+
+// With RequiredGroup set, a caller whose GroupHeader doesn't list it is
+// refused before the handler does anything else -- no CSRF cookie, no page.
+func TestHandlerRejectsMissingGroup(t *testing.T) {
+ h := testHandler(t, HandlerConfig{RequiredGroup: "gsg-admins"})
+
+ cases := []struct {
+ name string
+ groups string
+ }{
+ {"no header", ""},
+ {"different group", "some-other-group"},
+ {"prefix collision", "gsg-admins-extra"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ req := httptest.NewRequest("GET", "/admin/notices", nil)
+ if tc.groups != "" {
+ req.Header.Set("X-Authentik-Groups", tc.groups)
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
+ }
+ if rec.Result().Cookies() != nil && len(rec.Result().Cookies()) != 0 {
+ t.Errorf("rejected request set cookies: %v", rec.Result().Cookies())
+ }
+ })
+ }
+}
+
+// A member -- anywhere in the "|"-delimited list, with incidental
+// whitespace -- is let through.
+func TestHandlerAllowsMemberGroup(t *testing.T) {
+ h := testHandler(t, HandlerConfig{RequiredGroup: "gsg-admins"})
+
+ cases := []string{
+ "gsg-admins",
+ "other-group|gsg-admins",
+ "gsg-admins|other-group",
+ "other-group| gsg-admins |another",
+ }
+ for _, groups := range cases {
+ req := httptest.NewRequest("GET", "/admin/notices", nil)
+ req.Header.Set("X-Authentik-Groups", groups)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Errorf("groups=%q: status = %d, want %d", groups, rec.Code, http.StatusOK)
+ }
+ }
+}
+
+// A custom GroupHeader is honored instead of the Authentik default.
+func TestHandlerCustomGroupHeader(t *testing.T) {
+ h := testHandler(t, HandlerConfig{RequiredGroup: "gsg-admins", GroupHeader: "X-Custom-Groups"})
+
+ req := httptest.NewRequest("GET", "/admin/notices", nil)
+ req.Header.Set("X-Authentik-Groups", "gsg-admins") // wrong header, should not count
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("status = %d, want %d (wrong header should not authorize)", rec.Code, http.StatusForbidden)
+ }
+
+ req = httptest.NewRequest("GET", "/admin/notices", nil)
+ req.Header.Set("X-Custom-Groups", "gsg-admins")
+ rec = httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+}
diff --git a/internal/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 ee96fb2..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...}",
@@ -51,18 +52,26 @@ func Routes() []core.Route {
// The DAG across every ref. {rest...} carries only a ref (never a
// path, there's nothing to browse into) -- it exists purely so the
// selected branch survives navigating here and back out again.
- {Name: "graph", NavLabel: "graph",
- Pattern: "GET /{repo}/graph/{rest...}",
- Render: core.View{Template: "graph.html"}},
+ {Name: "graph", NavLabel: "graph", CacheableAnon: true,
+ Pattern: "GET /{repo}/graph/{rest...}",
+ Render: core.View{Template: "graph.html"},
+ PageSize: core.GraphPageSize},
- {Name: "refs", NavLabel: "refs",
+ {Name: "refs", NavLabel: "refs", CacheableAnon: true,
Pattern: "GET /{repo}/refs/{rest...}",
Render: core.View{Template: "refs.html"}},
- // Arbitrary two-commit diff: /demo/compare/main...feature
+ // Arbitrary two-commit diff: /demo/compare/main...feature. Not
+ // CacheableAnon: a diff body is bounded only by MaxGit (16 MiB,
+ // larger than a blob preview), and every valid ref pair is a
+ // distinct key -- a repo with N refs has ~N^2 reachable ones. The
+ // response cache has no size-aware eviction, so this route would
+ // risk filling it with a few huge entries for no real hit-rate
+ // benefit (see internal/core/route.go's CacheableAnon doc).
{Name: "compare",
Pattern: "GET /{repo}/compare/{range}",
- Render: core.View{Template: "compare.html"}},
+ 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 e6c1499..f5818ee 100644
--- a/main.go
+++ b/main.go
@@ -3,12 +3,15 @@
// 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
@@ -24,9 +27,12 @@ import (
"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"
)
@@ -40,6 +46,7 @@ type options struct {
root, addr, name, base, tmplDir string
noticesPath, adminAddr string
adminAllowRemote bool
+ adminGroupHeader, adminGroup string
cacheSize int
timeout time.Duration
}
@@ -54,7 +61,9 @@ func main() {
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.IntVar(&o.cacheSize, "cache", 4096, "module cache entries")
+ 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()
@@ -80,16 +89,24 @@ func run(o options) error {
// every module cache key and ETag (see Ctx.contentIdentity).
siteCfg.RevFn = nstore.Revision
}
- if o.adminAddr != "" && nstore == nil {
- return fmt.Errorf("-admin-addr requires -notices")
+ if err := core.Start(siteCfg); err != nil {
+ return err
}
- if err := core.Start(siteCfg); err != nil {
+ // 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 o.tmplDir != "" {
tfs = os.DirFS(o.tmplDir)
} else if tfs, err = fs.Sub(templateFS, "templates"); err != nil {
@@ -102,14 +119,66 @@ func run(o options) error {
}
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{{
@@ -120,35 +189,52 @@ func run(o options) error {
}}
if o.adminAddr != "" {
- // The public mux stays strictly read-only. The admin routes live on
- // their own listener so a misconfigured Caddy forward_auth cannot
- // expose a write path on the public port.
+ // 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)
}
- 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",
- Logger: slog.Default(),
- })
- if err != nil {
- return err
- }
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/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
- amux.Handle("/admin/", adminHandler)
+ 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,
@@ -171,6 +257,27 @@ func run(o options) error {
return <-errc
}
+// cachedStatic serves fsys with a Cache-Control that depends on whether the
+// request carries the `v` cache-busting query param the `static` template
+// func mints (see internal/core/funcs.go). Requests that carry it are
+// addressed by content hash, so they're frozen for a year; everything else
+// -- share/notices/error-page assets, which don't go through that funcmap
+// and so never get a `v` -- still gets a real improvement over no
+// Cache-Control at all, just a short one, in case a template edit changes
+// what a bare URL should serve.
+func cachedStatic(fsys fs.FS) http.Handler {
+ fileServer := http.FileServer(http.FS(fsys))
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Query().Get("v") != "" {
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+ } else {
+ w.Header().Set("Cache-Control", "public, max-age=600")
+ }
+ fileServer.ServeHTTP(w, r)
+ })
+ return http.StripPrefix("/static/", h)
+}
+
// isLoopbackAddr is a blunt guard, not a security boundary: it stops the
// admin listener from silently binding 0.0.0.0 outside a deliberate opt-in.
func isLoopbackAddr(addr string) bool {
diff --git a/static/css/base.css b/static/css/base.css
index 392b7aa..2d3aff4 100644
--- a/static/css/base.css
+++ b/static/css/base.css
@@ -166,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;
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/templates/layouts/base.html b/templates/layouts/base.html
index 67f1315..36a7382 100644
--- a/templates/layouts/base.html
+++ b/templates/layouts/base.html
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ block "title" . }}{{ with .Info.Title }}{{ . }} &middot; {{ end }}{{ .Site.Name }}{{ end }}</title>
-<link rel="stylesheet" href="/static/css/base.css">
+<link rel="stylesheet" href="{{ static "css/base.css" }}">
{{ block "styles" . }}{{ end }}
</head>
<body>
@@ -18,7 +18,7 @@
<div class="footer-right">
<!--<span>served from bare repositories</span>
<span style="color:var(--line);margin:0 .5rem;">&bull;</span>-->
- <span>{{ .Site.Name }} v1.1</span>
+ <span>{{ .Site.Name }} v1.4</span>
</div>
</footer>
{{ block "scripts" . }}{{ end }}
@@ -41,7 +41,9 @@
<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
@@ -60,8 +62,14 @@
</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/views/blob.html b/templates/views/blob.html
index 93aeef9..22b89f9 100644
--- a/templates/views/blob.html
+++ b/templates/views/blob.html
@@ -1,5 +1,5 @@
{{ define "blob.html" }}{{ template "base" . }}{{ end }}
-{{ define "styles" }}<link rel="stylesheet" href="/static/css/blob.css">{{ end }}
+{{ define "styles" }}<link rel="stylesheet" href="{{ static "css/blob.css" }}">{{ end }}
{{ define "main" }}
{{ $slug := print "/" .Info.Slug }}
{{ $b := mod "blob" }}
@@ -66,4 +66,4 @@
{{ end }}
{{ end }}
-{{ define "scripts" }}{{ if (mod "blob").Structured }}<script src="/static/js/structured.js" defer></script>{{ end }}{{ end }}
+{{ define "scripts" }}{{ if (mod "blob").Structured }}<script src="{{ static "js/structured.js" }}" defer></script>{{ end }}{{ end }}
diff --git a/templates/views/commit.html b/templates/views/commit.html
index 9522094..2a1f97b 100644
--- a/templates/views/commit.html
+++ b/templates/views/commit.html
@@ -1,7 +1,7 @@
{{ define "commit.html" }}{{ template "base" . }}{{ end }}
{{ define "styles" }}
-<link rel="stylesheet" href="/static/css/diff.css">
-<link rel="stylesheet" href="/static/css/readme.css">
+<link rel="stylesheet" href="{{ static "css/diff.css" }}">
+<link rel="stylesheet" href="{{ static "css/readme.css" }}">
{{ end }}
{{ define "main" }}
{{ $slug := print "/" .Info.Slug }}
diff --git a/templates/views/compare.html b/templates/views/compare.html
index 8bdc698..8630cd0 100644
--- a/templates/views/compare.html
+++ b/templates/views/compare.html
@@ -1,5 +1,5 @@
{{ define "compare.html" }}{{ template "base" . }}{{ end }}
-{{ define "styles" }}<link rel="stylesheet" href="/static/css/diff.css">{{ end }}
+{{ define "styles" }}<link rel="stylesheet" href="{{ static "css/diff.css" }}">{{ end }}
{{ define "main" }}
{{ $slug := print "/" .Info.Slug }}
{{ $c := mod "compare" }}
diff --git a/templates/views/graph.html b/templates/views/graph.html
index 57deca2..c52dd0d 100644
--- a/templates/views/graph.html
+++ b/templates/views/graph.html
@@ -1,5 +1,5 @@
{{ define "graph.html" }}{{ template "base" . }}{{ end }}
-{{ define "styles" }}<link rel="stylesheet" href="/static/css/graph.css">{{ end }}
+{{ define "styles" }}<link rel="stylesheet" href="{{ static "css/graph.css" }}">{{ end }}
{{ define "main" }}
{{ $slug := print "/" .Info.Slug }}
{{/* "150" here must match core.GraphPageSize -- buildCtx uses that
diff --git a/templates/views/index.html b/templates/views/index.html
index 8f26a42..6c31162 100644
--- a/templates/views/index.html
+++ b/templates/views/index.html
@@ -1,5 +1,5 @@
{{ define "index.html" }}{{ template "base" . }}{{ end }}
-{{ define "styles" }}<link rel="stylesheet" href="/static/css/repo-list.css">{{ end }}
+{{ define "styles" }}<link rel="stylesheet" href="{{ static "css/repo-list.css" }}">{{ end }}
{{ define "main" }}
{{ $secs := mod "sections" }}
diff --git a/templates/views/tree.html b/templates/views/tree.html
index c50b83f..181d8bb 100644
--- a/templates/views/tree.html
+++ b/templates/views/tree.html
@@ -1,7 +1,7 @@
{{ define "tree.html" }}{{ template "base" . }}{{ end }}
{{ define "styles" }}
-<link rel="stylesheet" href="/static/css/repo.css">
-<link rel="stylesheet" href="/static/css/readme.css">
+<link rel="stylesheet" href="{{ static "css/repo.css" }}">
+<link rel="stylesheet" href="{{ static "css/readme.css" }}">
{{ end }}
{{ define "main" }}
{{ $slug := print "/" .Info.Slug }}