Migration
This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs.
Contact me
Compare
38fa4e6cd9bb9dbf0ae23ef02e50b1c75cd84ebb
…
main
· 7 commits
Commits
| Hash | Subject | Author | Date |
|---|---|---|---|
| 554bcfc6 | invalid var name and version bump | Ryan Schanzenbacher | |
| d658fcc4 | merge caching trials back in and solve conflicts | Ryan Schanzenbacher | |
| cb5a7c7c | add human readme | Ryan Schanzenbacher | |
| 79436f09 | cleanup some code that can be shared | Ryan Schanzenbacher | |
| 8d1bb5bf | beta-1.3 caching trial | Ryan Schanzenbacher | |
| 4bda28df | bump version | Ryan Schanzenbacher | |
| 83aa601b | Added share plumbing, some admin changes, bump max image preview | Ryan Schanzenbacher |
@@ -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
@@ -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!
@@ -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
)
@@ -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=
@@ -0,0 +1,82 @@
+// Package access holds the rules that decide who may see or share a
+// repository. It deliberately imports nothing from core: the visibility
+// rule is used by the 404 gate, the index module, share-link minting, and
+// share-link redemption, and putting it in one dependency-free place is
+// what keeps those four from drifting apart.
+package access
+
+import (
+ "strings"
+ "time"
+)
+
+// Policy is the authorization-relevant subset of a repository's config.
+// core.RepoInfo is the full config; Policy is what the rules below need.
+type Policy struct {
+ Slug string
+ Hidden bool
+ VisibleTo []string
+ ShareRevokedAfter time.Time
+}
+
+// Identity is the verified caller. SharedSlugs/SharedIssuedAt come from a
+// valid gsg_share cookie and are empty when the caller has no share grant.
+type Identity struct {
+ User string
+ SharedSlugs []string
+ SharedIssuedAt time.Time
+}
+
+// CanView reports whether id may see the repository at all. Public repos
+// are visible to everyone; a hidden repo is visible to an owner named in
+// gsg.visible-to or to an unrevoked share grant.
+func (p Policy) CanView(id Identity) bool {
+ if !p.Hidden {
+ return true
+ }
+ return p.canViewAsOwner(id.User) ||
+ (p.coversShare(id.SharedSlugs) && ShareLive(p.ShareRevokedAfter, id.SharedIssuedAt))
+}
+
+// CanShare reports whether user may mint a share link for this repository.
+// Only hidden repos are shareable (sharing a public repo would grant
+// nothing new), and only an owner may mint -- a share grant cannot be
+// amplified into a broader grant.
+func (p Policy) CanShare(user string) bool {
+ return p.Hidden && p.canViewAsOwner(user)
+}
+
+// canViewAsOwner matches gsg.visible-to case-insensitively. Anonymous
+// callers never match: "" would otherwise vacuous-match a blank entry.
+func (p Policy) canViewAsOwner(user string) bool {
+ if user == "" {
+ return false
+ }
+ for _, u := range p.VisibleTo {
+ if strings.EqualFold(u, user) {
+ return true
+ }
+ }
+ return false
+}
+
+func (p Policy) coversShare(slugs []string) bool {
+ for _, s := range slugs {
+ if s == p.Slug {
+ return true
+ }
+ }
+ return false
+}
+
+// ShareLive reports whether a grant issued at issuedAt survives a
+// repository's gsg.share-revoked-after value. A zero revokedAfter means no
+// revocation; a grant issued at or before the timestamp is revoked, which
+// is why the comparison is strict -- the operator's intent is "everything
+// issued before this moment is dead", and the two sides are second-granular.
+//
+// The free function is for callers (share redemption) that only have the
+// timestamp, not a full Policy.
+func ShareLive(revokedAfter, issuedAt time.Time) bool {
+ return revokedAfter.IsZero() || issuedAt.After(revokedAfter)
+}
@@ -0,0 +1,107 @@
+package access
+
+import (
+ "testing"
+ "time"
+)
+
+func TestCanView(t *testing.T) {
+ revoked := time.Unix(1_700_000_000, 0)
+ granted := revoked.Add(time.Minute)
+
+ cases := []struct {
+ name string
+ p Policy
+ id Identity
+ want bool
+ }{
+ {
+ name: "public repo",
+ p: Policy{Slug: "demo"},
+ want: true,
+ },
+ {
+ name: "hidden anonymous",
+ p: Policy{Slug: "demo", Hidden: true},
+ want: false,
+ },
+ {
+ name: "hidden owner",
+ p: Policy{Slug: "demo", Hidden: true, VisibleTo: []string{"Alice"}},
+ id: Identity{User: "alice"},
+ want: true,
+ },
+ {
+ name: "hidden non-owner",
+ p: Policy{Slug: "demo", Hidden: true, VisibleTo: []string{"alice"}},
+ id: Identity{User: "bob"},
+ want: false,
+ },
+ {
+ name: "hidden share grant",
+ p: Policy{Slug: "demo", Hidden: true},
+ id: Identity{SharedSlugs: []string{"demo"}, SharedIssuedAt: granted},
+ want: true,
+ },
+ {
+ name: "share grant for another repo",
+ p: Policy{Slug: "demo", Hidden: true},
+ id: Identity{SharedSlugs: []string{"other"}, SharedIssuedAt: granted},
+ want: false,
+ },
+ {
+ name: "share grant before revocation",
+ p: Policy{Slug: "demo", Hidden: true, ShareRevokedAfter: revoked},
+ id: Identity{SharedSlugs: []string{"demo"}, SharedIssuedAt: revoked},
+ want: false,
+ },
+ {
+ name: "share grant after revocation",
+ p: Policy{Slug: "demo", Hidden: true, ShareRevokedAfter: revoked},
+ id: Identity{SharedSlugs: []string{"demo"}, SharedIssuedAt: granted},
+ want: true,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := tc.p.CanView(tc.id); got != tc.want {
+ t.Fatalf("CanView() = %v, want %v", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestCanShare(t *testing.T) {
+ p := Policy{Slug: "demo", Hidden: true, VisibleTo: []string{"alice"}}
+
+ if !p.CanShare("alice") {
+ t.Fatal("owner should be able to share a hidden repo")
+ }
+ if p.CanShare("bob") {
+ t.Fatal("non-owner should not be able to share")
+ }
+ if p.CanShare("") {
+ t.Fatal("anonymous should not be able to share")
+ }
+ if (Policy{Slug: "public", VisibleTo: []string{"alice"}}).CanShare("alice") {
+ t.Fatal("public repo should not be shareable")
+ }
+}
+
+func TestShareLive(t *testing.T) {
+ revoked := time.Unix(1_700_000_000, 0)
+
+ if !ShareLive(time.Time{}, revoked) {
+ t.Fatal("zero revokedAfter should not revoke")
+ }
+ if ShareLive(revoked, revoked) {
+ t.Fatal("grant issued at the revocation time should be revoked")
+ }
+ if ShareLive(revoked, revoked.Add(-time.Second)) {
+ t.Fatal("grant issued before revocation should be revoked")
+ }
+ if !ShareLive(revoked, revoked.Add(time.Second)) {
+ t.Fatal("grant issued after revocation should survive")
+ }
+}
@@ -0,0 +1,79 @@
+// Package authn owns gsg's browser session endpoints. Login trusts the
+// identity header that the reverse proxy sets after forward_auth; logout
+// only clears gsg's own cookie. Keeping both here means main.go composes
+// listeners rather than implementing authentication.
+//
+// The two handlers are returned separately on purpose: login must only ever
+// be mounted on the admin listener, while logout belongs on the public one.
+// A single combined handler would make that distinction a calling
+// convention instead of a structural one.
+package authn
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "gsg/internal/session"
+)
+
+// Config is the small amount of state the session endpoints need.
+type Config struct {
+ Sessions *session.Manager
+ UserHeader string // e.g. "X-Authentik-Username"; defaults to that
+}
+
+// Handlers are mounted individually by the caller.
+type Handlers struct {
+ Login http.Handler
+ Logout http.Handler
+}
+
+// NewHandlers builds the login and logout endpoints.
+func NewHandlers(cfg Config) (Handlers, error) {
+ if cfg.Sessions == nil {
+ return Handlers{}, fmt.Errorf("authn: no session manager")
+ }
+ if cfg.UserHeader == "" {
+ cfg.UserHeader = "X-Authentik-Username"
+ }
+ return Handlers{
+ Login: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { login(cfg, w, r) }),
+ Logout: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { logout(cfg, w, r) }),
+ }, nil
+}
+
+// login mints a session from the proxy-supplied identity. Reaching this
+// handler at all is the authorization decision: the admin listener is the
+// only place it is mounted, and Caddy's forward_auth runs before it.
+func login(cfg Config, w http.ResponseWriter, r *http.Request) {
+ user := r.Header.Get(cfg.UserHeader)
+ if user == "" {
+ http.Error(w, "missing identity header", http.StatusUnauthorized)
+ return
+ }
+ http.SetCookie(w, cfg.Sessions.Issue(user))
+ http.Redirect(w, r, safeNext(r.URL.Query().Get("next")), http.StatusSeeOther)
+}
+
+// logout needs no round trip through the identity provider; it only clears
+// the cookie this process issued.
+func logout(cfg Config, w http.ResponseWriter, r *http.Request) {
+ http.SetCookie(w, cfg.Sessions.Clear())
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+// safeNext returns next if it is a same-site relative path, else "/". next
+// comes from a query parameter on a login redirect; without this check it
+// would be an open redirect (?next=https://evil.example) off a trusted
+// domain, right after proving who the user is.
+func safeNext(next string) string {
+ if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
+ return "/"
+ }
+ if u, err := url.Parse(next); err != nil || u.Scheme != "" || u.Host != "" {
+ return "/"
+ }
+ return next
+}
@@ -0,0 +1,104 @@
+package authn
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "gsg/internal/session"
+)
+
+func TestSafeNext(t *testing.T) {
+ cases := []struct {
+ in, want string
+ }{
+ {"", "/"},
+ {"/", "/"},
+ {"/demo/", "/demo/"},
+ {"/share/new?x=1", "/share/new?x=1"},
+ {"//evil.example", "/"},
+ {"https://evil.example", "/"},
+ {"http://evil.example/path", "/"},
+ {"relative", "/"},
+ }
+ for _, tc := range cases {
+ if got := safeNext(tc.in); got != tc.want {
+ t.Errorf("safeNext(%q) = %q, want %q", tc.in, got, tc.want)
+ }
+ }
+}
+
+func TestLoginRequiresIdentityHeader(t *testing.T) {
+ h := testHandlers(t)
+ req := httptest.NewRequest("GET", "/auth/login", nil)
+ rec := httptest.NewRecorder()
+ h.Login.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
+ }
+ if cookies := rec.Result().Cookies(); len(cookies) != 0 {
+ t.Fatalf("unauthenticated login set cookies: %v", cookies)
+ }
+}
+
+func TestLoginIssuesCookieAndHonorsSafeNext(t *testing.T) {
+ h := testHandlers(t)
+ req := httptest.NewRequest("GET", "/auth/login?next=/demo/", nil)
+ req.Header.Set("X-Authentik-Username", "alice")
+ rec := httptest.NewRecorder()
+ h.Login.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusSeeOther)
+ }
+ if got := rec.Header().Get("Location"); got != "/demo/" {
+ t.Fatalf("Location = %q, want /demo/", got)
+ }
+ cookies := rec.Result().Cookies()
+ if len(cookies) != 1 || cookies[0].Name != session.CookieName {
+ t.Fatalf("cookies = %v, want one %s cookie", cookies, session.CookieName)
+ }
+}
+
+func TestLoginRejectsOffsiteNext(t *testing.T) {
+ h := testHandlers(t)
+ req := httptest.NewRequest("GET", "/auth/login?next=https://evil.example/", nil)
+ req.Header.Set("X-Authentik-Username", "alice")
+ rec := httptest.NewRecorder()
+ h.Login.ServeHTTP(rec, req)
+
+ if got := rec.Header().Get("Location"); got != "/" {
+ t.Fatalf("Location = %q, want /", got)
+ }
+}
+
+func TestLogoutClearsCookie(t *testing.T) {
+ h := testHandlers(t)
+ req := httptest.NewRequest("GET", "/auth/logout", nil)
+ rec := httptest.NewRecorder()
+ h.Logout.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusSeeOther)
+ }
+ if got := rec.Header().Get("Location"); got != "/" {
+ t.Fatalf("Location = %q, want /", got)
+ }
+ cookies := rec.Result().Cookies()
+ if len(cookies) != 1 || cookies[0].Name != session.CookieName {
+ t.Fatalf("cookies = %v, want one %s cookie", cookies, session.CookieName)
+ }
+ if cookies[0].MaxAge >= 0 {
+ t.Fatalf("logout cookie MaxAge = %d, want negative", cookies[0].MaxAge)
+ }
+}
+
+func testHandlers(t *testing.T) Handlers {
+ t.Helper()
+ h, err := NewHandlers(Config{Sessions: session.NewManager()})
+ if err != nil {
+ t.Fatalf("NewHandlers: %v", err)
+ }
+ return h
+}
@@ -6,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
@@ -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,
@@ -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
@@ -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")
@@ -0,0 +1,85 @@
+// Package httpx holds small HTTP helpers shared by gsg's non-page
+// surfaces. The admin and share handlers must make the same origin,
+// scheme, header and remote-address decisions; keeping them here means a
+// security fix lands in one place instead of three.
+package httpx
+
+import (
+ "net"
+ "net/http"
+ "net/url"
+ "strings"
+)
+
+// SecureOptions controls the admin/share response policy. These pages are
+// deliberately not the themed site: no inline script or style, so the CSP
+// can stay tight.
+type SecureOptions struct {
+ // ScriptSrc is the CSP script-src value. Empty means "'none'"; the
+ // notices admin page needs "'self'" for admin.js, while share pages
+ // ship no JavaScript at all.
+ ScriptSrc string
+ // NoStore marks a response whose body must never be stored, even by
+ // the requesting browser -- a minted share URL is a bearer capability.
+ NoStore bool
+}
+
+// Secure sets the response headers every admin/share page must carry.
+func Secure(w http.ResponseWriter, opts SecureOptions) {
+ scriptSrc := opts.ScriptSrc
+ if scriptSrc == "" {
+ scriptSrc = "'none'"
+ }
+ hdr := w.Header()
+ hdr.Set("X-Content-Type-Options", "nosniff")
+ hdr.Set("X-Frame-Options", "DENY")
+ hdr.Set("Content-Security-Policy",
+ "default-src 'none'; style-src 'self'; script-src "+scriptSrc+
+ "; img-src 'self'; form-action 'self'; base-uri 'none'")
+ if opts.NoStore {
+ hdr.Set("Cache-Control", "no-store")
+ }
+}
+
+// SameOrigin blocks a cross-site POST even if a session cookie would
+// otherwise be sent. A valid cookie proves the browser is logged in; it
+// does not prove the request came from our own page.
+func SameOrigin(r *http.Request) bool {
+ switch r.Header.Get("Sec-Fetch-Site") {
+ case "", "same-origin", "none":
+ default:
+ return false
+ }
+ if o := r.Header.Get("Origin"); o != "" {
+ u, err := url.Parse(o)
+ if err != nil || !strings.EqualFold(u.Host, r.Host) {
+ return false
+ }
+ }
+ return true
+}
+
+// IsHTTPS reports whether the browser's original request was HTTPS,
+// accounting for the reverse proxy's X-Forwarded-Proto.
+func IsHTTPS(r *http.Request) bool {
+ return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
+}
+
+// AbsoluteURL builds an absolute URL for a path on this host, using the
+// same proxy-aware scheme as IsHTTPS.
+func AbsoluteURL(r *http.Request, path string) string {
+ scheme := "http"
+ if IsHTTPS(r) {
+ scheme = "https"
+ }
+ return scheme + "://" + r.Host + path
+}
+
+// RemoteIP strips the port from r.RemoteAddr for use as a rate-limit key.
+func RemoteIP(r *http.Request) string {
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ return r.RemoteAddr
+ }
+ return host
+}
@@ -0,0 +1,98 @@
+package httpx
+
+import (
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestSecure(t *testing.T) {
+ rec := httptest.NewRecorder()
+ Secure(rec, SecureOptions{ScriptSrc: "'self'", NoStore: true})
+ h := rec.Header()
+
+ if got := h.Get("X-Content-Type-Options"); got != "nosniff" {
+ t.Errorf("X-Content-Type-Options = %q", got)
+ }
+ if got := h.Get("X-Frame-Options"); got != "DENY" {
+ t.Errorf("X-Frame-Options = %q", got)
+ }
+ if got := h.Get("Cache-Control"); got != "no-store" {
+ t.Errorf("Cache-Control = %q", got)
+ }
+ if got := h.Get("Content-Security-Policy"); got == "" ||
+ !strings.Contains(got, "script-src 'self'") || !strings.Contains(got, "base-uri 'none'") {
+ t.Errorf("Content-Security-Policy = %q", got)
+ }
+}
+
+func TestSecureDefaultsToNoScript(t *testing.T) {
+ rec := httptest.NewRecorder()
+ Secure(rec, SecureOptions{})
+ if got := rec.Header().Get("Content-Security-Policy"); !strings.Contains(got, "script-src 'none'") {
+ t.Errorf("Content-Security-Policy = %q, want script-src 'none'", got)
+ }
+ if got := rec.Header().Get("Cache-Control"); got != "" {
+ t.Errorf("Cache-Control = %q, want unset", got)
+ }
+}
+
+func TestSameOrigin(t *testing.T) {
+ cases := []struct {
+ name string
+ fetch string
+ origin string
+ want bool
+ }{
+ {name: "no headers", want: true},
+ {name: "same origin", fetch: "same-origin", origin: "https://git.example", want: true},
+ {name: "cross site", fetch: "cross-site", want: false},
+ {name: "different origin", origin: "https://evil.example", want: false},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ r := httptest.NewRequest("POST", "https://git.example/admin/notices", nil)
+ if tc.fetch != "" {
+ r.Header.Set("Sec-Fetch-Site", tc.fetch)
+ }
+ if tc.origin != "" {
+ r.Header.Set("Origin", tc.origin)
+ }
+ if got := SameOrigin(r); got != tc.want {
+ t.Fatalf("SameOrigin() = %v, want %v", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestIsHTTPSAndAbsoluteURL(t *testing.T) {
+ r := httptest.NewRequest("GET", "http://git.example/share/new", nil)
+ r.Host = "git.example"
+ if IsHTTPS(r) {
+ t.Fatal("plain request reported HTTPS")
+ }
+ if got := AbsoluteURL(r, "/share/view/token"); got != "http://git.example/share/view/token" {
+ t.Fatalf("AbsoluteURL() = %q", got)
+ }
+
+ r.Header.Set("X-Forwarded-Proto", "https")
+ if !IsHTTPS(r) {
+ t.Fatal("proxied HTTPS request not recognized")
+ }
+ if got := AbsoluteURL(r, "/share/view/token"); got != "https://git.example/share/view/token" {
+ t.Fatalf("AbsoluteURL() = %q", got)
+ }
+}
+
+func TestRemoteIP(t *testing.T) {
+ r := httptest.NewRequest("GET", "/", nil)
+ r.RemoteAddr = "203.0.113.9:54321"
+ if got := RemoteIP(r); got != "203.0.113.9" {
+ t.Fatalf("RemoteIP() = %q", got)
+ }
+ r.RemoteAddr = "unix"
+ if got := RemoteIP(r); got != "unix" {
+ t.Fatalf("RemoteIP() = %q", got)
+ }
+}
@@ -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.
@@ -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
}
@@ -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
-}
@@ -0,0 +1,114 @@
+package notices
+
+import (
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "testing"
+)
+
+func testHandler(t *testing.T, cfg HandlerConfig) http.Handler {
+ t.Helper()
+ store, err := Open(filepath.Join(t.TempDir(), "notices.yaml"))
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ t.Cleanup(store.Close)
+ cfg.Store = store
+ if cfg.Logger == nil {
+ cfg.Logger = slog.Default()
+ }
+ h, err := NewHandler(cfg)
+ if err != nil {
+ t.Fatalf("NewHandler: %v", err)
+ }
+ return h
+}
+
+// Without RequiredGroup, the handler behaves exactly as it always did:
+// reaching it at all is enough, regardless of any group header.
+func TestHandlerNoGroupGateByDefault(t *testing.T) {
+ h := testHandler(t, HandlerConfig{})
+
+ req := httptest.NewRequest("GET", "/admin/notices", nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+}
+
+// With RequiredGroup set, a caller whose GroupHeader doesn't list it is
+// refused before the handler does anything else -- no CSRF cookie, no page.
+func TestHandlerRejectsMissingGroup(t *testing.T) {
+ h := testHandler(t, HandlerConfig{RequiredGroup: "gsg-admins"})
+
+ cases := []struct {
+ name string
+ groups string
+ }{
+ {"no header", ""},
+ {"different group", "some-other-group"},
+ {"prefix collision", "gsg-admins-extra"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ req := httptest.NewRequest("GET", "/admin/notices", nil)
+ if tc.groups != "" {
+ req.Header.Set("X-Authentik-Groups", tc.groups)
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
+ }
+ if rec.Result().Cookies() != nil && len(rec.Result().Cookies()) != 0 {
+ t.Errorf("rejected request set cookies: %v", rec.Result().Cookies())
+ }
+ })
+ }
+}
+
+// A member -- anywhere in the "|"-delimited list, with incidental
+// whitespace -- is let through.
+func TestHandlerAllowsMemberGroup(t *testing.T) {
+ h := testHandler(t, HandlerConfig{RequiredGroup: "gsg-admins"})
+
+ cases := []string{
+ "gsg-admins",
+ "other-group|gsg-admins",
+ "gsg-admins|other-group",
+ "other-group| gsg-admins |another",
+ }
+ for _, groups := range cases {
+ req := httptest.NewRequest("GET", "/admin/notices", nil)
+ req.Header.Set("X-Authentik-Groups", groups)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Errorf("groups=%q: status = %d, want %d", groups, rec.Code, http.StatusOK)
+ }
+ }
+}
+
+// A custom GroupHeader is honored instead of the Authentik default.
+func TestHandlerCustomGroupHeader(t *testing.T) {
+ h := testHandler(t, HandlerConfig{RequiredGroup: "gsg-admins", GroupHeader: "X-Custom-Groups"})
+
+ req := httptest.NewRequest("GET", "/admin/notices", nil)
+ req.Header.Set("X-Authentik-Groups", "gsg-admins") // wrong header, should not count
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("status = %d, want %d (wrong header should not authorize)", rec.Code, http.StatusForbidden)
+ }
+
+ req = httptest.NewRequest("GET", "/admin/notices", nil)
+ req.Header.Set("X-Custom-Groups", "gsg-admins")
+ rec = httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+}
@@ -0,0 +1,280 @@
+// Package session mints and verifies the signed, stateless cookies gsg
+// uses to recognize a logged-in browser. There is no server-side session
+// store: everything needed to verify a cookie travels inside it, HMAC-signed
+// against a secret that lives only in this process's memory. A restart
+// invalidates every outstanding session -- an accepted trade-off for a
+// login this lightweight.
+//
+// The master secret is never used to sign anything directly. Each purpose
+// (today: the user session cookie; later: share-link tokens) gets its own
+// key derived from the secret via deriveKey, so a value signed for one
+// purpose can never be replayed as another -- that's enforced by
+// construction, not by a discriminator field a bug could skip.
+package session
+
+import (
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/subtle"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ // CookieName is the browser cookie carrying the signed session.
+ CookieName = "gsg_user"
+
+ // TTL is how long a session lasts after login before it must be
+ // re-established. Embedded inside the signed payload, not left to the
+ // cookie's own Max-Age/Expires alone, so a copied cookie can't outlive
+ // what the server considers valid.
+ TTL = 30 * 24 * time.Hour
+
+ // ShareCookieName is the browser cookie a redeemed share link mints.
+ ShareCookieName = "gsg_share"
+
+ purposeSession = "session"
+ purposeShareURL = "share-url"
+ purposeShareCookie = "share-cookie"
+)
+
+// Manager issues and verifies gsg's signed cookies. Safe for concurrent
+// use; a nil *Manager verifies every request as anonymous, so wiring it up
+// is optional.
+type Manager struct {
+ secret []byte
+}
+
+// NewManager generates a fresh in-memory secret.
+func NewManager() *Manager {
+ secret := make([]byte, 32)
+ if _, err := rand.Read(secret); err != nil {
+ panic("session: failed to generate secret: " + err.Error())
+ }
+ return &Manager{secret: secret}
+}
+
+// deriveKey derives a purpose-scoped signing key from the master secret.
+// This is a single-step HMAC-based derivation (not full RFC 5869 HKDF),
+// which is sufficient here: the input is already a uniformly random 32-byte
+// secret, so there's no entropy-extraction step to do, only labeling.
+func (m *Manager) deriveKey(purpose string) []byte {
+ mac := hmac.New(sha256.New, m.secret)
+ mac.Write([]byte(purpose))
+ return mac.Sum(nil)
+}
+
+// Issue mints a signed session cookie for username.
+func (m *Manager) Issue(username string) *http.Cookie {
+ exp := time.Now().Add(TTL).Unix()
+ payload := username + "|" + strconv.FormatInt(exp, 10)
+ return &http.Cookie{
+ Name: CookieName,
+ Value: sign(m.deriveKey(purposeSession), payload, ""),
+ Path: "/",
+ HttpOnly: true,
+ Secure: true,
+ SameSite: http.SameSiteLaxMode,
+ Expires: time.Unix(exp, 0),
+ }
+}
+
+// Verify reads and checks the session cookie on r, returning the
+// authenticated username, or "" if there is none, it's malformed, it's
+// signed for a different purpose, or it has expired.
+func (m *Manager) Verify(r *http.Request) string {
+ if m == nil {
+ return ""
+ }
+ c, err := r.Cookie(CookieName)
+ if err != nil {
+ return ""
+ }
+ payload, ok := verify(m.deriveKey(purposeSession), c.Value, "")
+ if !ok {
+ return ""
+ }
+ user, expStr, ok := strings.Cut(payload, "|")
+ if !ok {
+ return ""
+ }
+ exp, err := strconv.ParseInt(expStr, 10, 64)
+ if err != nil || time.Now().Unix() > exp {
+ return ""
+ }
+ return user
+}
+
+// Clear returns a cookie that immediately expires the session.
+func (m *Manager) Clear() *http.Cookie {
+ return &http.Cookie{
+ Name: CookieName,
+ Value: "",
+ Path: "/",
+ HttpOnly: true,
+ Secure: true,
+ SameSite: http.SameSiteLaxMode,
+ MaxAge: -1,
+ }
+}
+
+// ShareClaims is the payload embedded in both a share-URL token and the
+// gsg_share cookie minted on redemption. Slugs is the set of repo slugs the
+// grant unlocks; IAT/Exp are unix seconds. Pwd records only whether the
+// URL token requires a password -- it carries no password material itself.
+type ShareClaims struct {
+ V int `json:"v"`
+ Slugs []string `json:"slugs"`
+ IAT int64 `json:"iat"`
+ Exp int64 `json:"exp"`
+ Pwd bool `json:"pwd"`
+}
+
+func (c ShareClaims) expired() bool { return c.Exp != 0 && time.Now().Unix() > c.Exp }
+
+// IssuedAt is c.IAT as a time.Time, for comparison against a repo's
+// gsg.share-revoked-after.
+func (c ShareClaims) IssuedAt() time.Time { return time.Unix(c.IAT, 0) }
+
+// NewShareToken signs a share-URL token. password is "" for an unprotected
+// link; when set, it is folded into the signature, never stored in the
+// token -- see the package doc.
+func (m *Manager) NewShareToken(slugs []string, ttl time.Duration, password string) (string, ShareClaims) {
+ now := time.Now()
+ claims := ShareClaims{V: 1, Slugs: slugs, IAT: now.Unix(), Exp: now.Add(ttl).Unix(), Pwd: password != ""}
+ b, _ := json.Marshal(claims) // ShareClaims always marshals; no error path
+ return sign(m.deriveKey(purposeShareURL), string(b), password), claims
+}
+
+// PeekShareToken decodes a share-URL token's claims WITHOUT verifying its
+// signature. It exists only so the redemption page can decide whether to
+// show a password prompt before it knows the password; the result must
+// never be used to grant access -- only VerifyShareToken's result may do
+// that.
+func PeekShareToken(tok string) (ShareClaims, bool) {
+ encPayload, _, found := strings.Cut(tok, ".")
+ if !found {
+ return ShareClaims{}, false
+ }
+ b, err := dec(encPayload)
+ if err != nil {
+ return ShareClaims{}, false
+ }
+ var c ShareClaims
+ if err := json.Unmarshal(b, &c); err != nil || c.V != 1 {
+ return ShareClaims{}, false
+ }
+ return c, true
+}
+
+// VerifyShareToken checks a share-URL token against the password the
+// visitor supplied (pass "" for a token that doesn't require one) and
+// returns its claims. Wrong password, tampering, or expiry all fail the
+// same way -- there is nothing here for an attacker to distinguish.
+func (m *Manager) VerifyShareToken(tok, password string) (ShareClaims, bool) {
+ payload, ok := verify(m.deriveKey(purposeShareURL), tok, password)
+ if !ok {
+ return ShareClaims{}, false
+ }
+ var c ShareClaims
+ if err := json.Unmarshal([]byte(payload), &c); err != nil || c.V != 1 || c.expired() {
+ return ShareClaims{}, false
+ }
+ return c, true
+}
+
+// IssueShareCookie mints the gsg_share cookie from already-verified claims.
+// IAT and Exp are copied verbatim from the URL token, not restamped to
+// "now": the revocation check compares a repo's gsg.share-revoked-after
+// against IAT, and resetting it here would let an already-redeemed browser
+// outlive a revocation aimed at it.
+func (m *Manager) IssueShareCookie(claims ShareClaims) *http.Cookie {
+ b, _ := json.Marshal(claims)
+ return &http.Cookie{
+ Name: ShareCookieName,
+ Value: sign(m.deriveKey(purposeShareCookie), string(b), ""),
+ Path: "/",
+ HttpOnly: true,
+ Secure: true,
+ SameSite: http.SameSiteLaxMode,
+ Expires: time.Unix(claims.Exp, 0),
+ }
+}
+
+// VerifyShareCookie reads and checks the gsg_share cookie on r.
+func (m *Manager) VerifyShareCookie(r *http.Request) (ShareClaims, bool) {
+ if m == nil {
+ return ShareClaims{}, false
+ }
+ c, err := r.Cookie(ShareCookieName)
+ if err != nil {
+ return ShareClaims{}, false
+ }
+ payload, ok := verify(m.deriveKey(purposeShareCookie), c.Value, "")
+ if !ok {
+ return ShareClaims{}, false
+ }
+ var claims ShareClaims
+ if err := json.Unmarshal([]byte(payload), &claims); err != nil || claims.V != 1 || claims.expired() {
+ return ShareClaims{}, false
+ }
+ return claims, true
+}
+
+// sign produces "base64url(payload).base64url(hmac-sha256(payload||0||extra))".
+// Compact and self-contained like a JWT, without pulling in a JWT library or
+// its algorithm-confusion surface (alg:none, HS/RS key confusion) -- the key
+// and the hash are fixed by this package, not negotiable per token. extra is
+// "" for every purpose except a password-protected share-URL token, where
+// it's the password: folding it into the signature input (rather than
+// storing a hash of it in the payload) means nobody holding just the token
+// can crack it offline, and a guess can only be checked by asking the
+// server, which can rate-limit attempts.
+func sign(key []byte, payload, extra string) string {
+ mac := hmac.New(sha256.New, key)
+ mac.Write([]byte(payload))
+ mac.Write([]byte{0})
+ mac.Write([]byte(extra))
+ return enc(payload) + "." + enc(string(mac.Sum(nil)))
+}
+
+// verify checks tok against key and extra and, on success, returns the
+// payload.
+func verify(key []byte, tok, extra string) (payload string, ok bool) {
+ encPayload, encSig, found := strings.Cut(tok, ".")
+ if !found {
+ return "", false
+ }
+ payloadBytes, err := dec(encPayload)
+ if err != nil {
+ return "", false
+ }
+ sig, err := dec(encSig)
+ if err != nil {
+ return "", false
+ }
+ mac := hmac.New(sha256.New, key)
+ mac.Write(payloadBytes)
+ mac.Write([]byte{0})
+ mac.Write([]byte(extra))
+ if subtle.ConstantTimeCompare(sig, mac.Sum(nil)) != 1 {
+ return "", false
+ }
+ return string(payloadBytes), true
+}
+
+func enc(s string) string { return base64.RawURLEncoding.EncodeToString([]byte(s)) }
+
+func dec(s string) ([]byte, error) {
+ b, err := base64.RawURLEncoding.DecodeString(s)
+ if err != nil {
+ return nil, fmt.Errorf("session: bad encoding: %w", err)
+ }
+ return b, nil
+}
@@ -0,0 +1,64 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Share · {{ .Site }}</title>
+<link rel="stylesheet" href="/static/css/base.css">
+<link rel="stylesheet" href="/static/css/share.css">
+</head>
+<body>
+<main class="wrap">
+ <header class="hero">
+ <h1 class="page-title">Share a hidden repository</h1>
+ <p class="page-subtitle">Anyone with the link can view exactly the repositories you select below — no login required.</p>
+ </header>
+
+ {{ if .Result }}
+ <div class="notice notice-fix">
+ <strong class="notice-title">Link ready</strong>
+ <p class="share-link"><a href="{{ .Result }}">{{ .Result }}</a></p>
+ <p class="share-hint">Send this URL to whoever needs it. If you set a password, tell them separately — it isn't part of the link.</p>
+ </div>
+ <p><a href="/share/new">Create another</a></p>
+ {{ else }}
+ {{ if .Error }}
+ <div class="notice notice-critical">
+ <strong class="notice-title">Not created</strong>
+ <p>{{ .Error }}</p>
+ </div>
+ {{ end }}
+
+ {{ if not .Repos }}
+ <p>You don't have any hidden repositories to share.</p>
+ {{ else }}
+ <form method="post" action="/share/new" class="share-form">
+ <fieldset class="share-repos">
+ <legend>Repositories</legend>
+ {{ $sel := .Selected }}
+ {{ range .Repos }}
+ <label class="share-repo">
+ <input type="checkbox" name="repo" value="{{ .Slug }}"{{ if index $sel .Slug }} checked{{ end }}>
+ <span class="share-repo-title">{{ .Title }}</span>
+ <span class="share-maxage">up to {{ humanDuration .MaxAge }}</span>
+ </label>
+ {{ end }}
+ </fieldset>
+
+ <label class="share-field">
+ <span>Password (optional)</span>
+ <input type="password" name="password" autocomplete="new-password">
+ </label>
+
+ <label class="share-field">
+ <span>Expires in (optional — e.g. "24h", "168h")</span>
+ <input type="text" name="ttl" placeholder="defaults to the shortest selected repo's ceiling">
+ </label>
+
+ <button class="chip admin-save" type="submit">Create link</button>
+ </form>
+ {{ end }}
+ {{ end }}
+</main>
+</body>
+</html>
@@ -0,0 +1,388 @@
+// Package share implements anonymous, stateless share links: a logged-in
+// user picks a set of their hidden repos and mints a signed URL that grants
+// view access to exactly those repos, no login required, optionally gated
+// by a password. Everything needed to verify a link travels inside it --
+// there is no server-side record of what's been shared, only the one
+// revocation lever gsg.share-revoked-after provides (see internal/core).
+//
+// This package is deliberately decoupled from gsg/internal/core, the same
+// way internal/notices is: HandlerConfig takes plain data and callbacks,
+// not core.Site or core.RepoInfo, so main.go is the only place that bridges
+// the two.
+package share
+
+import (
+ "embed"
+ "fmt"
+ "html/template"
+ "log/slog"
+ "net/http"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "gsg/internal/access"
+ "gsg/internal/httpx"
+ "gsg/internal/session"
+)
+
+//go:embed new.html view.html
+var templatesFS embed.FS
+
+// maxSlugs caps how many repos a single share token may name. The token
+// travels as a URL, and an unbounded slug list eventually exceeds practical
+// length limits in browsers, mail clients, and proxies (roughly 2000
+// characters).
+const maxSlugs = 64
+
+const (
+ rateLimitWindow = time.Minute
+ rateLimitMax = 10
+)
+
+// RepoOption is one hidden repo a user could choose to share.
+type RepoOption struct {
+ Slug string
+ Title string
+ MaxAge time.Duration // gsg.share-max-age: the ceiling/default for this repo
+}
+
+// HandlerConfig is everything the share surface needs that it cannot
+// discover for itself.
+type HandlerConfig struct {
+ Sessions *session.Manager
+
+ // VisibleHiddenRepos returns the hidden repos user is currently allowed
+ // to see (and therefore may share). Used both to populate the mint form
+ // and to re-filter a mint submission -- a submitted slug is never
+ // trusted just because the form included it.
+ VisibleHiddenRepos func(user string) []RepoOption
+
+ // RevokedAfter returns a repo's gsg.share-revoked-after (zero if unset)
+ // and whether the repo still exists at all, for filtering a token's
+ // slugs at redemption time.
+ RevokedAfter func(slug string) (revokedAfter time.Time, exists bool)
+
+ SiteName string
+ Logger *slog.Logger
+}
+
+type handler struct {
+ cfg HandlerConfig
+ newTmpl *template.Template
+ viewTmpl *template.Template
+ limiter *rateLimiter
+}
+
+// NewHandler builds the share mux: minting (requires a gsg_user session)
+// and redemption (does not). Both belong on the public listener (see
+// main.go) and are hand-mounted directly on its mux rather than expressed
+// as a core.Route -- core.Route's Renderer always builds a body from a Ctx,
+// and a mint result page or a redirect-plus-Set-Cookie don't fit that
+// shape.
+func NewHandler(cfg HandlerConfig) (http.Handler, error) {
+ if cfg.Sessions == nil {
+ return nil, fmt.Errorf("share: no session manager")
+ }
+ if cfg.VisibleHiddenRepos == nil || cfg.RevokedAfter == nil {
+ return nil, fmt.Errorf("share: missing required callback")
+ }
+ if cfg.Logger == nil {
+ cfg.Logger = slog.Default()
+ }
+
+ funcs := template.FuncMap{"humanDuration": humanDuration}
+ newTmpl, err := template.New("new.html").Funcs(funcs).ParseFS(templatesFS, "new.html")
+ if err != nil {
+ return nil, fmt.Errorf("share: new.html: %w", err)
+ }
+ viewTmpl, err := template.New("view.html").Funcs(funcs).ParseFS(templatesFS, "view.html")
+ if err != nil {
+ return nil, fmt.Errorf("share: view.html: %w", err)
+ }
+
+ h := &handler{cfg: cfg, newTmpl: newTmpl, viewTmpl: viewTmpl, limiter: newRateLimiter()}
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /share/new", h.newForm)
+ mux.HandleFunc("POST /share/new", h.newSubmit)
+ mux.HandleFunc("GET /share/view/{token}", h.view)
+ mux.HandleFunc("POST /share/view/{token}", h.redeem)
+ return mux, nil
+}
+
+type formView struct {
+ Site string
+ Error string
+ Result string
+ Repos []RepoOption
+ Selected map[string]bool
+}
+
+func (h *handler) newForm(w http.ResponseWriter, r *http.Request) {
+ user := h.cfg.Sessions.Verify(r)
+ if user == "" {
+ http.Redirect(w, r, "/auth/login?next=/share/new", http.StatusSeeOther)
+ return
+ }
+ h.renderForm(w, http.StatusOK, user, "", nil)
+}
+
+func (h *handler) renderForm(w http.ResponseWriter, status int, user, errMsg string, selected map[string]bool) {
+ repos := h.cfg.VisibleHiddenRepos(user)
+ sort.Slice(repos, func(i, j int) bool { return repos[i].Slug < repos[j].Slug })
+
+ httpx.Secure(w, httpx.SecureOptions{NoStore: true})
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ if err := h.newTmpl.Execute(w, formView{
+ Site: h.cfg.SiteName, Error: errMsg, Repos: repos, Selected: selected,
+ }); err != nil {
+ h.cfg.Logger.Error("share new render", "err", err)
+ }
+}
+
+func (h *handler) newSubmit(w http.ResponseWriter, r *http.Request) {
+ user := h.cfg.Sessions.Verify(r)
+ if user == "" {
+ http.Error(w, "not logged in", http.StatusUnauthorized)
+ return
+ }
+ // Same-origin check on the POST: minting persists nothing server-side,
+ // but it's still an authenticated action worth CSRF-class protection --
+ // a cross-site page shouldn't be able to make a logged-in visitor's
+ // browser silently mint share links.
+ if !httpx.SameOrigin(r) {
+ h.cfg.Logger.Warn("share new: origin rejected",
+ "host", r.Host, "origin", r.Header.Get("Origin"),
+ "sec-fetch-site", r.Header.Get("Sec-Fetch-Site"))
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, 1<<16)
+ if err := r.ParseForm(); err != nil {
+ h.renderForm(w, http.StatusBadRequest, user, "bad form submission", nil)
+ return
+ }
+
+ allowed := h.cfg.VisibleHiddenRepos(user)
+ byslug := make(map[string]RepoOption, len(allowed))
+ for _, o := range allowed {
+ byslug[o.Slug] = o
+ }
+
+ submitted := r.PostForm["repo"]
+ selected := make(map[string]bool, len(submitted))
+ for _, s := range submitted {
+ selected[s] = true
+ }
+
+ // Never trust the submitted slug list: only repos byslug (this user's
+ // own current visibility) can end up in the token, however the form
+ // was tampered with.
+ var slugs []string
+ var ceiling time.Duration
+ for _, s := range submitted {
+ o, ok := byslug[s]
+ if !ok {
+ continue
+ }
+ slugs = append(slugs, o.Slug)
+ if ceiling == 0 || o.MaxAge < ceiling {
+ ceiling = o.MaxAge
+ }
+ }
+ if len(slugs) == 0 {
+ h.renderForm(w, http.StatusBadRequest, user, "select at least one repository", selected)
+ return
+ }
+ if len(slugs) > maxSlugs {
+ h.renderForm(w, http.StatusBadRequest, user,
+ fmt.Sprintf("a single link can name at most %d repositories", maxSlugs), selected)
+ return
+ }
+ sort.Strings(slugs)
+
+ // The user may always ask for less than the ceiling, never more; an
+ // unparsable or too-long value falls back to the ceiling rather than
+ // rejecting the submission outright, since the ceiling is always a
+ // valid choice.
+ ttl := ceiling
+ if v := strings.TrimSpace(r.PostForm.Get("ttl")); v != "" {
+ if d, err := time.ParseDuration(v); err == nil && d > 0 && d <= ceiling {
+ ttl = d
+ }
+ }
+
+ password := r.PostForm.Get("password")
+ tok, claims := h.cfg.Sessions.NewShareToken(slugs, ttl, password)
+
+ h.cfg.Logger.Info("share link minted",
+ "user", user, "repos", len(slugs), "password", password != "",
+ "exp", time.Unix(claims.Exp, 0).Format(time.RFC3339))
+
+ // The result page's body is a bearer capability (the share URL itself),
+ // so unlike the rest of the site it must never be stored anywhere.
+ httpx.Secure(w, httpx.SecureOptions{NoStore: true})
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := h.newTmpl.Execute(w, formView{
+ Site: h.cfg.SiteName, Result: httpx.AbsoluteURL(r, "/share/view/"+tok),
+ }); err != nil {
+ h.cfg.Logger.Error("share new render", "err", err)
+ }
+}
+
+type viewData struct {
+ Site string
+ Error string
+ Action string
+}
+
+// view is the landing page for a share link. A token that requires no
+// password redeems immediately; one that does gets a password prompt
+// first. PeekShareToken is unverified and only decides which of those two
+// to show -- redeem always re-derives the real answer via VerifyShareToken.
+func (h *handler) view(w http.ResponseWriter, r *http.Request) {
+ tok := r.PathValue("token")
+ claims, ok := session.PeekShareToken(tok)
+ if !ok {
+ http.Error(w, "invalid share link", http.StatusNotFound)
+ return
+ }
+ if !claims.Pwd {
+ h.redeem(w, r)
+ return
+ }
+ h.renderPassword(w, http.StatusOK, tok, "")
+}
+
+func (h *handler) renderPassword(w http.ResponseWriter, status int, tok, errMsg string) {
+ httpx.Secure(w, httpx.SecureOptions{NoStore: true})
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ if err := h.viewTmpl.Execute(w, viewData{
+ Site: h.cfg.SiteName, Error: errMsg, Action: "/share/view/" + tok,
+ }); err != nil {
+ h.cfg.Logger.Error("share view render", "err", err)
+ }
+}
+
+// redeem verifies the token (with whatever password was submitted, "" if
+// none), filters its slugs against live revocation state, and mints the
+// gsg_share cookie. It also serves the no-password GET path via view above,
+// where the submitted password is always "".
+func (h *handler) redeem(w http.ResponseWriter, r *http.Request) {
+ tok := r.PathValue("token")
+ r.Body = http.MaxBytesReader(w, r.Body, 1<<16)
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "bad form submission", http.StatusBadRequest)
+ return
+ }
+ password := r.PostForm.Get("password")
+
+ // Only rate-limit actual guesses against a password-protected link --
+ // there is nothing to guess on an unprotected one. Keyed on the source
+ // IP alone, not IP+token: PeekShareToken doesn't verify a signature, so
+ // keying on the token string would let an unauthenticated caller mint
+ // unbounded distinct keys (one per invented token) for free. IP-only
+ // also has the right semantics anyway -- one source gets a fixed
+ // budget of guesses per window, not a fixed budget per link it tries.
+ if peek, ok := session.PeekShareToken(tok); ok && peek.Pwd {
+ if !h.limiter.allow(httpx.RemoteIP(r)) {
+ http.Error(w, "too many attempts, try again later", http.StatusTooManyRequests)
+ return
+ }
+ }
+
+ claims, ok := h.cfg.Sessions.VerifyShareToken(tok, password)
+ if !ok {
+ if peek, peekOK := session.PeekShareToken(tok); peekOK && peek.Pwd {
+ h.renderPassword(w, http.StatusUnauthorized, tok, "incorrect password")
+ return
+ }
+ http.Error(w, "invalid or expired share link", http.StatusNotFound)
+ return
+ }
+
+ // Narrow to what's still live: a repo that no longer exists, or whose
+ // gsg.share-revoked-after postdates this grant, drops out here. This is
+ // a courtesy at redemption time -- the authoritative check runs again
+ // on every subsequent request in core.Server.buildCtx, since revocation
+ // must reach a browser that already holds the cookie, not just block
+ // new redemptions.
+ kept := make([]string, 0, len(claims.Slugs))
+ for _, slug := range claims.Slugs {
+ revokedAfter, exists := h.cfg.RevokedAfter(slug)
+ if !exists {
+ continue
+ }
+ if !access.ShareLive(revokedAfter, claims.IssuedAt()) {
+ continue
+ }
+ kept = append(kept, slug)
+ }
+ if len(kept) == 0 {
+ http.Error(w, "this share link no longer grants access to any repository", http.StatusGone)
+ return
+ }
+ claims.Slugs = kept
+
+ http.SetCookie(w, h.cfg.Sessions.IssueShareCookie(claims))
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+// rateLimiter is a small in-memory, per-process fixed-window limiter --
+// the same posture gsg already accepts elsewhere (see reposCache in
+// internal/modules/repos.go): it resets on restart, and, as a known
+// simplification, never evicts a key once created. Acceptable for guarding
+// a share link's password on a self-hosted, single-instance tool.
+type rateLimiter struct {
+ mu sync.Mutex
+ hits map[string][]time.Time
+}
+
+func newRateLimiter() *rateLimiter { return &rateLimiter{hits: map[string][]time.Time{}} }
+
+func (l *rateLimiter) allow(key string) bool {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ now := time.Now()
+ cutoff := now.Add(-rateLimitWindow)
+
+ // Compact in place: safe because append never writes past the range
+ // index it's reading from.
+ kept := l.hits[key][:0]
+ for _, t := range l.hits[key] {
+ if t.After(cutoff) {
+ kept = append(kept, t)
+ }
+ }
+ if len(kept) >= rateLimitMax {
+ l.hits[key] = kept
+ return false
+ }
+ l.hits[key] = append(kept, now)
+ return true
+}
+
+func humanDuration(d time.Duration) string {
+ switch {
+ case d <= 0:
+ return "0"
+ case d%(24*time.Hour) == 0:
+ days := d / (24 * time.Hour)
+ if days == 1 {
+ return "1 day"
+ }
+ return fmt.Sprintf("%d days", days)
+ case d%time.Hour == 0:
+ hours := d / time.Hour
+ if hours == 1 {
+ return "1 hour"
+ }
+ return fmt.Sprintf("%d hours", hours)
+ default:
+ return d.String()
+ }
+}
@@ -0,0 +1,30 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Share link · {{ .Site }}</title>
+<link rel="stylesheet" href="/static/css/base.css">
+<link rel="stylesheet" href="/static/css/share.css">
+</head>
+<body>
+<main class="wrap">
+ <header class="hero">
+ <h1 class="page-title">Password required</h1>
+ <p class="page-subtitle">This share link is password-protected.</p>
+ </header>
+
+ {{ if .Error }}
+ <div class="notice notice-critical"><p>{{ .Error }}</p></div>
+ {{ end }}
+
+ <form method="post" action="{{ .Action }}" class="share-form">
+ <label class="share-field">
+ <span>Password</span>
+ <input type="password" name="password" autofocus>
+ </label>
+ <button class="chip admin-save" type="submit">View</button>
+ </form>
+</main>
+</body>
+</html>
@@ -0,0 +1,28 @@
+package site
+
+import (
+ "net/http"
+ "strings"
+
+ "gsg/internal/core"
+)
+
+// CompareRequest parses {range} into the base and head refs. The refs are
+// still resolved by core through gitx.Repo.Resolve; this only splits text.
+func CompareRequest(r *http.Request) (core.Request, error) {
+ rng := r.PathValue("range")
+ // Check three dots first: "a...b" also contains "..".
+ if base, head, ok := strings.Cut(rng, "..."); ok {
+ return core.Request{
+ Ref: head,
+ Range: &core.Range{Base: base, ThreeDot: true},
+ }, nil
+ }
+ if base, head, ok := strings.Cut(rng, ".."); ok {
+ return core.Request{
+ Ref: head,
+ Range: &core.Range{Base: base},
+ }, nil
+ }
+ return core.Request{}, core.Errorf(http.StatusBadRequest, "compare needs base..head")
+}
@@ -0,0 +1,71 @@
+package site
+
+import (
+ "net/http/httptest"
+ "testing"
+)
+
+func TestCompareRequest(t *testing.T) {
+ cases := []struct {
+ name string
+ rangeVal string
+ wantRef string
+ wantBase string
+ threeDot bool
+ wantErr bool
+ }{
+ {name: "two dot", rangeVal: "main..feature", wantRef: "feature", wantBase: "main"},
+ {name: "three dot", rangeVal: "main...feature", wantRef: "feature", wantBase: "main", threeDot: true},
+ {name: "refs with dots", rangeVal: "v1.0..v2.0", wantRef: "v2.0", wantBase: "v1.0"},
+ {name: "missing separator", rangeVal: "main", wantErr: true},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ req := httptest.NewRequest("GET", "/demo/compare/"+tc.rangeVal, nil)
+ req.SetPathValue("range", tc.rangeVal)
+ got, err := CompareRequest(req)
+ if tc.wantErr {
+ if err == nil {
+ t.Fatal("expected an error")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("CompareRequest: %v", err)
+ }
+ if got.Ref != tc.wantRef || got.Range == nil || got.Range.Base != tc.wantBase || got.Range.ThreeDot != tc.threeDot {
+ t.Fatalf("CompareRequest = %+v, want ref=%q base=%q threeDot=%v", got, tc.wantRef, tc.wantBase, tc.threeDot)
+ }
+ })
+ }
+}
+
+// The compare route must use the parser; the paginated routes must declare
+// their page size. Otherwise buildCtx silently falls back to defaults.
+func TestRoutesDeclareParsingMetadata(t *testing.T) {
+ routes := Routes()
+ byName := make(map[string]struct {
+ request bool
+ pageSize int
+ })
+ for _, rt := range routes {
+ byName[rt.Name] = struct {
+ request bool
+ pageSize int
+ }{
+ request: rt.Request != nil,
+ pageSize: rt.PageSize,
+ }
+ }
+
+ if !byName["compare"].request {
+ t.Error("compare route has no Request parser")
+ }
+ if byName["log"].pageSize == 0 {
+ t.Error("log route has no PageSize")
+ }
+ if byName["graph"].pageSize == 0 {
+ t.Error("graph route has no PageSize")
+ }
+}
@@ -10,7 +10,7 @@ import (
func Routes() []core.Route {
return []core.Route{
// ---- site-scoped -------------------------------------------------
- {Name: "index", Scope: core.ScopeSite,
+ {Name: "index", Scope: core.ScopeSite, CacheableAnon: true,
Pattern: "GET /{$}",
Render: core.View{Template: "index.html"}},
@@ -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,
@@ -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.
@@ -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 {
@@ -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;
@@ -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;
+}
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ block "title" . }}{{ with .Info.Title }}{{ . }} · {{ end }}{{ .Site.Name }}{{ end }}</title>
-<link rel="stylesheet" href="/static/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;">•</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 {{ . }} · <a href="/auth/logout">log out</a></span>
+ {{ end }}
+ </div>
</div>
</nav>
{{ end }}
@@ -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 }}
@@ -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 }}
@@ -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" }}
@@ -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
@@ -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" }}
@@ -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 }}