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

Compare

caching_trials … main · 4 commits

merge base 8d1bb5bfdd2c…

Changed files

Commits

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