aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLena <lena@omega>2026-08-01 00:00:00 +0000
committerLena <lena@omega>2026-08-01 00:00:00 +0000
commitbb6bc2387d58c737dbf92e73f8544bf39319dbd8 (patch)
tree6d5eaf5dac213074cd832da50f64260fce728fed
downloadgit-forge-master.tar.gz
Enter git-forgeHEADmaster
A small self-hosted git forge: HTTP push and pull, cgit browsing, local and OIDC accounts, TOTP, scoped push tokens, per-repo permissions, issues, a wiki, and outbound mail. It does not reimplement git. git-http-backend and cgit run as CGI behind its own listener, which authorises every request before exec'ing either. One binary, one data directory, one listener: no database, no JavaScript, and state is JSON in a git repo with a working tree.
-rw-r--r--.dockerignore4
-rw-r--r--.gitignore3
-rw-r--r--Containerfile30
-rw-r--r--LICENSE26
-rw-r--r--README211
-rw-r--r--access_test.go364
-rw-r--r--account.go695
-rw-r--r--admin.go720
-rw-r--r--admin_test.go692
-rw-r--r--auth.go510
-rw-r--r--auth_test.go395
-rw-r--r--avatar.go45
-rw-r--r--cgit.go304
-rw-r--r--cgit_test.go151
-rw-r--r--config.go263
-rw-r--r--config_test.go120
-rw-r--r--filters.go73
-rw-r--r--fuzz_test.go122
-rwxr-xr-xgit-forge.initd22
-rw-r--r--git.go296
-rw-r--r--go.mod10
-rw-r--r--go.sum6
-rw-r--r--httpgit.go175
-rw-r--r--httpgit_test.go457
-rw-r--r--issues.go555
-rw-r--r--issues_test.go345
-rw-r--r--mail.go280
-rw-r--r--mail_test.go331
-rw-r--r--main.go733
-rw-r--r--oidc.go598
-rw-r--r--oidc_test.go474
-rw-r--r--perm.go202
-rw-r--r--perm_test.go132
-rw-r--r--static/forge.css135
-rw-r--r--store.go761
-rw-r--r--store_test.go195
-rw-r--r--templates/admin.html236
-rw-r--r--templates/base.html23
-rw-r--r--templates/error.html5
-rw-r--r--templates/home.html16
-rw-r--r--templates/issue-new.html27
-rw-r--r--templates/issue.html62
-rw-r--r--templates/issues.html34
-rw-r--r--templates/login.html33
-rw-r--r--templates/nav.html112
-rw-r--r--templates/repo-settings.html122
-rw-r--r--templates/reset.html23
-rw-r--r--templates/set-password.html20
-rw-r--r--templates/settings.html126
-rw-r--r--templates/setup.html33
-rw-r--r--templates/totp-enroll.html96
-rw-r--r--templates/totp.html16
-rw-r--r--templates/wiki-edit.html27
-rw-r--r--templates/wiki.html37
-rwxr-xr-xtest320
-rw-r--r--totp_test.go517
-rw-r--r--wiki.go303
-rw-r--r--wiki_test.go175
58 files changed, 12798 insertions, 0 deletions
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..f8d5960
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,4 @@
+.git
+/datadir
+/git-forge
+.env
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..6af6f97
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+/git-forge
+/datadir/
+.env
diff --git a/Containerfile b/Containerfile
new file mode 100644
index 0000000..1cb79e7
--- /dev/null
+++ b/Containerfile
@@ -0,0 +1,30 @@
+# git-forge: one binary, one data directory, one listener. Strictly Alpine,
+# both stages, so there is one distribution to reason about.
+
+FROM alpine:3.23 AS build
+RUN apk add --no-cache go
+WORKDIR /src
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+# Static, so the runtime image needs neither Go nor libc.
+RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /git-forge .
+
+FROM alpine:3.23
+# git-daemon is not the daemon here: in Alpine that subpackage is where
+# git-http-backend lives, and without it every clone and push answers 500.
+# tini reaps what git leaves: a detached gc is reparented to PID 1 and would
+# otherwise stay a zombie.
+RUN apk add --no-cache git git-daemon cgit highlight ca-certificates tini
+COPY --from=build /git-forge /usr/local/bin/git-forge
+
+# Non-root, and the datadir is credential material, so 0700 and one owner.
+RUN adduser -D -H -u 1000 forge \
+ && install -d -o forge -g forge -m 0700 /var/lib/git-forge
+USER forge
+ENV HOME=/tmp
+
+EXPOSE 8080
+VOLUME /var/lib/git-forge
+HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
+ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/git-forge"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..0f64eed
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,26 @@
+Copyright (c) 2026 Lena
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its contributors
+ may be used to endorse or promote products derived from this software
+ without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README b/README
new file mode 100644
index 0000000..14d3158
--- /dev/null
+++ b/README
@@ -0,0 +1,211 @@
+git-forge
+=========
+A small self-hosted git forge: HTTP push and pull, cgit browsing with syntax
+highlighting, local and OIDC accounts, TOTP, scoped push tokens, per-repo
+permissions, issues, a wiki, and outbound mail. One binary, one data
+directory, one listener.
+
+git-forge does not reimplement git. It runs `git-http-backend` and `cgit` as
+CGI behind its own listener, authorises every request before exec'ing either,
+and supplies only what those two lack.
+
+Out, deliberately: pull requests, patch review, CI, hooks, forks, repository
+rename, inbound mail, SSH, attachments, milestones, assignees, and closing an
+issue from a commit message. Also out: JavaScript, a database, a reverse
+proxy, and a process supervisor.
+
+Limits: one process per datadir, `git grep` is the issue search, and a few
+thousand issues per repository is the ceiling. One push is bounded at 1 GiB
+of code and 16 MiB of wiki; the total is not. Eight cgit and eight
+git-http-backend children run at once and the rest queue.
+
+
+Build
+-----
+Go 1.25 or later:
+ go build
+
+`CGO_ENABLED=0` makes the binary static, which is what `Containerfile` does.
+
+Gating checks:
+ gofmt -l . must print nothing
+ go vet ./... must be clean
+ go mod tidy -diff must print nothing
+ go test ./... unit and integration tests, needs git; some skip
+ without cgit
+ ./test builds the image and drives a container end to end
+
+`./test` needs docker, git, curl, lynx, openssl, python3, and timeout. Checked
+by hand: OIDC against a real provider, and Mailgun against a real domain.
+
+Running outside the container needs `git`, `cgit`, and `highlight`. On Alpine
+`git-http-backend` is in the `git-daemon` package; without it every clone and
+push answers 500. `GIT_FORGE_CGIT` names `cgit.cgi` when it is not at
+`/usr/share/webapps/cgit/cgit.cgi`.
+
+
+Run
+---
+ ./git-forge -datadir /var/lib/git-forge
+
+The datadir comes from the flag, else `GIT_FORGE_DATADIR`, else
+`/var/lib/git-forge`, and never from the config. Everything missing is
+created on first start.
+
+There is no TLS. A push token is sent as Basic auth on every request, and the
+session cookie is `Secure` only when `base_url` is https. Keep the listener on
+localhost, or put something in front that terminates TLS and set `base_url` to
+the origin it serves.
+
+Administration is web only. With no accounts, git-forge prints a one time
+setup token to stdout and serves `/setup`; that form takes the token and
+creates the first admin. A restart before setup prints a new token, and once
+any account exists `/setup` answers 404.
+
+Bare metal on Alpine: `adduser -D -H forge`, install the binary and
+`git-forge.initd` as `/etc/init.d/git-forge`, and put secrets as `VAR=value`
+lines in `/etc/conf.d/git-forge`, mode 0600. The unit creates the datadir,
+runs as `forge`, and writes stdout to `/var/log/git-forge.log`.
+
+
+Configuration
+-------------
+`{datadir}/config/forge.json`, plus environment for secrets. The config has no
+field for a secret:
+ GIT_FORGE_COOKIE_KEY else generated into {datadir}/cookie.key
+ GIT_FORGE_MAIL_MAILGUN_API_KEY
+ GIT_FORGE_OIDC_<KEY>_CLIENT_SECRET
+
+Five fields have an environment override: `GIT_FORGE_LISTEN`,
+`GIT_FORGE_SITE_NAME`, `GIT_FORGE_SITE_DESC`, `GIT_FORGE_BASE_URL`, and
+`GIT_FORGE_CLIENT_IP_HEADER`. An override is an overlay: `/admin` rewrites
+the file without it.
+
+`site_name`, `site_desc`, `logo`, and `icon` are editable at `/admin`, which
+commits the change. `logo` and `icon` are image paths, relative to the config
+directory, served at `/logo` and `/favicon.ico`; empty means cgit's own.
+
+`mail.driver` is one of:
+ none the default. Nothing is sent; the log names each dropped
+ message by recipient and subject, never its body
+ log whole messages to the log, for development. A set-password
+ or reset link is a capability, and this driver logs it
+ mailgun one form-encoded POST to `mail.mailgun.base_url`, with the
+ key from the environment
+
+An OIDC provider takes an optional `allow`: addresses, or `@domain` for a
+whole domain. An empty list admits anyone the provider vouches for. It is
+checked at every login, so removing an entry revokes:
+
+ "allow": ["carol@example.com", "@example.com"]
+
+`client_ip_header` is empty by default. Behind a proxy every request shares
+one address, so every per address rate limit becomes one bucket; naming the
+header the proxy sets is the way out. Naming one trusts it: the proxy must
+overwrite it, and nothing may reach the listener around the proxy.
+
+ "client_ip_header": "CF-Connecting-IP"
+
+Rate limits are constants in the source. Startup validates `forge.json` and
+`repos.json` and exits nonzero on anything incoherent.
+
+
+Data directory
+--------------
+ config/ git repo with a working tree: forge.json, users.json,
+ repos.json, and an uploaded logo and icon
+ cookie.key generated secret, 0600, outside the config repo
+ mailq/ spooled outbound mail, transient
+ repos/ bare git repos, holding nothing git-forge wrote
+
+Back up with `tar` against a stopped service, or a filesystem snapshot. The
+datadir is credential material in full: `users.json` holds argon2id hashes,
+TOTP secrets, and recovery codes, and the config repo keeps their history.
+
+Exactly one process may serve a datadir: git-forge takes an exclusive `flock`
+on it and a second process refuses to start. Storage must be a real
+filesystem with atomic `rename()` and working lock files; FUSE-mounted object
+storage is not.
+
+
+Debugging
+---------
+One log line per request on stdout. Query strings are not logged, because
+reset links and OIDC codes arrive in them, and the path is quoted, so a
+newline in one cannot write its own line.
+
+The config repo is the audit trail:
+ cat {datadir}/config/repos.json
+ git -C {datadir}/config log -p repos.json when did bob get write
+ git -C {datadir}/config log --author=alice what has this admin done
+ git -C {datadir}/config revert HEAD undo a fat-fingered grant
+
+The working tree is the source of truth; a commit that fails is logged and
+the request still succeeds.
+
+Issues and wiki pages are paths inside a git tree:
+ git -C {datadir}/repos/infra/dns.issues.git show master:issues/7.json
+
+Repository state is derived from the tree: a directory ending in `.git` is a
+repository, anything else is a section one level deep. A directory with no
+entry in `repos.json` is served private with issues and wiki off, and shown
+with an adopt button at `/admin`; an entry with no directory is ignored. Both
+are logged at startup.
+
+A repository path is one or two components of lowercase letters, digits,
+dot, dash, and underscore. Neither component may be a name one of git-forge's
+own routes owns, and a second component may not be a cgit route either:
+`/infra/log` is the log of the repository `infra`.
+
+Every form refused with a 403 while everything else works means `base_url` is
+not the origin the browser uses: a form post whose `Origin` disagrees with it
+is refused.
+
+An image that does not load is one that is not this origin's. The security
+policy refuses remote images, so a badge in a README cannot tell a third party
+who read it. No script runs anywhere.
+
+Handing out access asks for the acting account's own authenticator code, at
+`/admin` and on a repository's settings alike, since what it hands out
+outlives the session. A code works once: a second action within the same
+thirty seconds waits for the next code. An admin without an authenticator is
+sent to enrol one. Changing the second factor asks for the password on a local
+account and the enrolled code on an external one.
+
+cgit is invoked per request with a generated config naming only the one
+repository. Those configs live in a temporary directory logged at startup and
+are not part of the backup.
+
+`git gc --auto` runs after a push and after every issue and wiki write, so a
+repository's on-disk shape changes when git's thresholds fire.
+
+
+Tokens
+------
+Git over HTTP takes a token, never a password: git cannot be asked for a
+second factor, and a token is separately scoped and separately revoked.
+
+A token is a credential, not a permission. Minting one at `/settings` asks
+only that the account can reach the repository; what it may do is decided on
+arrival, from the same grants the web uses.
+
+An anonymous git request for anything it may not read is answered 401,
+whether the repository exists or not, so the answer never says which private
+names are taken. An authenticated request without read answers 404, as the
+web does.
+
+
+Container
+---------
+ podman build -t git-forge .
+ podman run -p 8080:8080 -v git-forge:/var/lib/git-forge git-forge
+
+Alpine, non-root, volume at `/var/lib/git-forge`, tini as PID 1 to reap
+git's detached gc. `/healthz` answers 200 and SIGTERM drains and exits.
+
+
+License
+-------
+BSD 3-Clause, in `LICENSE`. cgit's stylesheet is served from the cgit package
+at runtime rather than copied into this tree: it is cgit's, under cgit's own
+licence.
diff --git a/access_test.go b/access_test.go
new file mode 100644
index 0000000..d7ec662
--- /dev/null
+++ b/access_test.go
@@ -0,0 +1,364 @@
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "log"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "strings"
+ "testing"
+)
+
+// The rules are unit tested on Viewer. This proves the wiring: that every
+// route actually asks, and asks the right question.
+func TestAccessThroughTheRouter(t *testing.T) {
+ f := testForge(t)
+ for _, name := range []string{"owner", "writer", "reader", "stranger"} {
+ addUser(t, f, name, "hunter2hunter2")
+ }
+ // owner administers both repositories, and changing a grant asks for a code,
+ // so this is the viewer that holds an authenticator.
+ addTOTP(t, f, "owner")
+ grants := map[string]string{"owner": grantAdmin, "writer": grantWrite, "reader": grantRead}
+ addRepo(t, f, "pub", Repo{
+ Desc: "public", Visibility: visPublic, Issues: featUsers, Wiki: featUsers,
+ Labels: []string{"bug"}, Grants: grants,
+ })
+ addRepo(t, f, "priv", Repo{
+ Desc: "private", Visibility: visPrivate, Issues: featMembers, Wiki: featMembers,
+ Labels: []string{"bug"}, Grants: grants,
+ })
+ if _, err := os.Stat(cgitBinary()); err != nil {
+ t.Skip("cgit is not installed, and some of these rows are cgit's")
+ }
+ h := f.routes()
+
+ // Signing in is deliberately expensive, so it happens once per viewer.
+ sessions := map[string][]*http.Cookie{"": nil}
+ sessions["owner"] = codeSession(t, f, h, "owner", "hunter2hunter2")
+ for _, name := range []string{"writer", "reader", "stranger"} {
+ sessions[name] = signIn(t, f, h, name)
+ }
+
+ // Something to read in each.
+ token := f.csrf(withCookies("/", sessions["owner"]))
+ for _, repo := range []string{"pub", "priv"} {
+ post(h, "/"+repo+"/issues", url.Values{"csrf": {token}, "title": {"one"}, "body": {"b"}}, sessions["owner"])
+ post(h, "/"+repo+"/wiki/", url.Values{
+ "csrf": {token}, "base": {""}, "body": {"# page"}, "action": {"save"}}, sessions["owner"])
+ }
+
+ const (
+ ok = http.StatusOK
+ gone = http.StatusNotFound
+ refused = http.StatusForbidden
+ redirect = http.StatusSeeOther
+ )
+ // What each viewer should get. Anonymous is the empty name, and a path
+ // holding %s gets the viewer's name, so one row's writes cannot decide the
+ // next row's answer.
+ cases := []struct {
+ method, path string
+ anon, stranger int
+ reader, writer, owner int
+ }{
+ {"GET", "/pub/issues", ok, ok, ok, ok, ok},
+ {"GET", "/pub/issues/1", ok, ok, ok, ok, ok},
+ {"GET", "/pub/wiki", ok, ok, ok, ok, ok},
+ // Public and readable, so refusing to administer it is a 403 and not a
+ // pretence that it is not there.
+ {"GET", "/pub/settings", refused, refused, refused, refused, ok},
+ {"GET", "/pub/", ok, ok, ok, ok, ok},
+ {"GET", "/priv/", gone, gone, ok, ok, ok},
+ {"GET", "/priv/issues", gone, gone, ok, ok, ok},
+ {"GET", "/priv/issues/1", gone, gone, ok, ok, ok},
+ {"GET", "/priv/wiki", gone, gone, ok, ok, ok},
+ {"GET", "/priv/settings", gone, gone, refused, refused, ok},
+ {"GET", "/priv.issues/", gone, gone, ok, ok, ok},
+ {"GET", "/priv.wiki/", gone, gone, ok, ok, ok},
+
+ // issues is users on pub, so any account may write, but not a visitor.
+ {"POST", "/pub/issues", refused, redirect, redirect, redirect, redirect},
+ // issues is members on priv, so a grant is needed.
+ {"POST", "/priv/issues", gone, gone, redirect, redirect, redirect},
+ {"POST", "/priv/issues/1/comment", gone, gone, redirect, redirect, redirect},
+ // closing is triage: the author, or whoever the repo trusts with code.
+ {"POST", "/priv/issues/1/state", gone, gone, refused, redirect, redirect},
+ {"POST", "/priv/issues/1/labels", gone, gone, refused, redirect, redirect},
+ // the wiki knob, not the write grant, governs the wiki.
+ {"POST", "/pub/wiki/%s", refused, redirect, redirect, redirect, redirect},
+ {"POST", "/priv/wiki/%s", gone, gone, redirect, redirect, redirect},
+ // A repository's own settings follow the admin grant. Deleting it is
+ // the global flag's alone, so even the repository's admin is refused
+ // before the typed confirmation is ever looked at.
+ {"POST", "/priv/settings/grant", gone, gone, refused, refused, redirect},
+ {"POST", "/priv/settings/delete", gone, gone, refused, refused, refused},
+ // the global admin pages are neither.
+ {"GET", "/admin", redirect, refused, refused, refused, refused},
+ }
+
+ for _, c := range cases {
+ for _, who := range []struct {
+ name string
+ want int
+ }{
+ {"", c.anon}, {"stranger", c.stranger}, {"reader", c.reader},
+ {"writer", c.writer}, {"owner", c.owner},
+ } {
+ label := who.name
+ if label == "" {
+ label = "anonymous"
+ }
+ session := sessions[who.name]
+ path := c.path
+ if strings.Contains(path, "%s") {
+ path = fmt.Sprintf(path, label)
+ }
+ if got := request(t, f, h, c.method, path, session); got != who.want {
+ t.Errorf("%s %s as %s: %d, wanted %d", c.method, path, label, got, who.want)
+ }
+ }
+ }
+}
+
+// request drives one route with a body good enough to get past validation, so
+// that what is being measured is the permission check and not the form.
+func request(t *testing.T, f *Forge, h http.Handler, method, path string, session []*http.Cookie) int {
+ t.Helper()
+ if method == http.MethodGet {
+ return get(h, path, session).Code
+ }
+ form := url.Values{"csrf": {f.csrf(withCookies("/", session))}}
+ form.Set("title", "t")
+ form.Set("body", "b")
+ form.Set("account", "reader")
+ form.Set("level", grantRead)
+ form.Set("label", "bug")
+ form.Set("confirm", "wrong-on-purpose") // deletion must refuse before it acts
+ form.Set("base", "")
+ form.Set("action", "save")
+ form.Set("code", freshCode(t, f, "owner", knownSecret)) // for the rows that confirm
+ return post(h, path, form, session).Code
+}
+
+func signIn(t *testing.T, f *Forge, h http.Handler, name string) []*http.Cookie {
+ t.Helper()
+ w := post(h, "/login", url.Values{"name": {name}, "password": {"hunter2hunter2"}}, nil)
+ if w.Code != http.StatusSeeOther {
+ t.Fatalf("signing %s in: status %d", name, w.Code)
+ }
+ return w.Result().Cookies()
+}
+
+// What neither CGI will take. A control byte cannot go in an environment
+// variable at all, and git-http-backend refuses a relative or empty component
+// with a 500 of its own. Percent encoded, these get past the mux's cleaning.
+func TestUnservablePathsAreRefused(t *testing.T) {
+ f := testForge(t)
+ addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featUsers})
+ if _, err := os.Stat(cgitBinary()); err != nil {
+ t.Skip("cgit is not installed, and the rows that used to answer 500 are cgit's")
+ }
+ h := f.routes()
+ for _, path := range []string{
+ "/notes/tree/%00",
+ "/notes/plain/a%00b",
+ "/notes.git/%00/info/refs",
+ "/notes.git/info/refs%00",
+ "/notes/tree/%0aforged",
+ "/notes/%7f",
+ "/notes.git/%2e%2e/%2e%2e/etc/passwd",
+ "/notes.git/%2e/info/refs",
+ "/notes.git%2f%2finfo/refs",
+ "/notes.git/%2f/info/refs",
+ "/notes/%2e%2e/log/",
+ } {
+ if code := get(h, path, nil).Code; code != http.StatusNotFound {
+ t.Errorf("%s answered %d", path, code)
+ }
+ }
+ // And what the guard must let through, including the trailing slash cgit
+ // emits and expects back.
+ for _, path := range []string{"/notes/issues", "/notes/", "/notes/log/", "/notes.git/info/refs?service=git-upload-pack"} {
+ if code := get(h, path, nil).Code; code != http.StatusOK {
+ t.Errorf("an ordinary path %s answered %d", path, code)
+ }
+ }
+}
+
+// The path arrives percent-decoded, so a newline in it would end the log line
+// and let anybody write the next one.
+func TestLogLineCannotBeForged(t *testing.T) {
+ f := testForge(t)
+ addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featDisabled})
+ h := f.routes()
+
+ var buf bytes.Buffer
+ old := log.Writer()
+ log.SetOutput(&buf)
+ defer log.SetOutput(old)
+
+ // A path that closes the real line and opens a plausible fake one.
+ get(h, "/notes/issues%0a2026-08-15%2000:00:00%20203.0.113.9%20POST%20/admin/user/delete%20303%200%201ms", nil)
+
+ out := strings.TrimRight(buf.String(), "\n")
+ if lines := strings.Split(out, "\n"); len(lines) != 1 {
+ t.Errorf("one request wrote %d log lines:\n%s", len(lines), out)
+ }
+ if !strings.Contains(out, `\n`) {
+ t.Errorf("the newline in the path was not escaped: %s", out)
+ }
+}
+
+// A private repository's page must not sit in a shared cache. cgit says the
+// opposite on its own pages, dating them five minutes ahead, so the policy has
+// to be stated here and not left to what the CGI happens to send.
+func TestNoPageIsStored(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addRepo(t, f, "priv", Repo{
+ Visibility: visPrivate, Issues: featMembers, Wiki: featMembers,
+ Grants: map[string]string{"alice": grantRead},
+ })
+ h := f.routes()
+ session := signIn(t, f, h, "alice")
+ for _, path := range []string{"/", "/settings", "/priv/issues", "/priv/wiki", "/priv/"} {
+ w := get(h, path, session)
+ if w.Code != http.StatusOK {
+ t.Fatalf("%s answered %d", path, w.Code)
+ }
+ if got := w.Header().Get("Cache-Control"); got != "no-store" {
+ t.Errorf("%s says Cache-Control: %q", path, got)
+ }
+ }
+ // And what is the same for everybody keeps its own.
+ for _, path := range []string{"/cgit.css", "/favicon.ico", "/avatar/alice"} {
+ if got := get(h, path, session).Header().Get("Cache-Control"); !strings.HasPrefix(got, "max-age=") {
+ t.Errorf("%s says Cache-Control: %q", path, got)
+ }
+ }
+}
+
+// Markdown renders an image with whatever src it was handed, so a body written
+// by anyone who may file an issue would have the reader fetch from wherever the
+// author chose. On a private repository that names who is reading it. The
+// policy is what stops the fetch, so it has to say so on the page that carries
+// one.
+func TestARemoteImageIsRefusedByThePolicy(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addRepo(t, f, "priv", Repo{
+ Visibility: visPrivate, Issues: featMembers, Wiki: featDisabled,
+ Grants: map[string]string{"alice": grantRead},
+ })
+ h := f.routes()
+ session := signIn(t, f, h, "alice")
+ form := url.Values{
+ "csrf": {f.csrf(withCookies("/", session))}, "title": {"one"},
+ "body": {"![x](https://tracker.example/p.png)"},
+ }
+ if w := post(h, "/priv/issues", form, session); w.Code != http.StatusSeeOther {
+ t.Fatalf("opening the issue: status %d", w.Code)
+ }
+
+ w := get(h, "/priv/issues/1", session)
+ if !strings.Contains(w.Body.String(), `src="https://tracker.example/p.png"`) {
+ t.Fatal("markdown no longer renders a remote image, and this measures the header that stops it")
+ }
+ if policy := w.Header().Get("Content-Security-Policy"); !strings.Contains(policy, "img-src 'self'") {
+ t.Errorf("the page carries a third party image under %q", policy)
+ }
+}
+
+// A HEAD is a GET whose body is dropped on the way out. The mux answers one
+// for every route it holds a pattern for, so the pages under a repository,
+// which read the method themselves, have to as well.
+func TestHeadIsAnsweredWhereverGetIs(t *testing.T) {
+ f := testForge(t)
+ addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featUsers})
+ h := f.routes()
+ if code := get(h, "/notes/issues", nil).Code; code != http.StatusOK {
+ t.Fatalf("the route this is measured against answered %d", code)
+ }
+ for _, path := range []string{
+ "/", "/login", "/healthz", "/notes/issues", "/notes/issues/new",
+ "/notes/issues/1", "/notes/wiki", "/notes/wiki/page", "/notes/settings",
+ } {
+ want := get(h, path, nil).Code
+ r := httptest.NewRequest("HEAD", path, nil)
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ if w.Code != want {
+ t.Errorf("HEAD %s answered %d, where GET answers %d", path, w.Code, want)
+ }
+ }
+ // And a method nothing serves still says so.
+ r := httptest.NewRequest("PUT", "/notes/issues", nil)
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ if w.Code != http.StatusNotFound {
+ t.Errorf("PUT answered %d", w.Code)
+ }
+}
+
+// A repository that has been deleted must stop answering everywhere at once.
+func TestDeletedRepoIsGone(t *testing.T) {
+ f := testForge(t)
+ addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featUsers})
+ h := f.routes()
+ if code := get(h, "/notes/issues", nil).Code; code != http.StatusOK {
+ t.Fatalf("before the delete, the repository did not answer: %d", code)
+ }
+ if err := f.store.DeleteRepo("notes", f.store.forgeAuthor()); err != nil {
+ t.Fatal(err)
+ }
+ for _, path := range []string{"/notes/issues", "/notes/wiki", "/notes/settings"} {
+ if code := get(h, path, nil).Code; code != http.StatusNotFound {
+ t.Errorf("%s answered %d after the repository was deleted", path, code)
+ }
+ }
+ // git is challenged rather than told, so the answer cannot say whether the
+ // name was ever taken.
+ for _, path := range []string{
+ "/notes.git/info/refs?service=git-upload-pack",
+ "/notes.issues.git/info/refs?service=git-upload-pack",
+ } {
+ if code := get(h, path, nil).Code; code != http.StatusUnauthorized {
+ t.Errorf("%s answered %d after the repository was deleted", path, code)
+ }
+ }
+ if body := get(h, "/", nil).Body.String(); strings.Contains(body, "notes") {
+ t.Error("the index still lists a deleted repository")
+ }
+}
+
+// The name goes into an attribute of an SVG this server writes, so what the
+// route accepts is what keeps that safe.
+func TestAvatarIsDrawnFromTheNameAlone(t *testing.T) {
+ h := testForge(t).routes()
+
+ one := avatarSVG("alice")
+ if !bytes.Equal(one, avatarSVG("alice")) {
+ t.Error("one account was drawn two different ways")
+ }
+ if bytes.Equal(one, avatarSVG("bob")) {
+ t.Error("two accounts were drawn the same way")
+ }
+
+ for _, name := range []string{`a"><script>x</script>`, "../../etc/passwd", "Alice", "a b", "a/b"} {
+ if code := get(h, "/avatar/"+url.PathEscape(name), nil).Code; code != http.StatusNotFound {
+ t.Errorf("/avatar/%q answered %d, and only an account name may be drawn", name, code)
+ }
+ }
+
+ w := get(h, "/avatar/sso:carol", nil)
+ if w.Code != http.StatusOK || w.Header().Get("Content-Type") != "image/svg+xml" {
+ t.Errorf("a usable name answered %d as %q", w.Code, w.Header().Get("Content-Type"))
+ }
+ if body := w.Body.String(); strings.Contains(body, "<script") || !strings.Contains(body, "<svg") {
+ t.Errorf("the drawing is not a drawing: %q", body)
+ }
+}
diff --git a/account.go b/account.go
new file mode 100644
index 0000000..2cb15c1
--- /dev/null
+++ b/account.go
@@ -0,0 +1,695 @@
+package main
+
+// The account's own pages: signing in and out, settings, passwords, addresses,
+// push tokens, and the second factor.
+
+import (
+ "crypto/hmac"
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// loginPage lists the external providers by key and label alone: the template
+// is handed nothing it must not print.
+type loginPage struct{ Providers []loginLink }
+
+type loginLink struct{ Key, Label string }
+
+func (f *Forge) loginData() loginPage {
+ var links []loginLink
+ for _, p := range f.cfg.OIDC {
+ links = append(links, loginLink{Key: p.Key, Label: p.Label})
+ }
+ return loginPage{Providers: links}
+}
+
+func (f *Forge) handleLogin(w http.ResponseWriter, r *http.Request) {
+ if _, _, ok := f.user(r); ok {
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+ return
+ }
+ d := f.page(r)
+ d.Data = f.loginData()
+ f.render(w, "login.html", http.StatusOK, d)
+}
+
+func (f *Forge) handleLoginPost(w http.ResponseWriter, r *http.Request) {
+ if !f.checkPost(w, r) {
+ return
+ }
+ // Failures only, per address only: per account, anybody could lock a
+ // victim out.
+ if f.loginLimit.over("login:"+f.peer(r), loginsPerHour) {
+ f.fail(w, r, http.StatusTooManyRequests, "too many failed sign in attempts from here, try again later")
+ return
+ }
+ name := accountName(r.PostFormValue("name"))
+ password := r.PostFormValue("password")
+
+ u, ok := f.store.User(name)
+ if !ok || u.Origin != originLocal {
+ u = User{} // hash anyway, so the two failures cost the same
+ }
+ if !passwordOK(u.Password, password) {
+ f.loginLimit.charge("login:" + f.peer(r))
+ log.Printf("login refused for %q from %s", name, f.peer(r))
+ d := f.page(r)
+ d.Error = "wrong account name or password"
+ d.Data = f.loginData()
+ f.render(w, "login.html", http.StatusUnauthorized, d)
+ return
+ }
+ if u.TOTP != nil {
+ f.askForCode(w, r, name, u, "")
+ return
+ }
+ f.setSession(w, name, u.Epoch)
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+type totpPage struct{ Name, Expires, Token string }
+
+// askForCode is the second step of a login. The half-finished login is carried
+// as the account, a short expiry, and a MAC over both plus the epoch, which an
+// external account has where it has no password.
+func (f *Forge) askForCode(w http.ResponseWriter, r *http.Request, name string, u User, message string) {
+ expires := strconv.FormatInt(time.Now().Add(totpPendLife).Unix(), 10)
+ d := f.page(r)
+ d.Error = message
+ d.Data = totpPage{
+ Name: name, Expires: expires,
+ Token: f.mac("totp-pending", name, expires, strconv.Itoa(u.Epoch)),
+ }
+ status := http.StatusOK
+ if message != "" {
+ status = http.StatusUnauthorized
+ }
+ f.render(w, "totp.html", status, d)
+}
+
+func (f *Forge) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
+ if !f.checkPost(w, r) {
+ return
+ }
+ name, expires := r.PostFormValue("name"), r.PostFormValue("expires")
+ unix, err := strconv.ParseInt(expires, 10, 64)
+ u, ok := f.store.User(name)
+ if err != nil || !ok || u.TOTP == nil || time.Now().After(time.Unix(unix, 0)) {
+ f.fail(w, r, http.StatusForbidden, "that login took too long. sign in again")
+ return
+ }
+ if !hmac.Equal([]byte(r.PostFormValue("t")), []byte(f.mac("totp-pending", name, expires, strconv.Itoa(u.Epoch)))) {
+ f.fail(w, r, http.StatusForbidden, "that login did not start here. sign in again")
+ return
+ }
+ // Six digits is not many.
+ if !f.totpLimit.allow(f.peer(r), Viewer{Name: name}, totpPerHour) {
+ f.fail(w, r, http.StatusTooManyRequests, "too many codes tried in the last hour, try again later")
+ return
+ }
+
+ code := strings.TrimSpace(r.PostFormValue("code"))
+ switch {
+ case f.codeOK(name, u, code):
+ case f.spendRecoveryCode(name, u, code):
+ f.mailAccount(u, "a recovery code was used",
+ "A recovery code was used to sign in to "+name+" on "+f.siteName()+
+ ". It cannot be used again.\n")
+ u, _ = f.store.User(name)
+ default:
+ f.askForCode(w, r, name, u, "that code is not right")
+ return
+ }
+ f.setSession(w, name, u.Epoch)
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+// spendRecoveryCode removes the code in the same write that accepts it, and
+// decides inside that write, so two logins with one code cannot both succeed.
+func (f *Forge) spendRecoveryCode(name string, u User, code string) bool {
+ want := tokenHash(strings.TrimSpace(code))
+ err := f.store.UpdateUser(name, "users: spend a recovery code for "+name, f.store.author(name, u),
+ func(acct *User) error {
+ if acct.TOTP == nil {
+ return errBadCode
+ }
+ left := []string{}
+ for _, kept := range acct.TOTP.Recovery {
+ if !hmac.Equal([]byte(kept), []byte(want)) {
+ left = append(left, kept)
+ }
+ }
+ if len(left) == len(acct.TOTP.Recovery) {
+ return errBadCode
+ }
+ acct.TOTP.Recovery = left
+ return nil
+ })
+ if err != nil && !errors.Is(err, errBadCode) {
+ log.Printf("totp: %v", err)
+ }
+ return err == nil
+}
+
+type enrollPage struct {
+ Secret string // only when there is nothing enrolled yet
+ URI string // the same secret, as an otpauth link
+ Enrolled bool
+ Required bool // an admin may not turn it off
+ Local bool // an external account has no password to ask for
+ Left int // recovery codes still unspent
+ Codes []string // shown once, right after they are made
+}
+
+// The proposed secret lives in the form until a code proves the authenticator
+// has it.
+func (f *Forge) handleTOTP(w http.ResponseWriter, r *http.Request) {
+ name, u, ok := f.requireUser(w, r)
+ if !ok {
+ return
+ }
+ d := f.page(r)
+ d.Data = f.enrollData(name, u, "", nil)
+ f.render(w, "totp-enroll.html", http.StatusOK, d)
+}
+
+// secret is the one being enrolled: empty mints a fresh one, and a retry keeps
+// the one already typed into the authenticator.
+func (f *Forge) enrollData(name string, u User, secret string, codes []string) enrollPage {
+ page := enrollPage{Required: u.Admin, Codes: codes, Local: u.Origin == originLocal}
+ if u.TOTP != nil {
+ page.Enrolled, page.Left = true, len(u.TOTP.Recovery)
+ return page
+ }
+ if secret == "" {
+ secret = totpSecret()
+ }
+ page.Secret, page.URI = secret, f.totpURI(name, secret)
+ return page
+}
+
+// proveOwner stands between a stolen cookie and a change that outlives the
+// session: a local account is asked for its password, an external one for a
+// code, and one with neither has nothing to be asked for.
+func (f *Forge) proveOwner(w http.ResponseWriter, r *http.Request, name string, u User, secret string) bool {
+ if u.Origin != originLocal {
+ return u.TOTP == nil || f.confirmCode(w, r)
+ }
+ if passwordOK(u.Password, r.PostFormValue("current")) {
+ return true
+ }
+ f.enrollError(w, r, name, u, secret, "the current password is wrong", http.StatusUnauthorized)
+ return false
+}
+
+// enrollError draws the authenticator page again with a message and the same
+// secret.
+func (f *Forge) enrollError(w http.ResponseWriter, r *http.Request, name string, u User, secret, msg string, status int) {
+ d := f.page(r)
+ d.Error = msg
+ d.Data = f.enrollData(name, u, secret, nil)
+ f.render(w, "totp-enroll.html", status, d)
+}
+
+func (f *Forge) handleTOTPEnroll(w http.ResponseWriter, r *http.Request) {
+ if !f.checkPost(w, r) {
+ return
+ }
+ name, u, ok := f.requireUser(w, r)
+ if !ok {
+ return
+ }
+ secret := strings.TrimSpace(r.PostFormValue("secret"))
+ if u.TOTP != nil {
+ f.fail(w, r, http.StatusConflict, "this account already has an authenticator. remove it first")
+ return
+ }
+ if !validSecret(secret) {
+ f.fail(w, r, http.StatusBadRequest, "that is not a secret this forge minted. start again from the settings page")
+ return
+ }
+ // Without this a stolen cookie takes the account for good.
+ if !f.proveOwner(w, r, name, u, secret) {
+ return
+ }
+ step := totpStep(secret, r.PostFormValue("code"), time.Now())
+ if step < 0 {
+ f.enrollError(w, r, name, u, secret,
+ "that code is not right. check the clock on the device, and try the next code",
+ http.StatusBadRequest)
+ return
+ }
+
+ show, keep := newRecoveryCodes()
+ err := f.store.UpdateUser(name, "users: enroll an authenticator for "+name, f.store.author(name, u),
+ func(acct *User) error {
+ // The enrolling code is spent too.
+ acct.TOTP = &TOTP{Secret: secret, Recovery: keep, Used: step}
+ acct.Epoch++ // every other session dies with the change
+ return nil
+ })
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return
+ }
+ u, _ = f.store.User(name)
+ f.mailAccount(u, "an authenticator was enrolled",
+ "An authenticator was enrolled for "+name+" on "+f.siteName()+
+ ", and every other session was signed out.\n")
+
+ d := f.pageFor(name, u, f.setSession(w, name, u.Epoch))
+ d.Notice = "enrolled. write these recovery codes down now: each one works once, and they are not shown again"
+ d.Data = f.enrollData(name, u, "", show)
+ f.render(w, "totp-enroll.html", http.StatusOK, d)
+}
+
+func (f *Forge) handleTOTPRemove(w http.ResponseWriter, r *http.Request) {
+ if !f.checkPost(w, r) {
+ return
+ }
+ name, u, ok := f.requireUser(w, r)
+ if !ok {
+ return
+ }
+ if u.Admin {
+ f.fail(w, r, http.StatusForbidden,
+ "an account with the global admin flag must keep an authenticator. have another admin take the flag away first")
+ return
+ }
+ if !f.proveOwner(w, r, name, u, "") {
+ return
+ }
+ err := f.store.UpdateUser(name, "users: remove the authenticator for "+name, f.store.author(name, u),
+ func(acct *User) error {
+ acct.TOTP = nil
+ acct.Epoch++
+ return nil
+ })
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return
+ }
+ u, _ = f.store.User(name)
+ f.mailAccount(u, "the authenticator was removed",
+ "The authenticator for "+name+" on "+f.siteName()+
+ " was removed, and every other session was signed out.\n")
+ f.setSession(w, name, u.Epoch)
+ http.Redirect(w, r, "/settings/totp", http.StatusSeeOther)
+}
+
+func (f *Forge) handleTOTPCodes(w http.ResponseWriter, r *http.Request) {
+ if !f.checkPost(w, r) {
+ return
+ }
+ name, u, ok := f.requireUser(w, r)
+ if !ok {
+ return
+ }
+ if u.TOTP == nil {
+ f.fail(w, r, http.StatusConflict, "this account has no authenticator")
+ return
+ }
+ // New codes are new keys to the account, so they cost a proof too.
+ if !f.proveOwner(w, r, name, u, "") {
+ return
+ }
+ show, keep := newRecoveryCodes()
+ err := f.store.UpdateUser(name, "users: regenerate recovery codes for "+name, f.store.author(name, u),
+ func(acct *User) error {
+ // An admin can clear it between the check above and this write.
+ if acct.TOTP == nil {
+ return userError("this account has no authenticator")
+ }
+ acct.TOTP.Recovery = keep
+ return nil
+ })
+ if err != nil {
+ f.problem(w, r, http.StatusConflict, err)
+ return
+ }
+ u, _ = f.store.User(name)
+ f.mailAccount(u, "recovery codes were regenerated",
+ "New recovery codes were made for "+name+" on "+f.siteName()+". The old ones no longer work.\n")
+
+ d := f.page(r)
+ d.Notice = "write these down now: each one works once, and they are not shown again"
+ d.Data = f.enrollData(name, u, "", show)
+ f.render(w, "totp-enroll.html", http.StatusOK, d)
+}
+
+func (f *Forge) handleLogout(w http.ResponseWriter, r *http.Request) {
+ if !f.checkPost(w, r) {
+ return
+ }
+ f.clearSession(w)
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+// settingsPage is the account's own record, for its own page.
+type settingsPage struct {
+ Email string
+ EmailConfirmed bool
+ Notify bool
+ Local bool // an external account has no password to change here
+ Tokens []Token
+ Secret string // a push token just minted, shown once
+}
+
+func settingsFor(u User, d pageData) pageData {
+ d.Data = settingsPage{
+ Email: u.Email, EmailConfirmed: u.EmailConfirmed,
+ Notify: u.Notify, Local: u.Origin == originLocal, Tokens: u.Tokens,
+ }
+ return d
+}
+
+func (f *Forge) handleSettings(w http.ResponseWriter, r *http.Request) {
+ _, u, ok := f.requireUser(w, r)
+ if !ok {
+ return
+ }
+ f.render(w, "settings.html", http.StatusOK, settingsFor(u, f.page(r)))
+}
+
+// The current password is required: a stolen cookie must not be enough.
+func (f *Forge) handlePassword(w http.ResponseWriter, r *http.Request) {
+ if !f.checkPost(w, r) {
+ return
+ }
+ name, u, ok := f.requireUser(w, r)
+ if !ok {
+ return
+ }
+
+ current := r.PostFormValue("current")
+ next := r.PostFormValue("new")
+ again := r.PostFormValue("again")
+
+ d := f.page(r)
+ switch {
+ case u.Origin != originLocal:
+ d.Error = "this account signs in through an external provider"
+ case !passwordOK(u.Password, current):
+ d.Error = "the current password is wrong"
+ case next != again:
+ d.Error = "the two new passwords are not the same"
+ case len(next) < minPassword:
+ d.Error = fmt.Sprintf("the new password must be at least %d characters", minPassword)
+ }
+ if d.Error != "" {
+ f.render(w, "settings.html", http.StatusBadRequest, settingsFor(u, d))
+ return
+ }
+
+ hash := hashPassword(next)
+ err := f.store.UpdateUser(name, "users: change password for "+name, f.store.author(name, u),
+ func(acct *User) error {
+ acct.Password = hash
+ acct.Epoch++ // every other session for this account dies here
+ return nil
+ })
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return
+ }
+
+ // The bump killed this browser's cookie too; reissue it.
+ u, _ = f.store.User(name)
+ f.mailAccount(u, "your password changed",
+ "The password for "+name+" on "+f.siteName()+" was just changed, and every other session was signed out.\n\n"+
+ "If that was not you, ask an admin for a new set-password link.\n")
+ d = f.pageFor(name, u, f.setSession(w, name, u.Epoch))
+ d.Notice = "password changed, and every other session was signed out"
+ f.render(w, "settings.html", http.StatusOK, settingsFor(u, d))
+}
+
+type setPasswordPage struct{ Name, Expires, Token string }
+
+func (f *Forge) handleSetPassword(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ if _, _, ok := f.checkSetPassword(q); !ok {
+ f.fail(w, r, http.StatusNotFound, "this link has expired or has already been used")
+ return
+ }
+ d := f.page(r)
+ d.Data = setPasswordPage{Name: q.Get("name"), Expires: q.Get("expires"), Token: q.Get("t")}
+ f.render(w, "set-password.html", http.StatusOK, d)
+}
+
+func (f *Forge) handleSetPasswordPost(w http.ResponseWriter, r *http.Request) {
+ if !f.checkPost(w, r) {
+ return
+ }
+ name, u, ok := f.checkSetPassword(r.PostForm)
+ if !ok {
+ f.fail(w, r, http.StatusNotFound, "this link has expired or has already been used")
+ return
+ }
+
+ next, again := r.PostFormValue("new"), r.PostFormValue("again")
+ d := f.page(r)
+ d.Data = setPasswordPage{Name: name, Expires: r.PostFormValue("expires"), Token: r.PostFormValue("t")}
+ switch {
+ case next != again:
+ d.Error = "the two passwords are not the same"
+ case len(next) < minPassword:
+ d.Error = fmt.Sprintf("the password must be at least %d characters", minPassword)
+ }
+ if d.Error != "" {
+ f.render(w, "set-password.html", http.StatusBadRequest, d)
+ return
+ }
+
+ hash := hashPassword(next)
+ err := f.store.UpdateUser(name, "users: set password for "+name, f.store.author(name, u),
+ func(acct *User) error {
+ acct.Password = hash
+ acct.Epoch++
+ // Following the link is what proves the address, when there is one.
+ acct.EmailConfirmed = acct.EmailConfirmed || acct.Email != ""
+ return nil
+ })
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return
+ }
+ u, _ = f.store.User(name)
+ f.mailAccount(u, "your password was set",
+ "A password for "+name+" on "+f.siteName()+" was just set through a one time link.\n")
+ f.setSession(w, name, u.Epoch)
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+// Reading is what allows minting: a token is a credential, not a permission,
+// and what it may do is decided on arrival.
+func (f *Forge) handleTokenMint(w http.ResponseWriter, r *http.Request) {
+ if !f.checkPost(w, r) {
+ return
+ }
+ name, u, ok := f.requireUser(w, r)
+ if !ok {
+ return
+ }
+ v := f.viewer(r)
+ path := strings.Trim(strings.TrimSpace(r.PostFormValue("repo")), "/")
+ repo, exists := f.store.Repo(path)
+ if !exists || !v.CanRead(repo) {
+ d := settingsFor(u, f.page(r))
+ d.Error = "there is no repository called " + path + " that you can reach"
+ f.render(w, "settings.html", http.StatusForbidden, d)
+ return
+ }
+ // Every mint is a commit, and every account can reach every public repo.
+ if !f.tokenLimit.allow(f.peer(r), v, tokensPerHour) {
+ f.fail(w, r, http.StatusTooManyRequests, "too many tokens minted in the last hour, try again later")
+ return
+ }
+
+ secret, token := mintToken(path)
+ err := f.store.UpdateUser(name, "users: mint a push token for "+name+" on "+path, f.store.author(name, u),
+ func(acct *User) error {
+ // Under the lock, so two at once cannot both find room.
+ if len(acct.Tokens) >= maxTokens {
+ return userError(fmt.Sprintf(
+ "this account already holds %d tokens, which is all it may hold. revoke one first", maxTokens))
+ }
+ acct.Tokens = append(append([]Token(nil), acct.Tokens...), token)
+ return nil
+ })
+ if err != nil {
+ f.problem(w, r, http.StatusConflict, err)
+ return
+ }
+ u, _ = f.store.User(name)
+ f.mailAccount(u, "a push token was minted",
+ "A push token for "+path+" was minted for "+name+" on "+f.siteName()+
+ ", with the id "+token.ID+".\n\nIf that was not you, revoke it at "+f.url("/settings")+"\n")
+
+ d := settingsFor(u, f.page(r))
+ d.Notice = "copy this token now: it is the password for git over http, it carries your own " +
+ "access to " + path + " and nothing more, and it is not shown again"
+ page := d.Data.(settingsPage)
+ page.Secret = secret
+ d.Data = page
+ f.render(w, "settings.html", http.StatusOK, d)
+}
+
+func (f *Forge) handleTokenRevoke(w http.ResponseWriter, r *http.Request) {
+ if !f.checkPost(w, r) {
+ return
+ }
+ name, u, ok := f.requireUser(w, r)
+ if !ok {
+ return
+ }
+ id := r.PostFormValue("id")
+ var revoked Token
+ err := f.store.UpdateUser(name, "users: revoke a push token for "+name, f.store.author(name, u),
+ func(acct *User) error {
+ left := []Token{}
+ for _, t := range acct.Tokens {
+ if t.ID == id {
+ revoked = t
+ continue
+ }
+ left = append(left, t)
+ }
+ if revoked.ID == "" {
+ return userError("no such token")
+ }
+ acct.Tokens = left
+ return nil
+ })
+ if err != nil {
+ f.problem(w, r, http.StatusNotFound, err)
+ return
+ }
+ u, _ = f.store.User(name)
+ f.mailAccount(u, "a push token was revoked",
+ "The push token "+revoked.ID+" for "+revoked.Repo+" on "+f.siteName()+" was revoked.\n")
+ http.Redirect(w, r, "/settings", http.StatusSeeOther)
+}
+
+func (f *Forge) handleConfirmEmail(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ name, expires := q.Get("name"), q.Get("expires")
+ unix, err := strconv.ParseInt(expires, 10, 64)
+ u, ok := f.store.User(name)
+ if err != nil || !ok || time.Now().After(time.Unix(unix, 0)) {
+ f.fail(w, r, http.StatusNotFound, "this link has expired or has already been used")
+ return
+ }
+ if !hmac.Equal([]byte(q.Get("t")), []byte(f.mac("confirm-email", name, expires, u.Email))) {
+ f.fail(w, r, http.StatusNotFound, "this link has expired or has already been used")
+ return
+ }
+ err = f.store.UpdateUser(name, "users: confirm the address of "+name, f.store.author(name, u),
+ func(acct *User) error {
+ acct.EmailConfirmed = true
+ return nil
+ })
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return
+ }
+ d := f.page(r)
+ d.Notice = "that address is confirmed, and will now receive notifications"
+ f.render(w, "home.html", http.StatusOK, f.withIndex(r, d))
+}
+
+// A new address is unconfirmed until a link sent to it is followed, and an
+// unconfirmed address receives nothing else.
+func (f *Forge) handleEmail(w http.ResponseWriter, r *http.Request) {
+ if !f.checkPost(w, r) {
+ return
+ }
+ name, u, ok := f.requireUser(w, r)
+ if !ok {
+ return
+ }
+ email := strings.TrimSpace(r.PostFormValue("email"))
+ notify := r.PostFormValue("notify") != ""
+ if !validEmail(email) {
+ d := f.page(r)
+ d.Error = "that does not look like an email address"
+ f.render(w, "settings.html", http.StatusBadRequest, settingsFor(u, d))
+ return
+ }
+
+ changed := email != u.Email
+ err := f.store.UpdateUser(name, "users: set the address and notifications of "+name, f.store.author(name, u),
+ func(acct *User) error {
+ acct.Notify = notify
+ if changed {
+ acct.Email, acct.EmailConfirmed = email, false
+ }
+ return nil
+ })
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return
+ }
+
+ u, _ = f.store.User(name)
+ d := settingsFor(u, f.page(r))
+ d.Notice = "saved"
+ // An address still unproved gets another link.
+ if email != "" && (changed || !u.EmailConfirmed) {
+ f.mailLink(email, "confirm this address",
+ "Follow this link to confirm this address for "+name+":\n\n"+f.confirmEmailLink(name, u)+"\n")
+ d.Notice = "saved, and a link to confirm the address is on its way to it"
+ }
+ f.render(w, "settings.html", http.StatusOK, d)
+}
+
+// The same sentence whether or not the account exists.
+func (f *Forge) handleReset(w http.ResponseWriter, r *http.Request) {
+ f.render(w, "reset.html", http.StatusOK, f.page(r))
+}
+
+func (f *Forge) handleResetPost(w http.ResponseWriter, r *http.Request) {
+ if !f.checkPost(w, r) {
+ return
+ }
+ d := f.page(r)
+ d.Notice = "if that account exists and has an address on file, a link to set a new password is on its way to it"
+
+ if !f.resetLimit.allow(f.peer(r), f.viewer(r), resetsPerHour) {
+ f.fail(w, r, http.StatusTooManyRequests, "too many reset requests in the last hour, try again later")
+ return
+ }
+ name := accountName(r.PostFormValue("name"))
+ if u, ok := f.store.User(name); ok && u.Origin == originLocal && u.Email != "" {
+ f.mailLink(u.Email, "set a new password",
+ "Somebody asked for a new password for "+name+". Follow this link within an hour to set one:\n\n"+
+ f.passwordLink(name, u, resetLife)+"\n\nIf that was not you, nothing has happened and you can ignore this.\n")
+ }
+ f.render(w, "reset.html", http.StatusOK, d)
+}
+
+// Without a session store, signing out everywhere is an epoch bump.
+func (f *Forge) handleSignOut(w http.ResponseWriter, r *http.Request) {
+ if !f.checkPost(w, r) {
+ return
+ }
+ name, u, ok := f.requireUser(w, r)
+ if !ok {
+ return
+ }
+ err := f.store.UpdateUser(name, "users: sign out every session for "+name, f.store.author(name, u),
+ func(acct *User) error {
+ acct.Epoch++
+ return nil
+ })
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return
+ }
+ f.clearSession(w)
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+}
diff --git a/admin.go b/admin.go
new file mode 100644
index 0000000..cb03131
--- /dev/null
+++ b/admin.go
@@ -0,0 +1,720 @@
+package main
+
+import (
+ "crypto/hmac"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "sort"
+ "strings"
+)
+
+// Administration is web only, so the first admin cannot come from a shell.
+func (f *Forge) setupOpen() bool {
+ return f.setupToken.Load() != nil && len(f.store.Users()) == 0
+}
+
+func (f *Forge) setupTokenOK(given string) bool {
+ want := f.setupToken.Load()
+ return want != nil && hmac.Equal([]byte(given), []byte(*want))
+}
+
+func (f *Forge) handleSetup(w http.ResponseWriter, r *http.Request) {
+ if !f.setupOpen() {
+ f.fail(w, r, http.StatusNotFound, "no such page")
+ return
+ }
+ f.render(w, "setup.html", http.StatusOK, f.page(r))
+}
+
+func (f *Forge) handleSetupPost(w http.ResponseWriter, r *http.Request) {
+ if !f.checkPost(w, r) {
+ return
+ }
+ if !f.setupOpen() {
+ f.fail(w, r, http.StatusNotFound, "no such page")
+ return
+ }
+
+ token := r.PostFormValue("token")
+ name := accountName(r.PostFormValue("name"))
+ email := strings.TrimSpace(r.PostFormValue("email"))
+ password, again := r.PostFormValue("password"), r.PostFormValue("again")
+
+ d := f.page(r)
+ switch {
+ case !f.setupTokenOK(token):
+ d.Error = "wrong setup token, it was printed to the log at startup"
+ case !localNameRE.MatchString(name):
+ d.Error = "the account name must be lowercase letters, digits, dot, dash, or underscore"
+ case !validEmail(email):
+ d.Error = "that does not look like an email address"
+ case password != again:
+ d.Error = "the two passwords are not the same"
+ case len(password) < minPassword:
+ d.Error = fmt.Sprintf("the password must be at least %d characters", minPassword)
+ }
+ if d.Error != "" {
+ f.render(w, "setup.html", http.StatusBadRequest, d)
+ return
+ }
+
+ admin := User{Origin: originLocal, Admin: true, Email: email, Notify: true, Password: hashPassword(password)}
+ err := f.store.UpdateUsers("users: create the first admin, "+name, Author{Name: name, Email: email},
+ func(m map[string]User) error {
+ if len(m) > 0 {
+ return userError("an account already exists")
+ }
+ m[name] = admin
+ return nil
+ })
+ if err != nil {
+ f.problem(w, r, http.StatusConflict, err)
+ return
+ }
+ f.setupToken.Store(nil)
+ if email != "" {
+ f.mailLink(email, "confirm this address",
+ "Follow this link to confirm this address for "+name+":\n\n"+f.confirmEmailLink(name, admin)+"\n")
+ }
+ f.setSession(w, name, admin.Epoch)
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+// requireAdmin gates the global admin pages; repository grants do not reach
+// them.
+func (f *Forge) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
+ name, u, ok := f.user(r)
+ if !ok {
+ http.Redirect(w, r, "/login", http.StatusSeeOther)
+ return false
+ }
+ if !u.Admin {
+ f.fail(w, r, http.StatusForbidden, "this page is for global admins")
+ return false
+ }
+ if u.TOTP == nil {
+ log.Printf("admin: %s has the admin flag and no authenticator yet", name)
+ http.Redirect(w, r, "/settings/totp", http.StatusSeeOther)
+ return false
+ }
+ return true
+}
+
+// adminPage is the one admin page.
+type adminPage struct {
+ New Repo // the defaults the create form starts from
+ Repos []RepoEntry
+ Users map[string]User
+ Site *Site
+ Link string // a set-password link just minted, for the admin to convey
+ For string
+}
+
+func (f *Forge) adminData() adminPage {
+ return adminPage{
+ New: orphanRepo(), // private, both features off: the safe defaults
+ Repos: f.store.Repos(), Users: f.store.Users(), Site: f.site.Load(),
+ }
+}
+
+// formImage reads and checks one upload, writing nothing. A nil body is no
+// upload.
+func formImage(r *http.Request, field string) ([]byte, error) {
+ // Not multipart, or no file in it: no upload.
+ file, _, err := r.FormFile(field)
+ if errors.Is(err, http.ErrMissingFile) || errors.Is(err, http.ErrNotMultipart) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+
+ body, err := io.ReadAll(io.LimitReader(file, maxImage+1))
+ if err != nil {
+ return nil, err
+ }
+ if len(body) == 0 {
+ return nil, nil
+ }
+ if err := checkImage(body); err != nil {
+ return nil, err
+ }
+ return body, nil
+}
+
+// The name, subtitle, logo, and favicon are the only parts of forge.json a
+// form may touch: the rest decides how the process starts. An upload goes in
+// the config directory, so it is in the backup and the history.
+func (f *Forge) handleSiteSettings(w http.ResponseWriter, r *http.Request) {
+ if !f.checkPost(w, r) || !f.requireAdmin(w, r) {
+ return
+ }
+ name := strings.TrimSpace(r.PostFormValue("site_name"))
+ desc := strings.TrimSpace(r.PostFormValue("site_desc"))
+ site := f.site.Load()
+ logo, icon := site.Logo.Name, site.Icon.Name
+
+ // Refused before anything is written, so a refused save changes nothing.
+ if !validSiteName(name) {
+ f.adminError(w, r, errSiteName)
+ return
+ }
+ logoBody, err := formImage(r, "logo_file")
+ if err != nil {
+ f.adminError(w, r, err)
+ return
+ }
+ iconBody, err := formImage(r, "icon_file")
+ if err != nil {
+ f.adminError(w, r, err)
+ return
+ }
+
+ // What this request wrote over, and what was there, so a save that fails
+ // afterwards can put it back.
+ var written []swap
+ if r.PostFormValue("logo") == "cgit" {
+ logo = "" // back to the one the image ships
+ } else if logoBody != nil {
+ if err := writeFile(f.store.File("logo"), logoBody, 0600); err != nil {
+ f.adminError(w, r, err)
+ return
+ }
+ written = append(written, swap{"logo", site.Logo})
+ logo = "logo"
+ }
+ if r.PostFormValue("icon") == "cgit" {
+ icon = ""
+ } else if iconBody != nil {
+ if err := writeFile(f.store.File("icon"), iconBody, 0600); err != nil {
+ f.unwrite(written)
+ f.adminError(w, r, err)
+ return
+ }
+ written = append(written, swap{"icon", site.Icon})
+ icon = "icon"
+ }
+
+ // Read back before the config names it.
+ next, err := loadSite(f.store.configDir, name, desc, logo, icon)
+ if err == nil {
+ err = f.store.WriteConfig(f.actor(r), func(cfg *Config) {
+ cfg.SiteName, cfg.SiteDesc, cfg.Logo, cfg.Icon = name, desc, logo, icon
+ })
+ }
+ if err != nil {
+ f.unwrite(written)
+ f.adminError(w, r, err)
+ return
+ }
+ f.site.Store(next)
+ http.Redirect(w, r, "/admin", http.StatusSeeOther)
+}
+
+// swap is one image file a save wrote over, and what was there.
+type swap struct {
+ file string
+ old Image
+}
+
+// unwrite puts back what a failed save wrote over: the next start reads the
+// file the config names.
+func (f *Forge) unwrite(written []swap) {
+ for _, w := range written {
+ path := f.store.File(w.file)
+ err := os.Remove(path)
+ if w.old.Name == w.file {
+ err = writeFile(path, w.old.Body, 0600)
+ }
+ if err != nil {
+ log.Printf("admin: %s holds a refused image: %v", path, err)
+ }
+ }
+}
+
+func (f *Forge) handleAdmin(w http.ResponseWriter, r *http.Request) {
+ if !f.requireAdmin(w, r) {
+ return
+ }
+ d := f.page(r)
+ d.Data = f.adminData()
+ f.render(w, "admin.html", http.StatusOK, d)
+}
+
+// confirmCode asks the acting account for a code: the session says who they
+// are, this says they are at the keyboard. Only a wrong code is counted, on
+// the budget the login form spends.
+func (f *Forge) confirmCode(w http.ResponseWriter, r *http.Request) bool {
+ name, u, ok := f.user(r)
+ if !ok || u.TOTP == nil {
+ f.fail(w, r, http.StatusForbidden,
+ "this asks for an authenticator code, and this account holds no authenticator. enrol one under settings")
+ return false
+ }
+ if f.totpLimit.over(accountKey(name), totpPerHour) {
+ f.fail(w, r, http.StatusTooManyRequests, "too many codes tried in the last hour, try again later")
+ return false
+ }
+ if !f.codeOK(name, u, r.PostFormValue("code")) {
+ f.totpLimit.charge(accountKey(name))
+ f.fail(w, r, http.StatusForbidden,
+ "that authenticator code is not right, or was used already. wait for the next one. nothing was changed")
+ return false
+ }
+ return true
+}
+
+// adminError draws /admin again with a message on it: there is nowhere to keep
+// one across a redirect.
+func (f *Forge) adminError(w http.ResponseWriter, r *http.Request, err error) {
+ var mine userError
+ if !errors.As(err, &mine) {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return
+ }
+ d := f.page(r)
+ d.Data = f.adminData()
+ d.Error = mine.Error()
+ f.render(w, "admin.html", http.StatusBadRequest, d)
+}
+
+func (f *Forge) handleRepoCreate(w http.ResponseWriter, r *http.Request) {
+ if !f.checkPost(w, r) || !f.requireAdmin(w, r) {
+ return
+ }
+ section := strings.TrimSpace(r.PostFormValue("section"))
+ name := strings.TrimSpace(r.PostFormValue("name"))
+ path := name
+ if section != "" {
+ path = section + "/" + name
+ }
+ repo := Repo{
+ Desc: strings.TrimSpace(r.PostFormValue("desc")),
+ Visibility: r.PostFormValue("visibility"),
+ Issues: r.PostFormValue("issues"),
+ Wiki: r.PostFormValue("wiki"),
+ Labels: []string{},
+ Grants: map[string]string{},
+ }
+ if err := f.store.CreateRepo(path, repo, f.actor(r)); err != nil {
+ f.adminError(w, r, err)
+ return
+ }
+ who := f.viewer(r).Name
+ f.mailAdmins(who, "repository "+path+" created",
+ who+" created "+path+", "+repo.Visibility+".\n\n"+f.url("/"+path+"/")+"\n")
+ http.Redirect(w, r, "/"+path+"/settings", http.StatusSeeOther)
+}
+
+// handleRepoAdopt records the safe settings a directory with no entry in
+// repos.json is already being served with.
+func (f *Forge) handleRepoAdopt(w http.ResponseWriter, r *http.Request) {
+ if !f.checkPost(w, r) || !f.requireAdmin(w, r) {
+ return
+ }
+ path := r.PostFormValue("path")
+ if _, ok := f.store.Repo(path); !ok {
+ f.fail(w, r, http.StatusNotFound, "no such repository")
+ return
+ }
+ err := f.store.UpdateRepos("repos: adopt "+path, f.actor(r), func(m map[string]Repo) error {
+ if _, ok := m[path]; ok {
+ return userError(path + " already has an entry")
+ }
+ m[path] = orphanRepo()
+ return nil
+ })
+ if err != nil {
+ f.adminError(w, r, err)
+ return
+ }
+ http.Redirect(w, r, "/"+path+"/settings", http.StatusSeeOther)
+}
+
+// An admin never handles a password: a one time link is shown and, with mail,
+// sent. It outlives this session, so it costs the admin's own code.
+func (f *Forge) handleUserCreate(w http.ResponseWriter, r *http.Request) {
+ if !f.checkPost(w, r) || !f.requireAdmin(w, r) {
+ return
+ }
+ if !f.confirmCode(w, r) {
+ return
+ }
+ name := accountName(r.PostFormValue("name"))
+ email := strings.TrimSpace(r.PostFormValue("email"))
+
+ switch {
+ case !localNameRE.MatchString(name):
+ f.adminError(w, r, userError("the account name must be lowercase letters, digits, dot, dash, or underscore"))
+ return
+ case !validEmail(email):
+ f.adminError(w, r, userError("that does not look like an email address"))
+ return
+ }
+
+ // No admin flag here: handing that out is a separate act.
+ acct := User{Origin: originLocal, Email: email, Notify: true}
+ err := f.store.UpdateUsers("users: create "+name, f.actor(r), func(m map[string]User) error {
+ if _, ok := m[name]; ok {
+ return userError(name + " already exists")
+ }
+ m[name] = acct
+ return nil
+ })
+ if err != nil {
+ f.adminError(w, r, err)
+ return
+ }
+
+ link := f.passwordLink(name, acct, setPasswordLife)
+ if acct.Email != "" {
+ f.mailLink(acct.Email, "set a password for "+name,
+ "An admin made you an account named "+name+" on "+f.siteName()+
+ ". Follow this link within two days to set a password:\n\n"+link+"\n")
+ }
+ d := f.page(r)
+ data := f.adminData()
+ data.Link, data.For = link, name
+ d.Data = data
+ f.render(w, "admin.html", http.StatusOK, d)
+}
+
+func (f *Forge) handleUserAdmin(w http.ResponseWriter, r *http.Request) {
+ if !f.checkPost(w, r) || !f.requireAdmin(w, r) {
+ return
+ }
+ if !f.confirmCode(w, r) {
+ return
+ }
+ name := accountName(r.PostFormValue("name"))
+ admin := r.PostFormValue("admin") != ""
+ verb := "clear"
+ if admin {
+ verb = "set"
+ }
+
+ err := f.store.UpdateUsers(fmt.Sprintf("users: %s the admin flag on %s", verb, name), f.actor(r),
+ func(m map[string]User) error {
+ acct, ok := m[name]
+ if !ok {
+ return userError(name + " does not exist")
+ }
+ acct.Admin = admin
+ m[name] = acct
+ return lastAdminCheck(m)
+ })
+ if err != nil {
+ f.adminError(w, r, err)
+ return
+ }
+ http.Redirect(w, r, "/admin", http.StatusSeeOther)
+}
+
+// handleUserTOTP clears another account's second factor, the way back in when
+// both authenticator and recovery codes are lost. Never an account's own.
+func (f *Forge) handleUserTOTP(w http.ResponseWriter, r *http.Request) {
+ if !f.checkPost(w, r) || !f.requireAdmin(w, r) {
+ return
+ }
+ name := accountName(r.PostFormValue("name"))
+ if acting, _, _ := f.user(r); acting == name {
+ f.fail(w, r, http.StatusForbidden, "clear your own authenticator from your settings, where it asks for your password")
+ return
+ }
+ if !f.confirmCode(w, r) {
+ return
+ }
+ err := f.store.UpdateUsers("users: clear the authenticator for "+name, f.actor(r),
+ func(m map[string]User) error {
+ acct, ok := m[name]
+ if !ok {
+ return userError(name + " does not exist")
+ }
+ if acct.TOTP == nil {
+ return userError(name + " has no authenticator")
+ }
+ acct.TOTP = nil
+ acct.Epoch++ // and every session for that account with it
+ m[name] = acct
+ return nil
+ })
+ if err != nil {
+ f.adminError(w, r, err)
+ return
+ }
+ if u, ok := f.store.User(name); ok {
+ f.mailAccount(u, "your authenticator was cleared",
+ "An admin cleared the authenticator for "+name+" on "+f.siteName()+
+ ", and signed out every session for it. Enrol a new one when you next sign in.\n")
+ }
+ http.Redirect(w, r, "/admin", http.StatusSeeOther)
+}
+
+// Every grant pointing at the account goes with it.
+func (f *Forge) handleUserDelete(w http.ResponseWriter, r *http.Request) {
+ if !f.checkPost(w, r) || !f.requireAdmin(w, r) {
+ return
+ }
+ if !f.confirmCode(w, r) {
+ return
+ }
+ name := accountName(r.PostFormValue("name"))
+ err := f.store.UpdateUsers("users: delete "+name, f.actor(r), func(m map[string]User) error {
+ if _, ok := m[name]; !ok {
+ return userError(name + " does not exist")
+ }
+ delete(m, name)
+ return lastAdminCheck(m)
+ })
+ if err != nil {
+ f.adminError(w, r, err)
+ return
+ }
+
+ err = f.store.UpdateRepos("repos: drop grants for "+name, f.actor(r), func(m map[string]Repo) error {
+ for path, repo := range m {
+ if _, ok := repo.Grants[name]; !ok {
+ continue
+ }
+ grants := map[string]string{}
+ for who, level := range repo.Grants {
+ if who != name {
+ grants[who] = level
+ }
+ }
+ repo.Grants = grants
+ m[path] = repo
+ }
+ return nil
+ })
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return
+ }
+ http.Redirect(w, r, "/admin", http.StatusSeeOther)
+}
+
+// lastAdminCheck refuses the change that would leave the forge with no way in.
+func lastAdminCheck(m map[string]User) error {
+ for _, u := range m {
+ if u.Admin {
+ return nil
+ }
+ }
+ return userError("that would leave no global admin, and there is no way to make one from a shell")
+}
+
+// repoSettings is the per repository page, for the admin grant.
+func (f *Forge) repoSettings(w http.ResponseWriter, r *http.Request, path string, repo Repo) {
+ v := f.viewer(r)
+ if !v.CanAdmin(repo) {
+ f.fail(w, r, http.StatusForbidden, "you do not administer this repository")
+ return
+ }
+ _, u, _ := f.user(r) // administering a repository means being signed in
+ d := f.page(r)
+ d.Repo = f.repoBar(r, path, "", "settings", repo)
+ d.Data = repoSettingsPage{
+ Path: path, Repo: repo, Labels: strings.Join(repo.Labels, "\n"),
+ Delete: v.Admin, Enrolled: u.TOTP != nil,
+ }
+ f.render(w, "repo-settings.html", http.StatusOK, d)
+}
+
+type repoSettingsPage struct {
+ Path string
+ Repo Repo
+ Labels string // one per line, for the textarea
+ Delete bool // deletion is the global admin flag's alone
+ // Enrolled says whether the grant forms are drawn: they ask for a code.
+ Enrolled bool
+}
+
+func (f *Forge) repoSettingsSave(w http.ResponseWriter, r *http.Request, path string, repo Repo) {
+ v := f.viewer(r)
+ if !v.CanAdmin(repo) {
+ f.fail(w, r, http.StatusForbidden, "you do not administer this repository")
+ return
+ }
+ next := repo
+ next.Desc = strings.TrimSpace(r.PostFormValue("desc"))
+ next.Visibility = r.PostFormValue("visibility")
+ next.Issues = r.PostFormValue("issues")
+ next.Wiki = r.PostFormValue("wiki")
+ next.Labels = splitLabels(r.PostFormValue("labels"))
+
+ if errs := next.validate(path); len(errs) > 0 {
+ _, u, _ := f.user(r)
+ d := f.page(r)
+ d.Repo = f.repoBar(r, path, "", "settings", repo)
+ d.Data = repoSettingsPage{
+ Path: path, Repo: next, Labels: r.PostFormValue("labels"),
+ Delete: v.Admin, Enrolled: u.TOTP != nil,
+ }
+ d.Error = errors.Join(errs...).Error()
+ f.render(w, "repo-settings.html", http.StatusBadRequest, d)
+ return
+ }
+ // These three hand out access that outlives the session, the way a grant
+ // does; a description or a label does not.
+ if next.Visibility != repo.Visibility || next.Issues != repo.Issues || next.Wiki != repo.Wiki {
+ if !f.confirmCode(w, r) {
+ return
+ }
+ }
+ // First, so a knob is never on with no repository behind it.
+ if err := f.store.EnsureSiblings(path, next); err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return
+ }
+ err := f.store.UpdateRepos("repos: update settings for "+path, f.actor(r), func(m map[string]Repo) error {
+ // Onto the record as it is now: a grant that landed meanwhile has to
+ // survive.
+ entry, ok := m[path]
+ if !ok {
+ entry = orphanRepo()
+ }
+ entry.Desc, entry.Visibility = next.Desc, next.Visibility
+ entry.Issues, entry.Wiki, entry.Labels = next.Issues, next.Wiki, next.Labels
+ m[path] = entry
+ return nil
+ })
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return
+ }
+ if next.Visibility != repo.Visibility {
+ f.mailAdmins(v.Name, "repository "+path+" is now "+next.Visibility,
+ v.Name+" changed "+path+" from "+repo.Visibility+" to "+next.Visibility+".\n\n"+f.url("/"+path+"/")+"\n")
+ }
+ http.Redirect(w, r, "/"+path+"/settings", http.StatusSeeOther)
+}
+
+// splitLabels reads the textarea, one label per line, once each.
+func splitLabels(s string) []string {
+ seen := map[string]bool{}
+ out := []string{}
+ for _, line := range strings.Split(s, "\n") {
+ label := strings.TrimSpace(line)
+ if label == "" || seen[label] {
+ continue
+ }
+ seen[label] = true
+ out = append(out, label)
+ }
+ sort.Strings(out)
+ return out
+}
+
+// repoGrant adds, changes, or revokes one grant. An empty level revokes.
+func (f *Forge) repoGrant(w http.ResponseWriter, r *http.Request, path string, repo Repo) {
+ if !f.viewer(r).CanAdmin(repo) {
+ f.fail(w, r, http.StatusForbidden, "you do not administer this repository")
+ return
+ }
+ if !f.confirmCode(w, r) {
+ return
+ }
+ name := accountName(r.PostFormValue("account"))
+ level := r.PostFormValue("level")
+
+ if _, ok := f.store.User(name); !ok && level != "" {
+ f.fail(w, r, http.StatusBadRequest, "no such account: "+name)
+ return
+ }
+ subject := fmt.Sprintf("repos: grant %s %s on %s", name, level, path)
+ if level == "" {
+ subject = fmt.Sprintf("repos: revoke %s on %s", name, path)
+ }
+ err := f.store.UpdateRepos(subject, f.actor(r), func(m map[string]Repo) error {
+ // A grant on an unadopted directory adopts it as served.
+ entry, ok := m[path]
+ if !ok {
+ entry = orphanRepo()
+ }
+ grants := map[string]string{}
+ for who, l := range entry.Grants {
+ if who != name {
+ grants[who] = l
+ }
+ }
+ if level != "" {
+ grants[name] = level
+ }
+ entry.Grants = grants
+ if errs := entry.validate(path); len(errs) > 0 {
+ return userError(errors.Join(errs...).Error())
+ }
+ m[path] = entry
+ return nil
+ })
+ if err != nil {
+ f.problem(w, r, http.StatusBadRequest, err)
+ return
+ }
+ if u, ok := f.store.User(name); ok {
+ what := "you now have " + level + " on " + path
+ if level == "" {
+ what = "your access to " + path + " has been revoked"
+ }
+ f.mailAccount(u, "access to "+path+" changed", what+".\n\n"+f.url("/"+path+"/")+"\n")
+ }
+ http.Redirect(w, r, "/"+path+"/settings", http.StatusSeeOther)
+}
+
+// repoDelete removes the repository and both siblings, confirmed by typing the
+// path: there is no undo.
+func (f *Forge) repoDelete(w http.ResponseWriter, r *http.Request, path string, repo Repo) {
+ if !f.viewer(r).Admin {
+ f.fail(w, r, http.StatusForbidden, "deleting a repository is for global admins")
+ return
+ }
+ if r.PostFormValue("confirm") != path {
+ f.fail(w, r, http.StatusBadRequest, "type the repository path exactly to confirm")
+ return
+ }
+ if !f.confirmCode(w, r) {
+ return
+ }
+ if err := f.store.DeleteRepo(path, f.actor(r)); err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return
+ }
+ // A token is scoped to a path, and a path can be created again.
+ err := f.store.UpdateUsers("users: drop push tokens for "+path, f.actor(r), func(m map[string]User) error {
+ for name, u := range m {
+ left := []Token{}
+ for _, t := range u.Tokens {
+ if t.Repo != path {
+ left = append(left, t)
+ }
+ }
+ if len(left) != len(u.Tokens) {
+ u.Tokens = left
+ m[name] = u
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ log.Printf("repos: tokens for the deleted %s: %v", path, err)
+ }
+ who := f.viewer(r).Name
+ f.mailAdmins(who, "repository "+path+" deleted",
+ who+" deleted "+path+", with its issues and its wiki.\n")
+ http.Redirect(w, r, "/admin", http.StatusSeeOther)
+}
+
+// validEmail is shallow: an address is proved by a link arriving at it.
+func validEmail(email string) bool {
+ if email == "" {
+ return true
+ }
+ local, domain, ok := strings.Cut(email, "@")
+ return ok && local != "" && domain != "" && !strings.ContainsAny(email, " \t\r\n<>,;")
+}
diff --git a/admin_test.go b/admin_test.go
new file mode 100644
index 0000000..94ba8c7
--- /dev/null
+++ b/admin_test.go
@@ -0,0 +1,692 @@
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+)
+
+// The name is on the header, in every mail subject, in the otpauth URI, and in
+// the realm git prompts with. All four have to move together.
+func TestSiteRenameReachesEverything(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addAdmin(t, f, "alice")
+ addRepo(t, f, "priv", Repo{Visibility: visPrivate, Issues: featDisabled, Wiki: featDisabled})
+ h := f.routes()
+ session := codeSession(t, f, h, "alice", "hunter2hunter2")
+
+ form := url.Values{
+ "csrf": {f.csrf(withCookies("/", session))},
+ "site_name": {"Renamed"}, "site_desc": {"a subtitle"},
+ }
+ if w := post(h, "/admin/site", form, session); w.Code != http.StatusSeeOther {
+ t.Fatalf("saving the site: status %d", w.Code)
+ }
+
+ if got := f.subject("hello"); got != "[Renamed] hello" {
+ t.Errorf("a mail subject reads %q", got)
+ }
+ if got := f.totpURI("alice", knownSecret); !strings.Contains(got, "Renamed") {
+ t.Errorf("the otpauth uri reads %q", got)
+ }
+ challenge := get(h, "/priv.git/info/refs?service=git-upload-pack", nil)
+ if got := challenge.Header().Get("WWW-Authenticate"); !strings.Contains(got, "Renamed") {
+ t.Errorf("git is prompted with %q", got)
+ }
+ if body := get(h, "/", nil).Body.String(); !strings.Contains(body, "Renamed") {
+ t.Error("the header does not carry the new name")
+ }
+
+ // Refused at the door rather than escaped everywhere the name lands.
+ form.Set("site_name", `Ren"amed`)
+ if w := post(h, "/admin/site", form, session); w.Code != http.StatusBadRequest {
+ t.Errorf("a name with a quote in it was accepted: status %d", w.Code)
+ }
+ if got := f.siteName(); got != "Renamed" {
+ t.Errorf("the refused name was stored anyway: %q", got)
+ }
+}
+
+// saveSite posts the /admin/site form. A nil image leaves that part out, which
+// is how the form arrives when no file was chosen.
+func saveSite(t *testing.T, f *Forge, h http.Handler, session []*http.Cookie, name string, logo, icon []byte) int {
+ t.Helper()
+ var body bytes.Buffer
+ mw := multipart.NewWriter(&body)
+ mw.WriteField("csrf", f.csrf(withCookies("/", session)))
+ mw.WriteField("site_name", name)
+ mw.WriteField("site_desc", "")
+ add := func(field string, image []byte) {
+ if image == nil {
+ return
+ }
+ part, err := mw.CreateFormFile(field, field)
+ if err != nil {
+ t.Fatal(err)
+ }
+ part.Write(image)
+ }
+ add("logo_file", logo)
+ add("icon_file", icon)
+ mw.Close()
+
+ r := httptest.NewRequest("POST", "/admin/site", &body)
+ r.Header.Set("Content-Type", mw.FormDataContentType())
+ for _, c := range session {
+ r.AddCookie(c)
+ }
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ return w.Code
+}
+
+// A save that was refused must change nothing. Both images are written to the
+// config directory, so anything refused after one of them would leave the new
+// file there, serving from the next restart.
+func TestARefusedSaveKeepsTheImages(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addAdmin(t, f, "alice")
+ h := f.routes()
+ session := codeSession(t, f, h, "alice", "hunter2hunter2")
+
+ kept := []byte("\x89PNG\r\n\x1a\n" + strings.Repeat("k", 64))
+ refused := []byte("\x89PNG\r\n\x1a\n" + strings.Repeat("r", 64))
+ svg := []byte(`<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"></svg>`)
+
+ if code := saveSite(t, f, h, session, "Good Name", kept, nil); code != http.StatusSeeOther {
+ t.Fatalf("saving a logo: status %d", code)
+ }
+ if code := saveSite(t, f, h, session, `Bad"Name`, refused, nil); code != http.StatusBadRequest {
+ t.Fatalf("a name with a quote in it: status %d", code)
+ }
+ // And a logo this save would otherwise have taken, refused for the favicon
+ // posted beside it.
+ if code := saveSite(t, f, h, session, "Good Name", refused, svg); code != http.StatusBadRequest {
+ t.Fatalf("an svg favicon: status %d", code)
+ }
+
+ // What the running process serves, and what the next start would.
+ if got := f.site.Load().Logo.Body; !bytes.Equal(got, kept) {
+ t.Error("the refused image is already being served")
+ }
+ cfg, err := readConfig(f.store.File("forge.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ site, err := loadSite(f.store.configDir, cfg.SiteName, cfg.SiteDesc, cfg.Logo, cfg.Icon)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(site.Logo.Body, kept) {
+ t.Error("the refused image is what a restart would serve")
+ }
+
+ // A save that fails after the image has landed: with no config file to
+ // rewrite, the config write fails, and the image has to be put back.
+ if err := os.Remove(f.store.File("forge.json")); err != nil {
+ t.Fatal(err)
+ }
+ if code := saveSite(t, f, h, session, "Good Name", refused, nil); code != http.StatusInternalServerError {
+ t.Fatalf("a save with no config file to write: status %d", code)
+ }
+ if got, err := os.ReadFile(f.store.File("logo")); err != nil || !bytes.Equal(got, kept) {
+ t.Errorf("the failed save left %d bytes where the next start reads the logo: %v", len(got), err)
+ }
+}
+
+// A scalar override is an overlay. Reading through it to rewrite the file
+// would turn the override into the file's own value, permanently.
+func TestSavingTheSiteKeepsTheEnvironmentOut(t *testing.T) {
+ t.Setenv("GIT_FORGE_LISTEN", "127.0.0.1:9999")
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addAdmin(t, f, "alice")
+ if f.cfg.Listen != "127.0.0.1:9999" {
+ t.Fatalf("the override did not take: the process is on %q", f.cfg.Listen)
+ }
+ h := f.routes()
+ session := codeSession(t, f, h, "alice", "hunter2hunter2")
+
+ form := url.Values{
+ "csrf": {f.csrf(withCookies("/", session))},
+ "site_name": {"Renamed"}, "site_desc": {""},
+ }
+ if w := post(h, "/admin/site", form, session); w.Code != http.StatusSeeOther {
+ t.Fatalf("saving the site: status %d", w.Code)
+ }
+ data, err := os.ReadFile(f.store.File("forge.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(data), `"site_name": "Renamed"`) {
+ t.Fatalf("the name was not saved at all:\n%s", data)
+ }
+ if strings.Contains(string(data), "9999") {
+ t.Errorf("the environment was written into the file:\n%s", data)
+ }
+}
+
+// /setup hands out the global admin flag, so two people posting the form at
+// the same moment must not both get through it.
+func TestSetupClosesOnce(t *testing.T) {
+ f := testForge(t)
+ token := randomToken(16)
+ f.setupToken.Store(&token)
+ h := f.routes()
+
+ var wg sync.WaitGroup
+ for i := range 4 {
+ wg.Add(2)
+ go func() {
+ defer wg.Done()
+ post(h, "/setup", url.Values{
+ "token": {token}, "name": {fmt.Sprintf("admin%d", i)},
+ "password": {"hunter2hunter2"}, "again": {"hunter2hunter2"},
+ }, nil)
+ }()
+ // And a reader of the same field, for the race detector.
+ go func() { defer wg.Done(); get(h, "/setup", nil) }()
+ }
+ wg.Wait()
+
+ if n := len(f.store.Users()); n != 1 {
+ t.Errorf("%d accounts came out of one setup form", n)
+ }
+ if code := get(h, "/setup", nil).Code; code != http.StatusNotFound {
+ t.Errorf("/setup is still there after setup: status %d", code)
+ }
+}
+
+// An uploaded image is bytes handed back to a browser.
+func TestImageIsCheckedBeforeItIsKept(t *testing.T) {
+ png := []byte("\x89PNG\r\n\x1a\n" + strings.Repeat("x", 64))
+ if err := checkImage(png); err != nil {
+ t.Errorf("a png was refused: %v", err)
+ }
+ svg := []byte(`<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`)
+ if err := checkImage(svg); err == nil {
+ t.Error("an svg was accepted, and svg can carry script")
+ }
+ if err := checkImage([]byte("#!/bin/sh\nrm -rf /\n")); err == nil {
+ t.Error("a shell script was accepted as an image")
+ }
+ if err := checkImage(append(png, make([]byte, maxImage)...)); err == nil {
+ t.Error("an oversized image was accepted")
+ }
+}
+
+// One shape in repos.json, not two. Driven through the route, because what
+// adopting records is the settings it was already being served with.
+func TestAdoptedRepoLooksLikeACreatedOne(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addAdmin(t, f, "alice")
+ if err := gitInit(filepath.Join(f.store.reposDir, "orphan.git"), true); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.store.scanRepos(); err != nil {
+ t.Fatal(err)
+ }
+
+ h := f.routes()
+ session := codeSession(t, f, h, "alice", "hunter2hunter2")
+ form := url.Values{"csrf": {f.csrf(withCookies("/", session))}, "path": {"orphan"}}
+ if w := post(h, "/admin/repo/adopt", form, session); w.Code != http.StatusSeeOther {
+ t.Fatalf("adopting: status %d", w.Code)
+ }
+ repo, ok := f.store.Repo("orphan")
+ if !ok || repo.Visibility != visPrivate || repo.Issues != featDisabled || repo.Wiki != featDisabled {
+ t.Errorf("adopting changed how it is served: %+v", repo)
+ }
+ data, err := os.ReadFile(f.store.File("repos.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(data), "null") {
+ t.Errorf("an adopted repository is written differently from a created one:\n%s", data)
+ }
+ if w := post(h, "/admin/repo/adopt", form, session); w.Code != http.StatusBadRequest {
+ t.Errorf("adopting a repository that already has an entry: status %d", w.Code)
+ }
+}
+
+// The sweep runs across every repository rather than the one an admin happens
+// to be looking at, and it takes nobody else's grant with it.
+func TestDeletingAnAccountTakesItsGrants(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addUser(t, f, "bob", "hunter2hunter2")
+ addAdmin(t, f, "alice")
+ for _, path := range []string{"one", "two"} {
+ addRepo(t, f, path, Repo{
+ Visibility: visPrivate, Issues: featDisabled, Wiki: featDisabled,
+ Grants: map[string]string{"alice": grantAdmin, "bob": grantWrite},
+ })
+ }
+
+ h := f.routes()
+ session := codeSession(t, f, h, "alice", "hunter2hunter2")
+ form := func(name string) url.Values {
+ return url.Values{
+ "csrf": {f.csrf(withCookies("/", session))}, "name": {name},
+ "code": {freshCode(t, f, "alice", knownSecret)},
+ }
+ }
+ // Capitalised, because the shift key is forgiven wherever a name is typed.
+ if w := post(h, "/admin/user/delete", form("Bob"), session); w.Code != http.StatusSeeOther {
+ t.Fatalf("deleting bob: status %d", w.Code)
+ }
+ if _, ok := f.store.User("bob"); ok {
+ t.Error("the account survived being deleted")
+ }
+ for _, path := range []string{"one", "two"} {
+ repo, _ := f.store.Repo(path)
+ if _, ok := repo.Grants["bob"]; ok {
+ t.Errorf("%s still grants to a deleted account: %v", path, repo.Grants)
+ }
+ if repo.Grants["alice"] != grantAdmin {
+ t.Errorf("%s lost somebody else's grant: %v", path, repo.Grants)
+ }
+ }
+ // Administration is web only, so the last way in cannot be deleted either.
+ if w := post(h, "/admin/user/delete", form("alice"), session); w.Code != http.StatusBadRequest {
+ t.Errorf("deleting the last admin: status %d", w.Code)
+ }
+ if _, ok := f.store.User("alice"); !ok {
+ t.Error("the last admin was deleted anyway")
+ }
+}
+
+// A grant on an unadopted directory has to record the settings it is already
+// served with, rather than fail validation on the fields it does not have.
+func TestGrantOnAnUnadoptedRepo(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addUser(t, f, "bob", "hunter2hunter2")
+ addAdmin(t, f, "alice")
+ if err := gitInit(filepath.Join(f.store.reposDir, "orphan.git"), true); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.store.scanRepos(); err != nil {
+ t.Fatal(err)
+ }
+
+ h := f.routes()
+ session := codeSession(t, f, h, "alice", "hunter2hunter2")
+ form := url.Values{
+ "csrf": {f.csrf(withCookies("/", session))}, "account": {"bob"},
+ "level": {grantRead}, "code": {freshCode(t, f, "alice", knownSecret)},
+ }
+ if w := post(h, "/orphan/settings/grant", form, session); w.Code != http.StatusSeeOther {
+ t.Fatalf("granting on an unadopted repository: status %d", w.Code)
+ }
+ repo, ok := f.store.Repo("orphan")
+ if !ok || repo.Grants["bob"] != grantRead {
+ t.Fatalf("the grant did not land: %+v", repo)
+ }
+ if repo.Visibility != visPrivate || repo.Issues != featDisabled || repo.Wiki != featDisabled {
+ t.Errorf("adopting it by granting changed how it is served: %+v", repo)
+ }
+}
+
+// A repository admin holding no authenticator is refused rather than quietly
+// exempted, and the page says so rather than drawing a form that cannot work.
+func TestGrantNeedsAnAuthenticator(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "owner", "hunter2hunter2")
+ addUser(t, f, "bob", "hunter2hunter2")
+ addRepo(t, f, "notes", Repo{
+ Visibility: visPrivate, Issues: featDisabled, Wiki: featDisabled,
+ Grants: map[string]string{"owner": grantAdmin},
+ })
+ h := f.routes()
+ session := signIn(t, f, h, "owner")
+
+ page := get(h, "/notes/settings", session)
+ if page.Code != http.StatusOK {
+ t.Fatalf("the settings page: status %d", page.Code)
+ }
+ if body := page.Body.String(); !strings.Contains(body, "/settings/totp") {
+ t.Error("the page does not say where to enrol an authenticator")
+ }
+ if strings.Contains(page.Body.String(), `name="level"`) {
+ t.Error("the page draws a grant form this account cannot submit")
+ }
+
+ form := url.Values{
+ "csrf": {f.csrf(withCookies("/", session))}, "account": {"bob"}, "level": {grantRead},
+ }
+ if w := post(h, "/notes/settings/grant", form, session); w.Code != http.StatusForbidden {
+ t.Errorf("a grant with no authenticator behind it: status %d", w.Code)
+ }
+ if repo, _ := f.store.Repo("notes"); repo.Grants["bob"] != "" {
+ t.Error("the grant landed anyway")
+ }
+}
+
+// Creating a repository, turning a feature on, and deleting the lot are the
+// three most consequential things an admin does, and none of them was driven
+// through its route by anything but the container smoke test.
+func TestRepoLifeThroughTheRoutes(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addAdmin(t, f, "alice")
+ h := f.routes()
+ session := codeSession(t, f, h, "alice", "hunter2hunter2")
+ form := func(v url.Values) url.Values {
+ v.Set("csrf", f.csrf(withCookies("/", session)))
+ return v
+ }
+
+ create := form(url.Values{
+ "section": {"infra"}, "name": {"dns"}, "desc": {"zones"},
+ "visibility": {visPrivate}, "issues": {featMembers}, "wiki": {featDisabled},
+ })
+ if w := post(h, "/admin/repo/create", create, session); w.Code != http.StatusSeeOther {
+ t.Fatalf("creating a repository in a section: status %d, %s", w.Code, w.Body)
+ }
+ repo, ok := f.store.Repo("infra/dns")
+ if !ok || repo.Desc != "zones" || repo.Visibility != visPrivate || repo.Issues != featMembers {
+ t.Fatalf("the record is not what the form said: %+v", repo)
+ }
+ // The wiki was off, so it has no repository yet.
+ if _, err := os.Stat(filepath.Join(f.store.reposDir, "infra/dns.wiki.git")); err == nil {
+ t.Error("a disabled wiki was given a repository anyway")
+ }
+ // A name that is not a usable path is refused rather than coerced.
+ bad := form(url.Values{"name": {"Notes"}, "visibility": {visPrivate}, "issues": {featDisabled}, "wiki": {featDisabled}})
+ if w := post(h, "/admin/repo/create", bad, session); w.Code != http.StatusBadRequest {
+ t.Errorf("an uppercase name: status %d", w.Code)
+ }
+
+ // Turning the wiki on has to bring its repository with it, or the tab
+ // leads to a page with nothing behind it.
+ save := form(url.Values{
+ "desc": {"zones"}, "visibility": {visPrivate},
+ "issues": {featMembers}, "wiki": {featMembers}, "labels": {"bug\n bug \n\nazz"},
+ "code": {freshCode(t, f, "alice", knownSecret)}, // the wiki knob moves
+ })
+ if w := post(h, "/infra/dns/settings", save, session); w.Code != http.StatusSeeOther {
+ t.Fatalf("saving settings: status %d", w.Code)
+ }
+ if _, err := os.Stat(filepath.Join(f.store.reposDir, "infra/dns.wiki.git")); err != nil {
+ t.Errorf("turning the wiki on left it with no repository: %v", err)
+ }
+ if code := get(h, "/infra/dns/wiki", session).Code; code != http.StatusOK {
+ t.Errorf("the wiki that was just turned on answered %d", code)
+ }
+ // One label once, sorted, and the blank line is not a label.
+ if repo, _ := f.store.Repo("infra/dns"); len(repo.Labels) != 2 ||
+ repo.Labels[0] != "azz" || repo.Labels[1] != "bug" {
+ t.Errorf("the labels are %q", repo.Labels)
+ }
+ // users on a private repository is refused, not coerced.
+ if w := post(h, "/infra/dns/settings", form(url.Values{
+ "visibility": {visPrivate}, "issues": {featUsers}, "wiki": {featDisabled},
+ }), session); w.Code != http.StatusBadRequest {
+ t.Errorf("issues set to users on a private repository: status %d", w.Code)
+ }
+
+ // Deletion takes all three directories and every token pointing at the
+ // path, because the path can be created again.
+ addToken(t, f, "alice", "infra/dns")
+ addToken(t, f, "alice", "elsewhere")
+ del := form(url.Values{"confirm": {"infra/dns"}, "code": {freshCode(t, f, "alice", knownSecret)}})
+ if w := post(h, "/infra/dns/settings/delete", del, session); w.Code != http.StatusSeeOther {
+ t.Fatalf("deleting: status %d", w.Code)
+ }
+ if _, ok := f.store.Repo("infra/dns"); ok {
+ t.Error("the repository survived deletion")
+ }
+ for _, suffix := range []string{".git", ".issues.git", ".wiki.git"} {
+ if _, err := os.Stat(filepath.Join(f.store.reposDir, "infra/dns"+suffix)); err == nil {
+ t.Errorf("infra/dns%s is still on disk", suffix)
+ }
+ }
+ // The section it was alone in goes with it.
+ if _, err := os.Stat(filepath.Join(f.store.reposDir, "infra")); err == nil {
+ t.Error("the empty section was left behind")
+ }
+ u, _ := f.store.User("alice")
+ if len(u.Tokens) != 1 || u.Tokens[0].Repo != "elsewhere" {
+ t.Errorf("the token sweep took the wrong ones: %+v", u.Tokens)
+ }
+}
+
+// An admin never handles a password: the account is made with none and a one
+// time link is displayed for the admin to convey.
+func TestUserCreateThroughTheRoute(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addAdmin(t, f, "alice")
+ h := f.routes()
+ session := codeSession(t, f, h, "alice", "hunter2hunter2")
+ form := func(name, code string) url.Values {
+ return url.Values{
+ "csrf": {f.csrf(withCookies("/", session))}, "name": {name},
+ "email": {"bob@example.com"}, "code": {code},
+ }
+ }
+
+ // It hands out a way in, so it costs the acting admin's own code.
+ if w := post(h, "/admin/user/create", form("bob", "000000"), session); w.Code != http.StatusForbidden {
+ t.Errorf("creating an account with a wrong code: status %d", w.Code)
+ }
+ if _, ok := f.store.User("bob"); ok {
+ t.Fatal("the account was made anyway")
+ }
+
+ w := post(h, "/admin/user/create", form("Bob", freshCode(t, f, "alice", knownSecret)), session)
+ if w.Code != http.StatusOK {
+ t.Fatalf("creating an account: status %d", w.Code)
+ }
+ bob, ok := f.store.User("bob")
+ if !ok {
+ t.Fatalf("no account was made: %v", f.store.Users())
+ }
+ if bob.Admin || bob.Password != "" || bob.Origin != originLocal {
+ t.Errorf("a new account should hold no flag and no password: %+v", bob)
+ }
+ // Off the page as the template wrote it, so the separators are escaped.
+ link := strings.ReplaceAll(between(w.Body.String(), "<pre>", "</pre>"), "&amp;", "&")
+ if !strings.Contains(link, "/set-password?") {
+ t.Fatalf("no one time link on the page: %q", link)
+ }
+ // And it works, once.
+ if _, _, ok := f.checkSetPassword(mustQuery(t, link)); !ok {
+ t.Error("the link the admin was given does not verify")
+ }
+ if w := post(h, "/admin/user/create", form("bob", freshCode(t, f, "alice", knownSecret)), session); w.Code != http.StatusBadRequest {
+ t.Errorf("creating the same account twice: status %d", w.Code)
+ }
+}
+
+func mustQuery(t *testing.T, link string) url.Values {
+ t.Helper()
+ u, err := url.Parse(link)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return u.Query()
+}
+
+// The logo an admin uploads is served back from the config directory, and an
+// empty setting means cgit's own, which nothing links to.
+func TestUploadedLogoIsServed(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addAdmin(t, f, "alice")
+ h := f.routes()
+ session := codeSession(t, f, h, "alice", "hunter2hunter2")
+
+ if code := get(h, "/logo", nil).Code; code != http.StatusNotFound {
+ t.Errorf("/logo with none configured answered %d", code)
+ }
+ logo := []byte("\x89PNG\r\n\x1a\n" + strings.Repeat("p", 64))
+ if code := saveSite(t, f, h, session, "Forge", logo, nil); code != http.StatusSeeOther {
+ t.Fatalf("saving a logo: status %d", code)
+ }
+
+ got := get(h, "/logo", nil)
+ if got.Code != http.StatusOK || !bytes.Equal(got.Body.Bytes(), logo) {
+ t.Errorf("/logo answered %d with %d bytes", got.Code, got.Body.Len())
+ }
+ if kind := got.Header().Get("Content-Type"); kind != "image/png" {
+ t.Errorf("/logo is served as %q", kind)
+ }
+ // And the header points at it rather than at cgit's.
+ if page := get(h, "/", nil).Body.String(); !strings.Contains(page, `src="/logo"`) {
+ t.Error("the header does not point at the uploaded logo")
+ }
+}
+
+// The favicon is its own setting, served with the type its bytes say, and
+// cgit's own until one is uploaded.
+func TestUploadedFaviconIsServed(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addAdmin(t, f, "alice")
+ h := f.routes()
+ session := codeSession(t, f, h, "alice", "hunter2hunter2")
+
+ // Guarded, because cgit's own file is what there is to fall back to.
+ if want := f.cgit.favicon; len(want) > 0 {
+ if got := get(h, "/favicon.ico", nil); !bytes.Equal(got.Body.Bytes(), want) {
+ t.Errorf("/favicon.ico with none configured answered %d, not cgit's own", got.Code)
+ }
+ }
+
+ icon := []byte("\x89PNG\r\n\x1a\n" + strings.Repeat("i", 64))
+ if code := saveSite(t, f, h, session, "Forge", nil, icon); code != http.StatusSeeOther {
+ t.Fatalf("saving a favicon: status %d", code)
+ }
+
+ got := get(h, "/favicon.ico", nil)
+ if got.Code != http.StatusOK || !bytes.Equal(got.Body.Bytes(), icon) {
+ t.Errorf("/favicon.ico answered %d with %d bytes", got.Code, got.Body.Len())
+ }
+ if kind := got.Header().Get("Content-Type"); kind != "image/png" {
+ t.Errorf("/favicon.ico is served as %q", kind)
+ }
+ // The logo is its own setting, and posting one did not touch it.
+ if code := get(h, "/logo", nil).Code; code != http.StatusNotFound {
+ t.Errorf("uploading a favicon configured a logo too: /logo answered %d", code)
+ }
+
+ // A later save with no file chosen is not a save that clears it.
+ if code := saveSite(t, f, h, session, "Forge", nil, nil); code != http.StatusSeeOther {
+ t.Fatalf("a save with no file chosen: status %d", code)
+ }
+ if got := get(h, "/favicon.ico", nil); !bytes.Equal(got.Body.Bytes(), icon) {
+ t.Error("a save with no file chosen dropped the favicon")
+ }
+}
+
+// Who may read or write outlives the session that changes it, so moving one of
+// those knobs costs a code. A description does not.
+func TestKnobChangeNeedsACode(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "owner", "hunter2hunter2")
+ addTOTP(t, f, "owner")
+ addRepo(t, f, "notes", Repo{
+ Desc: "before", Visibility: visPrivate, Issues: featDisabled, Wiki: featDisabled,
+ Labels: []string{}, Grants: map[string]string{"owner": grantAdmin},
+ })
+ h := f.routes()
+ session := codeSession(t, f, h, "owner", "hunter2hunter2")
+ form := func(v url.Values) url.Values {
+ v.Set("csrf", f.csrf(withCookies("/", session)))
+ return v
+ }
+
+ desc := form(url.Values{
+ "desc": {"after"}, "visibility": {visPrivate},
+ "issues": {featDisabled}, "wiki": {featDisabled},
+ })
+ if w := post(h, "/notes/settings", desc, session); w.Code != http.StatusSeeOther {
+ t.Fatalf("a description with no code: status %d", w.Code)
+ }
+ if repo, _ := f.store.Repo("notes"); repo.Desc != "after" {
+ t.Errorf("the description is %q", repo.Desc)
+ }
+
+ open := url.Values{
+ "desc": {"after"}, "visibility": {visPublic},
+ "issues": {featDisabled}, "wiki": {featDisabled},
+ }
+ if w := post(h, "/notes/settings", form(open), session); w.Code != http.StatusForbidden {
+ t.Fatalf("opening the repository with no code: status %d", w.Code)
+ }
+ if repo, _ := f.store.Repo("notes"); repo.Visibility != visPrivate {
+ t.Fatal("a repository was opened without a code")
+ }
+ open.Set("code", freshCode(t, f, "owner", knownSecret))
+ if w := post(h, "/notes/settings", form(open), session); w.Code != http.StatusSeeOther {
+ t.Fatalf("opening the repository with a code: status %d", w.Code)
+ }
+ if repo, _ := f.store.Repo("notes"); repo.Visibility != visPublic {
+ t.Error("the code was right and the repository is still private")
+ }
+}
+
+// A save carries the fields the form owns and nothing else, so a grant handed
+// out while it was open is still there afterwards.
+func TestSettingsSaveKeepsGrants(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "owner", "hunter2hunter2")
+ addTOTP(t, f, "owner")
+ addRepo(t, f, "notes", Repo{
+ Desc: "before", Visibility: visPrivate, Issues: featDisabled, Wiki: featDisabled,
+ Labels: []string{}, Grants: map[string]string{"owner": grantAdmin},
+ })
+ h := f.routes()
+ session := codeSession(t, f, h, "owner", "hunter2hunter2")
+
+ // Straight into repos.json, standing in for a grant that landed while the
+ // request that follows was in flight.
+ err := f.store.UpdateRepos("repos: grant bob", f.store.forgeAuthor(), func(m map[string]Repo) error {
+ entry := m["notes"]
+ entry.Grants = map[string]string{"owner": grantAdmin, "bob": grantWrite}
+ m["notes"] = entry
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ stale, _ := f.store.Repo("notes")
+ stale.Grants = map[string]string{"owner": grantAdmin} // what the request read
+ f.repoSettingsSave(httptest.NewRecorder(), withForm(f, session, url.Values{
+ "desc": {"after"}, "visibility": {visPrivate},
+ "issues": {featDisabled}, "wiki": {featDisabled},
+ }), "notes", stale)
+
+ repo, _ := f.store.Repo("notes")
+ if repo.Desc != "after" {
+ t.Errorf("the description is %q", repo.Desc)
+ }
+ if repo.Grants["bob"] != grantWrite {
+ t.Errorf("the save dropped a grant it never read: %v", repo.Grants)
+ }
+}
+
+// withForm is a parsed POST, for driving a handler below the router.
+func withForm(f *Forge, session []*http.Cookie, v url.Values) *http.Request {
+ v.Set("csrf", f.csrf(withCookies("/", session)))
+ r := httptest.NewRequest("POST", "/notes/settings", strings.NewReader(v.Encode()))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ for _, c := range session {
+ r.AddCookie(c)
+ }
+ r.ParseForm()
+ return r
+}
diff --git a/auth.go b/auth.go
new file mode 100644
index 0000000..83f9b10
--- /dev/null
+++ b/auth.go
@@ -0,0 +1,510 @@
+package main
+
+// The primitives: hashing, signing, cookies, one time links, TOTP, push
+// tokens, and rate limits.
+
+import (
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha1"
+ "crypto/sha256"
+ "crypto/subtle"
+ "encoding/base32"
+ "encoding/base64"
+ "encoding/binary"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "golang.org/x/crypto/argon2"
+)
+
+// Password hashing parameters, stored in every hash, so old hashes keep
+// verifying.
+const (
+ argonTime = 3
+ argonMemory = 64 * 1024 // KiB
+ argonThreads = 4
+ argonSaltLen = 16
+ argonKeyLen = 32
+
+ minPassword = 8
+)
+
+const (
+ sessionCookie = "session"
+ sessionLifetime = 30 * 24 * time.Hour
+)
+
+// What a form may weigh; field limits are the handlers' own.
+const (
+ // Percent encoding can treble a byte, and the largest field is maxBody.
+ maxForm = 4*maxBody + 64<<10
+ // Both images at their limit plus one a little over, so it still gets the
+ // sentence saying so.
+ maxUpload = 3 * maxImage
+)
+
+// argon2id is expensive in memory by design, so concurrent hashes are bounded:
+// otherwise an unauthenticated POST to /login sets the peak.
+var hashing = make(chan struct{}, 2)
+
+func hashSlot() func() {
+ hashing <- struct{}{}
+ return func() { <-hashing }
+}
+
+// hashPassword returns a PHC string: $argon2id$v=19$m=...,t=...,p=...$salt$hash.
+func hashPassword(password string) string {
+ defer hashSlot()()
+
+ salt := make([]byte, argonSaltLen)
+ rand.Read(salt) // crypto/rand does not fail, it panics if the kernel does
+ key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
+ b64 := base64.RawStdEncoding.EncodeToString
+ return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
+ argon2.Version, argonMemory, argonTime, argonThreads, b64(salt), b64(key))
+}
+
+// passwordOK hashes even when the encoding is unusable, so a wrong account
+// name costs the same as a wrong password.
+func passwordOK(encoded, password string) bool {
+ defer hashSlot()()
+
+ salt, want, time, memory, threads, err := parseHash(encoded)
+ if err != nil {
+ salt, want, time, memory, threads = make([]byte, argonSaltLen), make([]byte, argonKeyLen), argonTime, argonMemory, argonThreads
+ }
+ got := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(want)))
+ return err == nil && subtle.ConstantTimeCompare(got, want) == 1
+}
+
+func parseHash(encoded string) (salt, key []byte, time, memory uint32, threads uint8, err error) {
+ fail := func(why string) ([]byte, []byte, uint32, uint32, uint8, error) {
+ return nil, nil, 0, 0, 0, fmt.Errorf("argon2id hash: %s", why)
+ }
+ parts := strings.Split(encoded, "$")
+ if len(parts) != 6 || parts[0] != "" || parts[1] != "argon2id" {
+ return fail("not a $argon2id$ string")
+ }
+ var version int
+ if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil || version != argon2.Version {
+ return fail("unsupported version")
+ }
+ var m, t uint32
+ var p uint8
+ if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil {
+ return fail("unreadable parameters")
+ }
+ salt, err = base64.RawStdEncoding.DecodeString(parts[4])
+ if err != nil {
+ return fail("unreadable salt")
+ }
+ key, err = base64.RawStdEncoding.DecodeString(parts[5])
+ if err != nil || len(key) == 0 {
+ return fail("unreadable hash")
+ }
+ return salt, key, t, m, p, nil
+}
+
+// mac signs fields for one purpose, so a session cookie is never a reset link;
+// the separator keeps two field lists apart.
+func (f *Forge) mac(purpose string, fields ...string) string {
+ h := hmac.New(sha256.New, f.store.cookieKey)
+ h.Write([]byte(purpose))
+ for _, s := range fields {
+ h.Write([]byte{0})
+ h.Write([]byte(s))
+ }
+ return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
+}
+
+// There is no session store: the cookie carries the name and expiry, and the
+// epoch is mixed into the signature, so bumping it invalidates every cookie.
+func (f *Forge) setSession(w http.ResponseWriter, name string, epoch int) string {
+ expiry := time.Now().Add(sessionLifetime)
+ unix := strconv.FormatInt(expiry.Unix(), 10)
+ value := base64.RawURLEncoding.EncodeToString([]byte(name)) + "." + unix + "." +
+ f.mac("session", name, unix, strconv.Itoa(epoch))
+
+ http.SetCookie(w, &http.Cookie{
+ Name: sessionCookie,
+ Value: value,
+ Path: "/",
+ Expires: expiry,
+ MaxAge: int(sessionLifetime / time.Second),
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ Secure: strings.HasPrefix(f.cfg.BaseURL, "https://"),
+ })
+ return value
+}
+
+func (f *Forge) clearSession(w http.ResponseWriter) {
+ http.SetCookie(w, &http.Cookie{
+ Name: sessionCookie,
+ Value: "",
+ Path: "/",
+ MaxAge: -1,
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ Secure: strings.HasPrefix(f.cfg.BaseURL, "https://"),
+ })
+}
+
+func (f *Forge) user(r *http.Request) (string, User, bool) {
+ c, err := r.Cookie(sessionCookie)
+ if err != nil {
+ return "", User{}, false
+ }
+ parts := strings.Split(c.Value, ".")
+ if len(parts) != 3 {
+ return "", User{}, false
+ }
+ raw, err := base64.RawURLEncoding.DecodeString(parts[0])
+ if err != nil {
+ return "", User{}, false
+ }
+ unix, err := strconv.ParseInt(parts[1], 10, 64)
+ if err != nil || time.Now().After(time.Unix(unix, 0)) {
+ return "", User{}, false
+ }
+ name := string(raw)
+ u, ok := f.store.User(name)
+ if !ok {
+ return "", User{}, false
+ }
+ want := f.mac("session", name, parts[1], strconv.Itoa(u.Epoch))
+ if !hmac.Equal([]byte(parts[2]), []byte(want)) {
+ return "", User{}, false
+ }
+ return name, u, true
+}
+
+// csrf is the form token, bound to the session cookie.
+func (f *Forge) csrf(r *http.Request) string {
+ c, err := r.Cookie(sessionCookie)
+ if err != nil {
+ return f.mac("csrf", "")
+ }
+ return f.mac("csrf", c.Value)
+}
+
+// checkPost gates the HTML forms, not the git endpoints. Signed in, the form
+// token is required; signed out, only the Origin header can say, and a client
+// sending none is let through.
+func (f *Forge) checkPost(w http.ResponseWriter, r *http.Request) bool {
+ // ParseMultipartForm's argument bounds memory only; the rest goes to a
+ // temporary file, so MaxBytesReader is what bounds the disk.
+ var err error
+ if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
+ r.Body = http.MaxBytesReader(w, r.Body, maxUpload)
+ err = r.ParseMultipartForm(maxImage)
+ } else {
+ r.Body = http.MaxBytesReader(w, r.Body, maxForm)
+ err = r.ParseForm()
+ }
+ if err != nil {
+ f.fail(w, r, http.StatusBadRequest, "unreadable form, or one too big to be one")
+ return false
+ }
+ if origin := r.Header.Get("Origin"); origin != "" && origin != f.cfg.BaseURL {
+ f.fail(w, r, http.StatusForbidden, "cross-site post refused")
+ return false
+ }
+ if _, _, ok := f.user(r); ok {
+ if !hmac.Equal([]byte(r.PostFormValue("csrf")), []byte(f.csrf(r))) {
+ f.fail(w, r, http.StatusForbidden, "stale form, reload the page and try again")
+ return false
+ }
+ }
+ return true
+}
+
+// maxTokens bounds users.json and the audit log, and keeps a three byte id
+// unambiguous.
+const maxTokens = 32
+
+// Only the digest is kept.
+func mintToken(repo string) (secret string, token Token) {
+ secret = randomToken(24)
+ return secret, Token{
+ ID: randomToken(3), Repo: repo, Hash: tokenHash(secret), Created: today(),
+ }
+}
+
+// today is the granularity every date on a token is kept at.
+func today() string { return time.Now().UTC().Format("2006-01-02") }
+
+// tokenHash is SHA-256, not argon2id: a token is high entropy, and this runs
+// on every git request.
+func tokenHash(secret string) string {
+ sum := sha256.Sum256([]byte(secret))
+ return hex.EncodeToString(sum[:])
+}
+
+// checkToken returns the account's own name, not the one typed: grants and
+// REMOTE_USER key off it.
+func (f *Forge) checkToken(given, secret, repo string) (string, User, bool) {
+ name := accountName(given)
+ u, ok := f.store.User(name)
+ if !ok {
+ return "", User{}, false
+ }
+ want := tokenHash(secret)
+ for _, t := range u.Tokens {
+ if t.Repo == repo && hmac.Equal([]byte(t.Hash), []byte(want)) {
+ f.touchToken(name, t)
+ return name, u, true
+ }
+ }
+ return "", User{}, false
+}
+
+// touchToken writes once a day: git makes several requests per clone.
+func (f *Forge) touchToken(name string, used Token) {
+ if used.Used == today() {
+ return
+ }
+ err := f.store.touchUser(name, func(acct *User) error {
+ for i := range acct.Tokens {
+ if acct.Tokens[i].ID == used.ID {
+ acct.Tokens[i].Used = today()
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ log.Printf("tokens: %v", err) // not worth failing a push over
+ }
+}
+
+// TOTP is RFC 6238 with the parameters every authenticator assumes: HMAC-SHA1,
+// six digits, thirty seconds.
+const (
+ totpDigits = 6
+ totpPeriod = 30 * time.Second
+ totpSkew = 1 // steps either side
+ totpSecretLen = 20
+ recoveryCodes = 10
+ totpPendLife = 5 * time.Minute
+)
+
+// totpSecret is base32 without padding, as an otpauth URI carries it.
+func totpSecret() string {
+ raw := make([]byte, totpSecretLen)
+ rand.Read(raw)
+ return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw)
+}
+
+func totpCode(secret string, at time.Time) string {
+ raw, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(secret))
+ if err != nil {
+ return ""
+ }
+ counter := make([]byte, 8)
+ binary.BigEndian.PutUint64(counter, uint64(at.Unix())/uint64(totpPeriod.Seconds()))
+
+ mac := hmac.New(sha1.New, raw)
+ mac.Write(counter)
+ sum := mac.Sum(nil)
+ offset := sum[len(sum)-1] & 0x0f
+ value := binary.BigEndian.Uint32(sum[offset:offset+4]) & 0x7fffffff
+ return fmt.Sprintf("%0*d", totpDigits, value%1000000)
+}
+
+// validSecret is base32 of exactly the bytes this forge mints. The secret
+// arrives in a form, so its strength is checked here.
+func validSecret(secret string) bool {
+ raw, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
+ return err == nil && len(raw) == totpSecretLen
+}
+
+// totpStep is the time step the code is right for, or -1. One step either
+// side of now is accepted, because clocks disagree.
+func totpStep(secret, code string, now time.Time) int64 {
+ code = strings.TrimSpace(code)
+ for skew := -totpSkew; skew <= totpSkew; skew++ {
+ at := now.Add(time.Duration(skew) * totpPeriod)
+ want := totpCode(secret, at)
+ if want != "" && hmac.Equal([]byte(code), []byte(want)) {
+ return at.Unix() / int64(totpPeriod.Seconds())
+ }
+ }
+ return -1
+}
+
+var errBadCode = errors.New("that code is not right, or was used already")
+
+// codeOK accepts a code once: the step it was accepted at is recorded on the
+// account and a step at or below it is refused, so a code seen over a shoulder
+// cannot be replayed within its window. Recorded without a commit, like a
+// token's last use. A record that cannot be written refuses the code.
+func (f *Forge) codeOK(name string, u User, code string) bool {
+ if u.TOTP == nil {
+ return false
+ }
+ step := totpStep(u.TOTP.Secret, code, time.Now())
+ if step < 0 || step <= u.TOTP.Used {
+ return false
+ }
+ err := f.store.touchUser(name, func(acct *User) error {
+ if acct.TOTP == nil || step <= acct.TOTP.Used {
+ return errBadCode
+ }
+ acct.TOTP.Used = step
+ return nil
+ })
+ if err != nil && !errors.Is(err, errBadCode) {
+ log.Printf("totp: %v", err)
+ }
+ return err == nil
+}
+
+// totpURI is given as text: a QR code is useless in a terminal browser.
+func (f *Forge) totpURI(name, secret string) string {
+ label := url.PathEscape(f.siteName() + ":" + name)
+ q := url.Values{
+ "secret": {secret},
+ "issuer": {f.siteName()},
+ "algorithm": {"SHA1"},
+ "digits": {strconv.Itoa(totpDigits)},
+ "period": {strconv.Itoa(int(totpPeriod.Seconds()))},
+ }
+ return "otpauth://totp/" + label + "?" + q.Encode()
+}
+
+// newRecoveryCodes returns the codes to show once and the digests to keep.
+func newRecoveryCodes() (show []string, keep []string) {
+ for range recoveryCodes {
+ code := randomToken(5)
+ show = append(show, code)
+ keep = append(keep, tokenHash(code))
+ }
+ return show, keep
+}
+
+func randomToken(n int) string {
+ b := make([]byte, n)
+ rand.Read(b)
+ return hex.EncodeToString(b)
+}
+
+// A first password and a reset are the same link with different lives.
+const (
+ setPasswordLife = 48 * time.Hour
+ resetLife = time.Hour
+)
+
+// passwordLink is stateless: a MAC over the account, an expiry, and the
+// current password hash, so it dies when the password changes.
+func (f *Forge) passwordLink(name string, u User, life time.Duration) string {
+ expires := strconv.FormatInt(time.Now().Add(life).Unix(), 10)
+ q := url.Values{
+ "name": {name},
+ "expires": {expires},
+ "t": {f.mac("set-password", name, expires, u.Password)},
+ }
+ return f.cfg.BaseURL + "/set-password?" + q.Encode()
+}
+
+func (f *Forge) checkSetPassword(v url.Values) (string, User, bool) {
+ name, expires := v.Get("name"), v.Get("expires")
+ unix, err := strconv.ParseInt(expires, 10, 64)
+ if err != nil || time.Now().After(time.Unix(unix, 0)) {
+ return "", User{}, false
+ }
+ u, ok := f.store.User(name)
+ if !ok {
+ return "", User{}, false
+ }
+ want := f.mac("set-password", name, expires, u.Password)
+ if !hmac.Equal([]byte(v.Get("t")), []byte(want)) {
+ return "", User{}, false
+ }
+ return name, u, true
+}
+
+const confirmEmailLife = 24 * time.Hour
+
+// confirmEmailLink is bound to the address, so changing it kills the link.
+func (f *Forge) confirmEmailLink(name string, u User) string {
+ expires := strconv.FormatInt(time.Now().Add(confirmEmailLife).Unix(), 10)
+ q := url.Values{
+ "name": {name},
+ "expires": {expires},
+ "t": {f.mac("confirm-email", name, expires, u.Email)},
+ }
+ return f.cfg.BaseURL + "/confirm-email?" + q.Encode()
+}
+
+// Every budget, per hour. Constants: nobody tunes these.
+const (
+ loginsPerHour = 60
+ totpPerHour = 30
+ resetsPerHour = 5
+ issuesPerHour = 20
+ commentsPerHour = 60
+ // maxTokens bounds what is kept; this bounds what is written to get there.
+ tokensPerHour = 20
+)
+
+// limiter is a fixed window counter, per account and per address. In memory,
+// so it resets on restart.
+type limiter struct {
+ mu sync.Mutex
+ opened time.Time
+ counts map[string]int
+}
+
+func newLimiter() *limiter {
+ return &limiter{opened: time.Now(), counts: map[string]int{}}
+}
+
+func (l *limiter) hit(key string, perHour int) bool {
+ l.charge(key)
+ return !l.over(key, perHour)
+}
+
+// charge counts; over asks without counting, for budgets spent only by
+// failures.
+func (l *limiter) charge(key string) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ l.rollLocked()
+ l.counts[key]++
+}
+
+func (l *limiter) over(key string, perHour int) bool {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ l.rollLocked()
+ return l.counts[key] > perHour
+}
+
+// Both callers roll the window: a budget spent only by failures never reaches
+// charge once it has run out.
+func (l *limiter) rollLocked() {
+ if time.Since(l.opened) >= time.Hour {
+ l.opened, l.counts = time.Now(), map[string]int{}
+ }
+}
+
+// allow charges both the account and the address.
+func (l *limiter) allow(addr string, v Viewer, perHour int) bool {
+ ok := l.hit("ip:"+addr, perHour)
+ if v.SignedIn() {
+ ok = l.hit(accountKey(v.Name), perHour) && ok
+ }
+ return ok
+}
+
+// accountKey is one account's budget, wherever it is spent from.
+func accountKey(name string) string { return "account:" + name }
diff --git a/auth_test.go b/auth_test.go
new file mode 100644
index 0000000..35d1818
--- /dev/null
+++ b/auth_test.go
@@ -0,0 +1,395 @@
+package main
+
+import (
+ "bytes"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+)
+
+func testForge(t *testing.T) *Forge {
+ t.Helper()
+ store, err := openStore(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ f, err := newForge(store)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Only close removes the runtime directory newForge makes.
+ t.Cleanup(f.cgit.close)
+ return f
+}
+
+func addUser(t *testing.T, f *Forge, name, password string) {
+ t.Helper()
+ hash := hashPassword(password)
+ err := f.store.UpdateUsers("users: add "+name, f.store.forgeAuthor(), func(m map[string]User) error {
+ m[name] = User{Origin: originLocal, Notify: true, Password: hash}
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func post(h http.Handler, path string, form url.Values, cookies []*http.Cookie) *httptest.ResponseRecorder {
+ r := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ for _, c := range cookies {
+ r.AddCookie(c)
+ }
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ return w
+}
+
+// withCookies is a request carrying a session, for asking the forge what the
+// form token bound to it is.
+func withCookies(path string, cookies []*http.Cookie) *http.Request {
+ r := httptest.NewRequest("GET", path, nil)
+ for _, c := range cookies {
+ r.AddCookie(c)
+ }
+ return r
+}
+
+func get(h http.Handler, path string, cookies []*http.Cookie) *httptest.ResponseRecorder {
+ r := httptest.NewRequest("GET", path, nil)
+ for _, c := range cookies {
+ r.AddCookie(c)
+ }
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ return w
+}
+
+func TestPasswordHash(t *testing.T) {
+ hash := hashPassword("correct horse battery staple")
+ if !strings.HasPrefix(hash, "$argon2id$v=19$m=") {
+ t.Fatalf("not a PHC string: %q", hash)
+ }
+ if !passwordOK(hash, "correct horse battery staple") {
+ t.Error("the right password was rejected")
+ }
+ if passwordOK(hash, "correct horse battery stapl") {
+ t.Error("a wrong password was accepted")
+ }
+ for _, bad := range []string{"", "hunter2", "$argon2id$v=19$m=1,t=1,p=1$..$..", "$argon2i$v=19$m=1,t=1,p=1$AAAA$AAAA"} {
+ if passwordOK(bad, "hunter2") {
+ t.Errorf("password accepted against hash %q", bad)
+ }
+ }
+}
+
+func TestSessionEpochRevokes(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+
+ w := httptest.NewRecorder()
+ alice, _ := f.store.User("alice")
+ f.setSession(w, "alice", alice.Epoch)
+ cookies := w.Result().Cookies()
+ if len(cookies) != 1 {
+ t.Fatalf("expected one cookie, got %d", len(cookies))
+ }
+ c := cookies[0]
+ if !c.HttpOnly || c.SameSite != http.SameSiteLaxMode || c.Path != "/" {
+ t.Errorf("session cookie is not HttpOnly, Lax, and rooted: %+v", c)
+ }
+
+ r := httptest.NewRequest("GET", "/", nil)
+ r.AddCookie(c)
+ if name, _, ok := f.user(r); !ok || name != "alice" {
+ t.Fatal("a fresh session was not accepted")
+ }
+
+ tampered := httptest.NewRequest("GET", "/", nil)
+ tampered.AddCookie(&http.Cookie{Name: sessionCookie, Value: c.Value[:len(c.Value)-1] + "x"})
+ if _, _, ok := f.user(tampered); ok {
+ t.Error("a tampered session was accepted")
+ }
+
+ err := f.store.UpdateUsers("users: revoke", f.store.forgeAuthor(), func(m map[string]User) error {
+ u := m["alice"]
+ u.Epoch++
+ m["alice"] = u
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, _, ok := f.user(r); ok {
+ t.Error("a session survived an epoch bump")
+ }
+}
+
+// Signing out everywhere has to take the browser asking for it as well as the
+// others, or the session somebody wants gone most is the one that survives.
+func TestSignOutEverywhere(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ h := f.routes()
+ sessions := [][]*http.Cookie{signIn(t, f, h, "alice"), signIn(t, f, h, "alice")}
+ for i, s := range sessions {
+ if code := get(h, "/settings", s).Code; code != http.StatusOK {
+ t.Fatalf("session %d was not usable to begin with: status %d", i, code)
+ }
+ }
+
+ form := url.Values{"csrf": {f.csrf(withCookies("/", sessions[0]))}}
+ if w := post(h, "/settings/sessions", form, sessions[0]); w.Code != http.StatusSeeOther {
+ t.Fatalf("signing out everywhere: status %d", w.Code)
+ }
+ for i, s := range sessions {
+ if code := get(h, "/settings", s).Code; code != http.StatusSeeOther {
+ t.Errorf("session %d survived: status %d", i, code)
+ }
+ }
+}
+
+func TestLoginLogout(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ h := f.routes()
+
+ if w := post(h, "/login", url.Values{"name": {"alice"}, "password": {"nope"}}, nil); w.Code != http.StatusUnauthorized {
+ t.Fatalf("wrong password: status %d", w.Code)
+ }
+ // The account name is lowercase by construction, so the shift key is forgiven.
+ w := post(h, "/login", url.Values{"name": {"Alice"}, "password": {"hunter2hunter2"}}, nil)
+ if w.Code != http.StatusSeeOther {
+ t.Fatalf("right password: status %d", w.Code)
+ }
+ session := w.Result().Cookies()
+
+ if body := get(h, "/", session).Body.String(); !strings.Contains(body, `<a href="/settings">alice</a>`) {
+ t.Errorf("the bar does not show the session: %s", body)
+ }
+ if w := get(h, "/settings", nil); w.Code != http.StatusSeeOther {
+ t.Errorf("settings without a session: status %d", w.Code)
+ }
+
+ if w := post(h, "/logout", nil, session); w.Code != http.StatusForbidden {
+ t.Error("a signed in post without the form token was accepted")
+ }
+ r := httptest.NewRequest("GET", "/", nil)
+ for _, c := range session {
+ r.AddCookie(c)
+ }
+ token := url.Values{"csrf": {f.csrf(r)}}
+ if w := post(h, "/logout", token, session); w.Code != http.StatusSeeOther {
+ t.Fatalf("logout: status %d", w.Code)
+ }
+}
+
+func TestPasswordChange(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ h := f.routes()
+
+ session := signIn(t, f, h, "alice")
+ r := httptest.NewRequest("GET", "/settings", nil)
+ for _, c := range session {
+ r.AddCookie(c)
+ }
+ form := url.Values{"csrf": {f.csrf(r)}, "current": {"hunter2hunter2"}, "new": {"opensesame1"}, "again": {"opensesame1"}}
+
+ w := post(h, "/settings/password", form, session)
+ if w.Code != http.StatusOK {
+ t.Fatalf("password change: status %d, %s", w.Code, w.Body.String())
+ }
+ // The bump killed the cookie the request carried, so the response has to
+ // both reissue it and still render as signed in.
+ if len(w.Result().Cookies()) != 1 {
+ t.Error("the password change did not reissue the session cookie")
+ }
+ // The header only names the account when there is a session, so this is
+ // how the page says the reissued cookie took.
+ if !strings.Contains(w.Body.String(), `<a href="/settings">alice</a>`) {
+ t.Error("the page after a password change looks signed out")
+ }
+ if get(h, "/settings", session).Code != http.StatusSeeOther {
+ t.Error("the old session cookie still works")
+ }
+ if get(h, "/settings", w.Result().Cookies()).Code != http.StatusOK {
+ t.Error("the reissued session cookie does not work")
+ }
+ if alice, _ := f.store.User("alice"); !passwordOK(alice.Password, "opensesame1") {
+ t.Error("the new password was not stored")
+ }
+}
+
+func TestCrossSitePostRefused(t *testing.T) {
+ f := testForge(t)
+ h := f.routes()
+
+ r := httptest.NewRequest("POST", "/login", strings.NewReader("name=alice&password=x"))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ r.Header.Set("Origin", "https://evil.example")
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ if w.Code != http.StatusForbidden {
+ t.Errorf("cross-site post: status %d", w.Code)
+ }
+}
+
+// A budget spent by successful sign ins would refuse people doing nothing
+// wrong.
+func TestLoginLimitCountsFailuresOnly(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ h := f.routes()
+ key := "login:" + f.peer(httptest.NewRequest("POST", "/login", nil))
+
+ for range 3 {
+ if code := post(h, "/login", url.Values{"name": {"alice"}, "password": {"hunter2hunter2"}}, nil).Code; code != http.StatusSeeOther {
+ t.Fatalf("a correct password was refused: status %d", code)
+ }
+ }
+ if f.loginLimit.over(key, 0) {
+ t.Error("signing in correctly spent the budget for guessing")
+ }
+ for range 3 {
+ post(h, "/login", url.Values{"name": {"alice"}, "password": {"nope"}}, nil)
+ }
+ if !f.loginLimit.over(key, 2) {
+ t.Error("failures were not counted")
+ }
+ if f.loginLimit.over(key, 3) {
+ t.Error("more was counted than was tried")
+ }
+}
+
+// Behind something that terminates the connection, every request arrives from
+// the same address and every budget becomes one bucket. Naming a header is the
+// way out, and it is a trust decision: an unnamed one is never read, and a
+// value that is not a single address is never counted.
+func TestClientAddressFromAHeader(t *testing.T) {
+ f := testForge(t)
+ r := httptest.NewRequest("POST", "/login", nil)
+ r.RemoteAddr = "127.0.0.1:5555"
+ r.Header.Set("CF-Connecting-IP", "203.0.113.9")
+
+ if got := f.peer(r); got != "127.0.0.1" {
+ t.Errorf("a header nobody named was read: %q", got)
+ }
+ f.cfg.ClientIPHeader = "CF-Connecting-IP"
+ if got := f.peer(r); got != "203.0.113.9" {
+ t.Errorf("the named header was not read: %q", got)
+ }
+ // Anything that is not one address is nobody's address.
+ for _, junk := range []string{"", " ", "not-an-ip", "203.0.113.9, 70.41.3.18", "203.0.113.9:443"} {
+ r.Header.Set("CF-Connecting-IP", junk)
+ if got := f.peer(r); got != "127.0.0.1" {
+ t.Errorf("%q was taken as an address: %q", junk, got)
+ }
+ }
+
+ // And the budgets follow it. Filled directly, to save the password checks.
+ for range loginsPerHour + 1 {
+ f.loginLimit.charge("login:203.0.113.9")
+ }
+ h := f.routes()
+ attempt := func(ip string) int {
+ body := url.Values{"name": {"alice"}, "password": {"wrong"}}.Encode()
+ req := httptest.NewRequest("POST", "/login", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("CF-Connecting-IP", ip)
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, req)
+ return w.Code
+ }
+ if code := attempt("203.0.113.9"); code != http.StatusTooManyRequests {
+ t.Errorf("the client that spent the budget was not refused: %d", code)
+ }
+ if code := attempt("198.51.100.4"); code != http.StatusUnauthorized {
+ t.Errorf("another client behind the same proxy shared its budget: %d", code)
+ }
+
+ // A name that could never match is refused at startup.
+ cfg := defaultConfig()
+ cfg.ClientIPHeader = "CF-Connecting-IP:"
+ if err := cfg.validate(); err == nil {
+ t.Error("a header name with a colon in it was accepted")
+ }
+ cfg.ClientIPHeader = "CF-Connecting-IP"
+ if err := cfg.validate(); err != nil {
+ t.Errorf("a usable header name was refused: %v", err)
+ }
+}
+
+// A budget spent only by failures is read by over, and a request over refuses
+// never reaches charge. Rolling the window in charge alone would lock an
+// address out for ever.
+func TestLoginLimitLetsGoAfterAnHour(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ h := f.routes()
+
+ key := "login:" + f.peer(httptest.NewRequest("POST", "/login", nil))
+ for range loginsPerHour + 1 {
+ f.loginLimit.charge(key)
+ }
+ right := url.Values{"name": {"alice"}, "password": {"hunter2hunter2"}}
+ if code := post(h, "/login", right, nil).Code; code != http.StatusTooManyRequests {
+ t.Fatalf("the budget did not run out: status %d", code)
+ }
+
+ f.loginLimit.mu.Lock()
+ f.loginLimit.opened = time.Now().Add(-90 * time.Minute)
+ f.loginLimit.mu.Unlock()
+
+ if code := post(h, "/login", right, nil).Code; code != http.StatusSeeOther {
+ t.Errorf("still refused an hour after the window ran out: status %d", code)
+ }
+}
+
+// Without a cap on the body itself, an unauthenticated POST to any route fills
+// the disk. Asserted on the route that needs no account at all.
+func TestFormBodyIsCapped(t *testing.T) {
+ f := testForge(t)
+ h := f.routes()
+
+ var body bytes.Buffer
+ mw := multipart.NewWriter(&body)
+ part, err := mw.CreateFormFile("logo_file", "big.bin")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := part.Write(make([]byte, maxUpload+1<<20)); err != nil {
+ t.Fatal(err)
+ }
+ mw.Close()
+
+ r := httptest.NewRequest("POST", "/login", &body)
+ r.Header.Set("Content-Type", mw.FormDataContentType())
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("an oversized multipart body was accepted: status %d", w.Code)
+ }
+
+ long := url.Values{"name": {strings.Repeat("x", maxForm+1)}, "password": {"x"}}
+ if code := post(h, "/login", long, nil).Code; code != http.StatusBadRequest {
+ t.Errorf("an oversized form body was accepted: status %d", code)
+ }
+
+ // And the cap is generous enough for what the forms are for: a wiki page at
+ // the size limit, in text where every byte percent encodes to six.
+ addUser(t, f, "alice", "hunter2hunter2")
+ addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featUsers})
+ session := signIn(t, f, h, "alice")
+ page := url.Values{
+ "csrf": {f.csrf(withCookies("/", session))}, "base": {""}, "action": {"save"},
+ "body": {strings.Repeat("é", maxBody/2)},
+ }
+ if code := post(h, "/notes/wiki/", page, session).Code; code != http.StatusSeeOther {
+ t.Errorf("a wiki page at the size limit was refused: status %d", code)
+ }
+}
diff --git a/avatar.go b/avatar.go
new file mode 100644
index 0000000..4342890
--- /dev/null
+++ b/avatar.go
@@ -0,0 +1,45 @@
+package main
+
+import (
+ "crypto/sha256"
+ "fmt"
+ "net/http"
+ "strings"
+)
+
+// An avatar is drawn from the name alone: fetching one tells somewhere else
+// who reads what, and an upload needs a parser.
+func avatarSVG(name string) []byte {
+ sum := sha256.Sum256([]byte(name))
+ // Fixed saturation and lightness, so every one is readable.
+ hue := int(sum[0]) * 360 / 256
+ var b strings.Builder
+ fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 5 5" width="20" height="20" role="img" aria-label="%s">`, name)
+ fmt.Fprintf(&b, `<rect width="5" height="5" fill="#eee"/>`)
+ fmt.Fprintf(&b, `<g fill="hsl(%d,45%%,35%%)">`, hue)
+ // Three columns mirrored into five.
+ for x := range 3 {
+ for y := range 5 {
+ if sum[1+x*5+y]&1 == 0 {
+ continue
+ }
+ fmt.Fprintf(&b, `<rect x="%d" y="%d" width="1" height="1"/>`, x, y)
+ if x < 2 {
+ fmt.Fprintf(&b, `<rect x="%d" y="%d" width="1" height="1"/>`, 4-x, y)
+ }
+ }
+ }
+ b.WriteString(`</g></svg>`)
+ return []byte(b.String())
+}
+
+func (f *Forge) handleAvatar(w http.ResponseWriter, r *http.Request) {
+ name := r.PathValue("name")
+ if !accountNameRE.MatchString(name) {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "image/svg+xml")
+ w.Header().Set("Cache-Control", "max-age=86400")
+ w.Write(avatarSVG(name))
+}
diff --git a/cgit.go b/cgit.go
new file mode 100644
index 0000000..e2687d3
--- /dev/null
+++ b/cgit.go
@@ -0,0 +1,304 @@
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "log"
+ "net/http"
+ "net/http/cgi"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// Cgit is the browser. One config per repository, because under scan-path any
+// request reaching cgit's index lists every repository. The configs are
+// derived state in a temporary directory.
+type Cgit struct {
+ binary string
+ css []byte // cgit's own stylesheet, read from beside the binary
+ logo []byte // and its logo, which is the default one
+ favicon []byte
+ dir string
+ store *Store
+}
+
+// cgitBinary is the CGI in Alpine's cgit package; the override is for running
+// outside that image.
+func cgitBinary() string {
+ if path := os.Getenv("GIT_FORGE_CGIT"); path != "" {
+ return path
+ }
+ return "/usr/share/webapps/cgit/cgit.cgi"
+}
+
+func newCgit(store *Store, forgeCSS []byte) (*Cgit, error) {
+ dir, err := os.MkdirTemp("", "git-forge-cgit")
+ if err != nil {
+ return nil, err
+ }
+ c := &Cgit{binary: cgitBinary(), dir: dir, store: store}
+ if _, err := os.Stat(c.binary); err != nil {
+ log.Printf("cgit: %v, so browsing will not work", err)
+ }
+ // cgit's own, read beside the CGI: its licence, and it changes with the
+ // package.
+ beside := filepath.Dir(c.binary)
+ if c.css, err = os.ReadFile(filepath.Join(beside, "cgit.css")); err != nil {
+ log.Printf("cgit: %v, so pages will be unstyled", err)
+ }
+ // One file at the path cgit links to, or the injected bar is unstyled.
+ c.css = append(c.css, forgeCSS...)
+ // The defaults, linked to when no logo is configured.
+ if c.logo, err = os.ReadFile(filepath.Join(beside, "cgit.png")); err != nil {
+ log.Printf("cgit: %v, so /cgit.png answers 404", err)
+ }
+ if c.favicon, err = os.ReadFile(filepath.Join(beside, "favicon.ico")); err != nil {
+ log.Printf("cgit: %v, so /favicon.ico answers 404", err)
+ }
+ log.Printf("cgit configs in %s, written on demand and never backed up", dir)
+ return c, c.writeFilters()
+}
+
+func (c *Cgit) close() { os.RemoveAll(c.dir) }
+
+// writeFilters generates the two filter shims: an exec filter is not split on
+// spaces, so "git-forge md" cannot be named directly.
+func (c *Cgit) writeFilters() error {
+ exe, err := os.Executable()
+ if err != nil {
+ return err
+ }
+ // Quoted: an unquoted path with a space in it execs its first word.
+ quoted := "'" + strings.ReplaceAll(exe, "'", `'\''`) + "'"
+ for _, sub := range []string{"md", "highlight"} {
+ shim := fmt.Sprintf("#!/bin/sh\nexec %s %s \"$@\"\n", quoted, sub)
+ if err := writeFile(filepath.Join(c.dir, sub), []byte(shim), 0700); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// config writes one repository's cgitrc if it is not there. The content
+// depends on the path alone.
+func (c *Cgit) config(path, kind string) (string, error) {
+ name := c.configPath(path, kind)
+ if _, err := os.Stat(name); err == nil {
+ return name, nil
+ }
+ return c.write(path, kind)
+}
+
+func (c *Cgit) configPath(path, kind string) string {
+ // A path holds at most one slash and no colon, so this is unambiguous.
+ return filepath.Join(c.dir, strings.ReplaceAll(urlPath(path, kind), "/", ":")+".rc")
+}
+
+func (c *Cgit) repoDir(path, kind string) string {
+ return filepath.Join(c.store.reposDir, path+gitSuffix(kind))
+}
+
+// virtual-root is unset: with SCRIPT_NAME empty, cgit emits links of the form
+// /infra/dns/log/, the forge's own routes. No repo.desc: the description is in
+// repos.json.
+func (c *Cgit) write(path, kind string) (string, error) {
+ url := urlPath(path, kind)
+ var b strings.Builder
+ // css stays /cgit.css; the injected bar carries the name.
+ fmt.Fprintf(&b, "logo=\n")
+ fmt.Fprintf(&b, "noheader=1\n")
+ fmt.Fprintf(&b, "enable-blame=1\n")
+ fmt.Fprintf(&b, "enable-http-clone=0\n")
+ fmt.Fprintf(&b, "enable-log-filecount=1\n")
+ fmt.Fprintf(&b, "enable-log-linecount=1\n")
+ fmt.Fprintf(&b, "snapshots=tar.gz\n")
+ // Both spellings, since a repository may carry either.
+ fmt.Fprintf(&b, "readme=:README.md\n")
+ fmt.Fprintf(&b, "readme=:README\n")
+ // highlight is fed arbitrary blobs, so capped. In kilobytes.
+ fmt.Fprintf(&b, "max-blob-size=512\n")
+ // No mimetype mapping, so every blob comes back as text, not run on this
+ // origin.
+ fmt.Fprintf(&b, "mimetype-file=\n")
+ fmt.Fprintf(&b, "about-filter=%s\n", filepath.Join(c.dir, "md"))
+ fmt.Fprintf(&b, "source-filter=%s\n", filepath.Join(c.dir, "highlight"))
+ fmt.Fprintf(&b, "clone-url=%s/%s.git\n", c.store.cfg.BaseURL, url)
+ fmt.Fprintf(&b, "repo.url=%s\n", url)
+ fmt.Fprintf(&b, "repo.path=%s\n", c.repoDir(path, kind))
+
+ name := c.configPath(path, kind)
+ return name, writeFile(name, []byte(b.String()), 0600)
+}
+
+// urlPath is how a repository and its siblings appear in a URL: infra/dns,
+// infra/dns.issues, infra/dns.wiki.
+func urlPath(path, kind string) string {
+ if kind == "" {
+ return path
+ }
+ return path + "." + kind
+}
+
+// browsing bounds the cgit children in flight. Its own budget: a page must not
+// queue behind a clone.
+var browsing = make(chan struct{}, 8)
+
+// Authorisation has already happened: cgit is only ever told about a
+// repository the viewer may read.
+func (f *Forge) handleCgit(w http.ResponseWriter, r *http.Request, path, kind, rest string, repo Repo) {
+ config, err := f.cgit.config(path, kind)
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return
+ }
+ // The neutral configuration gitEnv gives: cgit reads repositories with
+ // git's library.
+ env := []string{
+ "CGIT_CONFIG=" + config,
+ "PATH_INFO=/" + urlPath(path, kind) + "/" + rest,
+ "SCRIPT_NAME=",
+ "GIT_CONFIG_NOSYSTEM=1",
+ "GIT_CONFIG_GLOBAL=/dev/null",
+ }
+ nav := &injector{ResponseWriter: w, nav: f.navPart(r, "header", path, kind, repo)}
+ // The forge's tabs go inside cgit's row; a sibling's history has none.
+ if kind == "" {
+ nav.tabs = f.navPart(r, "tabs", path, kind, repo)
+ }
+ if strings.HasPrefix(rest, "about") {
+ nav.summary = "/" + urlPath(path, kind) + "/"
+ }
+ release, ok := childSlot(browsing, r)
+ if !ok {
+ return
+ }
+ defer release()
+ (&cgi.Handler{Path: f.cgit.binary, Env: env, Stderr: os.Stdout}).ServeHTTP(nav, r)
+ nav.flush()
+}
+
+// navPart renders one of nav.html's partials for injection into a cgit page:
+// injected, so it follows the knobs and names the account.
+func (f *Forge) navPart(r *http.Request, part, path, kind string, repo Repo) []byte {
+ d := f.page(r)
+ d.Repo = f.repoBar(r, path, kind, "", repo)
+ var buf bytes.Buffer
+ if err := f.nav.ExecuteTemplate(&buf, part, d); err != nil {
+ log.Printf("nav %s: %v", part, err)
+ return nil
+ }
+ return buf.Bytes()
+}
+
+// injector puts the header inside cgit's div and the tabs inside its tab row.
+// Only text/html is buffered, and only until both are placed or the window
+// runs out.
+type injector struct {
+ http.ResponseWriter
+ nav []byte
+ tabs []byte
+ summary string // where to send an about page cgit declined to answer
+ html bool
+ wrote bool
+ injected bool
+ buf bytes.Buffer
+}
+
+// cgitTabs is where cgit's own tab cell ends; the forge's go in before it.
+var cgitTabs = []byte("</td><td class='form'>")
+
+// cgitDiv is the element cgit puts its whole page in.
+var cgitDiv = []byte("<div id='cgit'>")
+
+// maxInject bounds the wait for markup that may never come.
+const maxInject = 64 << 10
+
+func (w *injector) WriteHeader(status int) {
+ if w.wrote {
+ return
+ }
+ w.wrote = true
+ // Without a readme cgit redirects the about page to a path that is not a
+ // route.
+ if w.summary != "" && status >= 300 && status < 400 {
+ if strings.Contains(w.Header().Get("Location"), "..") {
+ w.Header().Set("Location", w.summary)
+ }
+ }
+ if strings.HasPrefix(w.Header().Get("Content-Type"), "text/html") {
+ w.html = true
+ // The bar changes the length.
+ w.Header().Del("Content-Length")
+ }
+ w.ResponseWriter.WriteHeader(status)
+}
+
+func (w *injector) Write(p []byte) (int, error) {
+ if !w.wrote {
+ w.WriteHeader(http.StatusOK)
+ }
+ if !w.html || w.injected {
+ return w.ResponseWriter.Write(p)
+ }
+ w.buf.Write(p)
+ // Both go in the first kilobyte of a cgit page.
+ done := w.placed()
+ if done || w.buf.Len() > maxInject {
+ if !done {
+ cgitMoved()
+ }
+ w.injected = true
+ if err := w.drain(); err != nil {
+ return 0, err
+ }
+ }
+ return len(p), nil
+}
+
+// cgitMoved is a cgit whose markup no longer carries what the bar goes on: a
+// package upgrade.
+func cgitMoved() {
+ log.Printf("cgit: the bar was not placed, so cgit's markup has moved")
+}
+
+// placed reports whether anything is left to wait for. The tabs go in first:
+// the header carries a cell of its own.
+func (w *injector) placed() bool {
+ tabs := insertNear(&w.buf, cgitTabs, w.tabs, false)
+ return insertNear(&w.buf, cgitDiv, w.nav, true) && tabs
+}
+
+// insertNear reports whether what went in. Nothing to insert, or something
+// already inserted, counts as done.
+func insertNear(buf *bytes.Buffer, needle, what []byte, after bool) bool {
+ if len(what) == 0 || bytes.Contains(buf.Bytes(), what) {
+ return true
+ }
+ at := bytes.Index(buf.Bytes(), needle)
+ if at < 0 {
+ return false
+ }
+ if after {
+ at += len(needle)
+ }
+ rest := append([]byte(nil), buf.Bytes()[at:]...)
+ buf.Truncate(at)
+ buf.Write(what)
+ buf.Write(rest)
+ return true
+}
+
+func (w *injector) flush() {
+ if w.buf.Len() > 0 {
+ cgitMoved()
+ w.drain()
+ }
+}
+
+func (w *injector) drain() error {
+ out := w.buf.Bytes()
+ w.buf.Reset()
+ _, err := w.ResponseWriter.Write(out)
+ return err
+}
diff --git a/cgit_test.go b/cgit_test.go
new file mode 100644
index 0000000..1d62694
--- /dev/null
+++ b/cgit_test.go
@@ -0,0 +1,151 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+ "time"
+)
+
+// Every cgit page forks a child, and browsing a public repository takes no
+// credential, so the budget is what stands between a visitor and one child per
+// connection. A request that cannot have a slot waits for one, but not on
+// behalf of somebody who has already hung up.
+func TestBrowsingIsBounded(t *testing.T) {
+ f := testForge(t)
+ addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featDisabled})
+ h := f.routes()
+
+ for range cap(browsing) {
+ browsing <- struct{}{}
+ }
+ defer func() {
+ for range cap(browsing) {
+ <-browsing
+ }
+ }()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ done := make(chan struct{})
+ go func() {
+ h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/notes/", nil).WithContext(ctx))
+ close(done)
+ }()
+ select {
+ case <-done:
+ case <-time.After(5 * time.Second):
+ t.Fatal("a browse held a goroutine waiting to fork for a client that had gone")
+ }
+}
+
+// A tab that leads to a 404 is worse than no tab. One template draws the row
+// for both kinds of page, so both are asked here.
+func TestTabsFollowTheKnobs(t *testing.T) {
+ f := testForge(t)
+ addRepo(t, f, "docs", Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featUsers})
+ addRepo(t, f, "code", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featDisabled})
+ h := f.routes()
+
+ for _, c := range []struct {
+ path, page string
+ issues, wiki bool
+ }{
+ {"docs", "/docs/wiki", false, true},
+ {"code", "/code/issues", true, false},
+ } {
+ repo, _ := f.store.Repo(c.path)
+ r := httptest.NewRequest("GET", "/"+c.path+"/", nil)
+ rows := []struct{ where, html string }{
+ {"injected into cgit's row", string(f.navPart(r, "tabs", c.path, "", repo))},
+ {"on our own page", get(h, c.page, nil).Body.String()},
+ }
+ for _, row := range rows {
+ for _, tab := range []struct {
+ name string
+ want bool
+ }{{"issues", c.issues}, {"wiki", c.wiki}} {
+ got := strings.Contains(row.html, ">"+tab.name+"</a>")
+ if got != tab.want {
+ t.Errorf("%s %s: the %s tab is present %v, wanted %v",
+ c.path, row.where, tab.name, got, tab.want)
+ }
+ }
+ }
+ }
+}
+
+// The bar is spliced into cgit's own markup by matching it, so a cgit whose
+// page has changed shape has to fail here rather than quietly serve a page
+// with no bar on it.
+func TestTheBarGoesInsideCgitsPage(t *testing.T) {
+ f := testForge(t)
+ addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featUsers})
+ if _, err := os.Stat(cgitBinary()); err != nil {
+ t.Skip("cgit is not installed, and this is a test of cgit's markup")
+ }
+ page := get(f.routes(), "/notes/", nil).Body.String()
+
+ div := strings.Index(page, string(cgitDiv))
+ header := strings.Index(page, `<table id="header">`)
+ cell := strings.Index(page, string(cgitTabs))
+ tabs := strings.Index(page, `>issues</a>`)
+ switch {
+ case div < 0:
+ t.Fatalf("cgit no longer opens its page with %s:\n%s", cgitDiv, page)
+ case cell < 0:
+ t.Fatalf("cgit no longer ends its tab cell with %s:\n%s", cgitTabs, page)
+ case header < 0:
+ t.Error("the header was not injected")
+ case header < div:
+ t.Error("the header landed outside cgit's own div")
+ case tabs < 0:
+ t.Error("the forge's tabs were not injected")
+ case tabs > cell:
+ t.Error("the forge's tabs landed after cgit's own tab cell rather than in it")
+ }
+ if n := strings.Count(page, `<table id="header">`); n != 1 {
+ t.Errorf("the header went in %d times", n)
+ }
+}
+
+// Only text/html is held back, and only until there is nothing left to wait
+// for: a snapshot has to stream through whole and untouched.
+func TestTheInjectorPassesThroughWhatItCannotPlace(t *testing.T) {
+ body := bytes.Repeat([]byte("x"), 3*maxInject)
+ for _, kind := range []string{"text/html; charset=utf-8", "application/x-gzip"} {
+ w := httptest.NewRecorder()
+ w.Header().Set("Content-Type", kind)
+ nav := &injector{ResponseWriter: w, nav: []byte("NAV"), tabs: []byte("TABS")}
+ nav.WriteHeader(http.StatusOK)
+ if _, err := nav.Write(body); err != nil {
+ t.Fatalf("%s: %v", kind, err)
+ }
+ nav.flush()
+ if got := w.Body.Bytes(); !bytes.Equal(got, body) {
+ t.Errorf("%s: %d bytes came back, of the %d that went in", kind, len(got), len(body))
+ }
+ }
+}
+
+// Bodies are handed to goldmark straight and the result is trusted as HTML, so
+// leaving raw HTML unrendered is the whole reason that is safe.
+func TestMarkdownIsNotHTML(t *testing.T) {
+ cases := map[string]string{
+ "<script>alert(1)</script>": "alert",
+ "<img src=x onerror=alert(1)>": "onerror",
+ "[click](javascript:alert(1))": "javascript:",
+ "[click](data:text/html,%3Cs)": "data:",
+ "<a href='vbscript:x'>hello</a>": "vbscript:",
+ }
+ for source, forbidden := range cases {
+ out := string(renderMarkdown(source))
+ if strings.Contains(out, forbidden) {
+ t.Errorf("%q rendered to %q, which still carries %q", source, out, forbidden)
+ }
+ }
+}
diff --git a/config.go b/config.go
new file mode 100644
index 0000000..0739de5
--- /dev/null
+++ b/config.go
@@ -0,0 +1,263 @@
+package main
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/url"
+ "os"
+ "regexp"
+ "strings"
+)
+
+// Config is {datadir}/config/forge.json. It has no field for a secret: every
+// secret is environment only. SiteName, SiteDesc, Logo, and Icon are read once
+// at startup, into Site.
+type Config struct {
+ Listen string `json:"listen"`
+ SiteName string `json:"site_name"`
+ SiteDesc string `json:"site_desc"`
+ Logo string `json:"logo"`
+ Icon string `json:"icon"`
+ BaseURL string `json:"base_url"`
+ OIDC []OIDCProvider `json:"oidc"`
+ Mail MailConfig `json:"mail"`
+
+ // ClientIPHeader names the header the client address arrives in, behind a
+ // proxy. Empty means the connection is the address.
+ ClientIPHeader string `json:"client_ip_header"`
+
+ host string // BaseURL's host without the port, for generated addresses
+}
+
+type OIDCProvider struct {
+ Key string `json:"key"`
+ Label string `json:"label"`
+ Issuer string `json:"issuer"`
+ ClientID string `json:"client_id"`
+ Allow []string `json:"allow,omitempty"`
+
+ ClientSecret string `json:"-"` // GIT_FORGE_OIDC_<key>_CLIENT_SECRET
+}
+
+// An entry beginning with @ is a whole domain, anything else is one address,
+// and an empty list admits anyone. Checked at every login, so removing an
+// entry revokes.
+func (p OIDCProvider) allows(email string) bool {
+ if len(p.Allow) == 0 {
+ return true
+ }
+ email = strings.ToLower(email)
+ _, domain, split := strings.Cut(email, "@")
+ for _, entry := range p.Allow {
+ entry = strings.ToLower(entry)
+ if want, isDomain := strings.CutPrefix(entry, "@"); isDomain {
+ // Whole label, not suffix.
+ if split && domain == want {
+ return true
+ }
+ continue
+ }
+ if email == entry {
+ return true
+ }
+ }
+ return false
+}
+
+type MailConfig struct {
+ Driver string `json:"driver"`
+ From string `json:"from"`
+ Mailgun MailgunConfig `json:"mailgun"`
+}
+
+type MailgunConfig struct {
+ Domain string `json:"domain"`
+ BaseURL string `json:"base_url"`
+
+ APIKey string `json:"-"` // GIT_FORGE_MAIL_MAILGUN_API_KEY
+}
+
+// An OIDC key prefixes the accounts it mints, as in sso:carol, and names the
+// client secret's environment variable.
+var oidcKeyRE = regexp.MustCompile(`^[a-z][a-z0-9]{0,15}$`)
+
+// A header name. One with a colon in it never matches, which looks like the
+// proxy failing to set it.
+var headerNameRE = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`)
+
+// The name reaches the realm git is prompted with, where a quote or backslash
+// ends it early.
+func validSiteName(name string) bool {
+ return name != "" && !strings.ContainsAny(name, `"\`) && !hasControlByte(name)
+}
+
+var errSiteName = userError("the site needs a name, without a quote, a backslash, or a control byte in it")
+
+func defaultConfig() *Config {
+ return &Config{
+ Listen: ":8080",
+ SiteName: "git-forge",
+ SiteDesc: "a small self-hosted git forge",
+ BaseURL: "http://localhost:8080",
+ OIDC: []OIDCProvider{},
+ Mail: MailConfig{
+ Driver: "none",
+ From: "git-forge <git-forge@localhost>",
+ Mailgun: MailgunConfig{BaseURL: "https://api.mailgun.net/v3"},
+ },
+ }
+}
+
+// readConfig is the file alone. An unknown field is an error, so a typo cannot
+// silently do nothing.
+func readConfig(path string) (*Config, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ cfg := &Config{}
+ dec := json.NewDecoder(f)
+ dec.DisallowUnknownFields()
+ if err := dec.Decode(cfg); err != nil {
+ return nil, err
+ }
+ return cfg, nil
+}
+
+// loadConfig is the file with the environment on top, validated.
+func loadConfig(path string) (*Config, error) {
+ cfg, err := readConfig(path)
+ if err != nil {
+ return nil, err
+ }
+ cfg.applyEnv()
+ if err := cfg.validate(); err != nil {
+ return nil, err
+ }
+ return cfg, nil
+}
+
+func (c *Config) applyEnv() {
+ fromEnv(&c.Listen, "GIT_FORGE_LISTEN")
+ fromEnv(&c.SiteName, "GIT_FORGE_SITE_NAME")
+ fromEnv(&c.SiteDesc, "GIT_FORGE_SITE_DESC")
+ fromEnv(&c.BaseURL, "GIT_FORGE_BASE_URL")
+ fromEnv(&c.ClientIPHeader, "GIT_FORGE_CLIENT_IP_HEADER")
+
+ c.Mail.Mailgun.APIKey = os.Getenv("GIT_FORGE_MAIL_MAILGUN_API_KEY")
+ for i := range c.OIDC {
+ key := strings.ToUpper(c.OIDC[i].Key)
+ c.OIDC[i].ClientSecret = os.Getenv("GIT_FORGE_OIDC_" + key + "_CLIENT_SECRET")
+ }
+}
+
+func fromEnv(dst *string, name string) {
+ if v, ok := os.LookupEnv(name); ok {
+ *dst = v
+ }
+}
+
+// validate reports everything wrong at once.
+func (c *Config) validate() error {
+ var errs []error
+ fail := func(format string, a ...any) {
+ errs = append(errs, fmt.Errorf(format, a...))
+ }
+
+ if c.Listen == "" {
+ fail("listen: empty")
+ }
+ if !validSiteName(c.SiteName) {
+ fail("site_name: empty, or holds a quote, a backslash, or a control byte")
+ }
+ if c.ClientIPHeader != "" && !headerNameRE.MatchString(c.ClientIPHeader) {
+ fail("client_ip_header: %q is not a header name", c.ClientIPHeader)
+ }
+
+ c.BaseURL = strings.TrimSuffix(c.BaseURL, "/")
+ u, err := url.Parse(c.BaseURL)
+ switch {
+ case err != nil:
+ fail("base_url: %v", err)
+ case u.Scheme != "http" && u.Scheme != "https":
+ fail("base_url: scheme must be http or https, not %q", u.Scheme)
+ case u.Host == "":
+ fail("base_url: no host")
+ case u.Path != "" || u.RawQuery != "" || u.Fragment != "":
+ fail("base_url: scheme and host only, git-forge serves the root")
+ default:
+ c.host = u.Hostname() // no port: this ends up in mail addresses
+ }
+
+ seen := map[string]bool{}
+ for i, p := range c.OIDC {
+ at := fmt.Sprintf("oidc[%d]", i)
+ switch {
+ case !oidcKeyRE.MatchString(p.Key):
+ fail("%s: key %q must match %s, it prefixes account names", at, p.Key, oidcKeyRE)
+ case seen[p.Key]:
+ fail("%s: duplicate key %q", at, p.Key)
+ default:
+ seen[p.Key] = true
+ }
+ if p.Label == "" {
+ fail("%s: label: empty", at)
+ }
+ if p.ClientID == "" {
+ fail("%s: client_id: empty", at)
+ }
+ iss, err := url.Parse(p.Issuer)
+ switch {
+ case err != nil:
+ fail("%s: issuer: %v", at, err)
+ case iss.Scheme != "https":
+ fail("%s: issuer: must be https", at)
+ case iss.Host == "":
+ fail("%s: issuer: no host", at)
+ case strings.HasSuffix(p.Issuer, "/"):
+ fail("%s: issuer: no trailing slash, it is compared to the iss claim", at)
+ }
+ // An entry that can never match is a lockout, refused here rather than
+ // at login.
+ for j, entry := range p.Allow {
+ switch {
+ case entry == "" || strings.TrimSpace(entry) != entry:
+ fail("%s: allow[%d]: empty or padded with spaces", at, j)
+ case entry == "@":
+ fail("%s: allow[%d]: a domain entry needs a domain after the @", at, j)
+ case !strings.Contains(entry, "@"):
+ fail("%s: allow[%d]: %q is neither an address nor an @domain", at, j, entry)
+ }
+ }
+ }
+
+ if c.Mail.From == "" {
+ fail("mail.from: empty")
+ }
+ switch c.Mail.Driver {
+ case "none", "log":
+ case "mailgun":
+ if c.Mail.Mailgun.Domain == "" {
+ fail("mail.mailgun.domain: empty")
+ }
+ if c.Mail.Mailgun.APIKey == "" {
+ fail("mail.mailgun: GIT_FORGE_MAIL_MAILGUN_API_KEY is not set")
+ }
+ base, err := url.Parse(c.Mail.Mailgun.BaseURL)
+ switch {
+ case err != nil:
+ fail("mail.mailgun.base_url: %v", err)
+ case base.Scheme != "https" || base.Host == "":
+ fail("mail.mailgun.base_url: must be an https url")
+ case strings.HasSuffix(c.Mail.Mailgun.BaseURL, "/"):
+ fail("mail.mailgun.base_url: no trailing slash")
+ }
+ default:
+ fail("mail.driver: %q is not none, log, or mailgun", c.Mail.Driver)
+ }
+
+ return errors.Join(errs...)
+}
diff --git a/config_test.go b/config_test.go
new file mode 100644
index 0000000..fe7406d
--- /dev/null
+++ b/config_test.go
@@ -0,0 +1,120 @@
+package main
+
+import (
+ "strings"
+ "testing"
+)
+
+// Startup refuses an incoherent file rather than guessing, and reports
+// everything wrong at once. What each rule catches matters most for the ones
+// that would otherwise fail silently much later: an allow entry that can never
+// match is a lockout, and a bad issuer is a login that never works.
+func TestConfigValidate(t *testing.T) {
+ provider := OIDCProvider{Key: "sso", Label: "SSO", Issuer: "https://id.example", ClientID: "forge"}
+ cases := []struct {
+ what string
+ spoil func(*Config)
+ want string
+ }{
+ {"a good file", func(*Config) {}, ""},
+ {"no listener", func(c *Config) { c.Listen = "" }, "listen"},
+ {"no name", func(c *Config) { c.SiteName = "" }, "site_name"},
+ {"a quote in the name", func(c *Config) { c.SiteName = `a"b` }, "site_name"},
+ {"a header name with a colon", func(c *Config) { c.ClientIPHeader = "X-Real-IP:" }, "client_ip_header"},
+ {"a base url with a path", func(c *Config) { c.BaseURL = "https://git.example/forge" }, "base_url"},
+ {"a base url with no scheme", func(c *Config) { c.BaseURL = "git.example" }, "base_url"},
+ {"an oidc key that prefixes nothing usable", func(c *Config) {
+ p := provider
+ p.Key = "SSO"
+ c.OIDC = []OIDCProvider{p}
+ }, "key"},
+ {"two providers under one key", func(c *Config) { c.OIDC = []OIDCProvider{provider, provider} }, "duplicate"},
+ {"a provider with no label", func(c *Config) {
+ p := provider
+ p.Label = ""
+ c.OIDC = []OIDCProvider{p}
+ }, "label"},
+ {"an issuer that is not https", func(c *Config) {
+ p := provider
+ p.Issuer = "http://id.example"
+ c.OIDC = []OIDCProvider{p}
+ }, "issuer"},
+ {"an issuer with a trailing slash", func(c *Config) {
+ p := provider
+ p.Issuer = "https://id.example/"
+ c.OIDC = []OIDCProvider{p}
+ }, "trailing slash"},
+ {"an allow entry that is neither", func(c *Config) {
+ p := provider
+ p.Allow = []string{"everyone"}
+ c.OIDC = []OIDCProvider{p}
+ }, "allow[0]"},
+ {"an allow entry that is a bare at", func(c *Config) {
+ p := provider
+ p.Allow = []string{"@"}
+ c.OIDC = []OIDCProvider{p}
+ }, "allow[0]"},
+ {"an allow entry padded with spaces", func(c *Config) {
+ p := provider
+ p.Allow = []string{" carol@example.com"}
+ c.OIDC = []OIDCProvider{p}
+ }, "allow[0]"},
+ {"a good provider", func(c *Config) {
+ p := provider
+ p.Allow = []string{"carol@example.com", "@example.com"}
+ c.OIDC = []OIDCProvider{p}
+ }, ""},
+ {"no sender", func(c *Config) { c.Mail.From = "" }, "mail.from"},
+ {"a driver that is neither", func(c *Config) { c.Mail.Driver = "sendmail" }, "mail.driver"},
+ {"mailgun with no domain", func(c *Config) { c.Mail.Driver = "mailgun" }, "mailgun.domain"},
+ {"mailgun with no key in the environment", func(c *Config) {
+ c.Mail.Driver = "mailgun"
+ c.Mail.Mailgun.Domain = "mg.example"
+ }, "MAILGUN_API_KEY"},
+ }
+ for _, c := range cases {
+ cfg := defaultConfig()
+ c.spoil(cfg)
+ err := cfg.validate()
+ switch {
+ case c.want == "" && err != nil:
+ t.Errorf("%s was refused: %v", c.what, err)
+ case c.want != "" && err == nil:
+ t.Errorf("%s was accepted", c.what)
+ case c.want != "" && !strings.Contains(err.Error(), c.want):
+ t.Errorf("%s was refused for the wrong reason: %v", c.what, err)
+ }
+ }
+}
+
+// Everything wrong at once, because fixing a config one restart at a time is
+// miserable.
+func TestConfigReportsEveryFault(t *testing.T) {
+ cfg := defaultConfig()
+ cfg.Listen, cfg.SiteName, cfg.BaseURL, cfg.Mail.From = "", "", "ftp://x", ""
+ err := cfg.validate()
+ if err == nil {
+ t.Fatal("a file with four faults in it was accepted")
+ }
+ for _, want := range []string{"listen", "site_name", "base_url", "mail.from"} {
+ if !strings.Contains(err.Error(), want) {
+ t.Errorf("%q is not in the report:\n%v", want, err)
+ }
+ }
+}
+
+// The host is what generated addresses are built from, so the port has to come
+// off and a trailing slash must not survive into it.
+func TestConfigBaseURLIsNormalised(t *testing.T) {
+ cfg := defaultConfig()
+ cfg.BaseURL = "https://git.example.com:8443/"
+ if err := cfg.validate(); err != nil {
+ t.Fatal(err)
+ }
+ if cfg.BaseURL != "https://git.example.com:8443" {
+ t.Errorf("base_url is %q", cfg.BaseURL)
+ }
+ if cfg.host != "git.example.com" {
+ t.Errorf("addresses would be built from %q", cfg.host)
+ }
+}
diff --git a/filters.go b/filters.go
new file mode 100644
index 0000000..64a1d21
--- /dev/null
+++ b/filters.go
@@ -0,0 +1,73 @@
+package main
+
+// The two filters: in process for the forge's pages, as programs for cgit.
+
+import (
+ "bytes"
+ "fmt"
+ "html/template"
+ "io"
+ "log"
+ "os"
+ "os/exec"
+ "path"
+
+ "github.com/yuin/goldmark"
+)
+
+// Raw HTML is left unrendered, goldmark's default, which is what makes user
+// text safe to hand it.
+var markdown = goldmark.New()
+
+func renderMarkdown(source string) template.HTML {
+ var buf bytes.Buffer
+ if err := markdown.Convert([]byte(source), &buf); err != nil {
+ log.Printf("markdown: %v", err)
+ return template.HTML(template.HTMLEscapeString(source))
+ }
+ return template.HTML(buf.String())
+}
+
+// runMarkdown is the about-filter: markdown on stdin, HTML on stdout.
+func runMarkdown() int {
+ source, err := io.ReadAll(os.Stdin)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "md:", err)
+ return 1
+ }
+ if err := markdown.Convert(source, os.Stdout); err != nil {
+ // A readable page beats an empty one.
+ fmt.Fprintln(os.Stderr, "md:", err)
+ template.HTMLEscape(os.Stdout, source)
+ return 1
+ }
+ return 0
+}
+
+// runHighlight is the source-filter: cgit's bundled one issues a command line
+// the installed highlight refuses, silently.
+func runHighlight(args []string) int {
+ source, err := io.ReadAll(os.Stdin)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "highlight:", err)
+ return 1
+ }
+ name := "source"
+ if len(args) > 0 && args[0] != "" {
+ name = path.Base(args[0])
+ }
+ // --force: an unknown syntax comes back as escaped text, not as nothing.
+ cmd := exec.Command("highlight", "--fragment", "--force", "--out-format=html", "--syntax-by-name="+name)
+ cmd.Stdin = bytes.NewReader(source)
+ cmd.Stderr = os.Stderr
+ out, err := cmd.Output()
+ if err != nil || len(bytes.TrimSpace(out)) == 0 {
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "highlight:", err)
+ }
+ template.HTMLEscape(os.Stdout, source)
+ return 0
+ }
+ os.Stdout.Write(out)
+ return 0
+}
diff --git a/fuzz_test.go b/fuzz_test.go
new file mode 100644
index 0000000..866863d
--- /dev/null
+++ b/fuzz_test.go
@@ -0,0 +1,122 @@
+package main
+
+import (
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+)
+
+// The parsers that are handed bytes from outside: an id token, a claim, a URL
+// path, and a secret typed into a form. Each target states the property the
+// caller relies on, so the seed corpus runs with the rest of the tests and a
+// longer search is one flag away.
+
+// splitJWT takes a token apart before anything is known about it, and
+// verifySignature is then handed whatever came out.
+func FuzzSplitJWT(f *testing.F) {
+ f.Add("aGVhZGVy.cGF5bG9hZA.c2ln")
+ f.Add("...")
+ f.Add("")
+ f.Fuzz(func(t *testing.T, token string) {
+ signed, sig, kid, alg, _, err := splitJWT(token)
+ if err != nil {
+ return
+ }
+ if verifySignature(jwk{Kty: "RSA", N: kid, E: "AQAB"}, alg, signed, sig) == nil {
+ t.Fatalf("a made up rsa key verified %q", token)
+ }
+ if verifySignature(jwk{Kty: "EC", Crv: "P-256", X: kid, Y: kid}, alg, signed, sig) == nil {
+ t.Fatalf("a made up ec key verified %q", token)
+ }
+ })
+}
+
+// The name becomes a key in repos.json and a component of a URL, out of a
+// claim from somewhere else.
+func FuzzSanitizeName(f *testing.F) {
+ f.Add("Carol")
+ f.Add("../../config")
+ f.Fuzz(func(t *testing.T, in string) {
+ if out := sanitizeName(in); out != "" && !localNameRE.MatchString(out) {
+ t.Fatalf("sanitizeName(%q) = %q, which is not a usable account name", in, out)
+ }
+ })
+}
+
+// Whatever this lets through is handed to both CGIs in an environment
+// variable.
+func FuzzServablePath(f *testing.F) {
+ f.Add("/notes/log/")
+ f.Add("/a/../b")
+ f.Fuzz(func(t *testing.T, p string) {
+ if !servablePath(p) {
+ return
+ }
+ if hasControlByte(p) {
+ t.Fatalf("servablePath(%q) accepted a control byte", p)
+ }
+ parts := strings.Split(strings.TrimPrefix(p, "/"), "/")
+ for i, part := range parts {
+ if part == "." || part == ".." || (part == "" && i < len(parts)-1) {
+ t.Fatalf("servablePath(%q) accepted a %q component", p, part)
+ }
+ }
+ })
+}
+
+// Whatever this lets through is joined onto the repos directory, for the
+// repository and for both of its siblings.
+func FuzzValidRepoPath(f *testing.F) {
+ f.Add("infra/dns")
+ f.Add("../etc")
+ f.Fuzz(func(t *testing.T, p string) {
+ if !validRepoPath(p) {
+ return
+ }
+ for _, suffix := range []string{".git", ".issues.git", ".wiki.git"} {
+ joined := filepath.Join("/repos", p+suffix)
+ if joined != filepath.Clean(joined) || !strings.HasPrefix(joined, "/repos/") {
+ t.Fatalf("validRepoPath(%q) accepted a path that lands on %q", p, joined)
+ }
+ if strings.Count(strings.TrimPrefix(joined, "/repos/"), "/") > 1 {
+ t.Fatalf("validRepoPath(%q) accepted more than one section deep", p)
+ }
+ }
+ })
+}
+
+// A page listed in the index has to open the file it was listed from. A push
+// can put any name in the tree.
+func FuzzWikiNameRoundTrip(f *testing.F) {
+ f.Add("docs/index.md")
+ f.Add("myindex.md")
+ f.Add(".md")
+ f.Fuzz(func(t *testing.T, file string) {
+ page := wikiPage(file)
+ files, ok := wikiFiles(page)
+ if !ok || !slices.Contains(files, file) {
+ return // not listed, so never linked to
+ }
+ if wikiPage(files[0]) != page {
+ t.Fatalf("%q is listed as %q, which reads back as %q", file, page, wikiPage(files[0]))
+ }
+ })
+}
+
+// The secret being enrolled arrives in a form, so this decodes whatever was
+// typed.
+func FuzzTOTPSecret(f *testing.F) {
+ f.Add("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")
+ f.Add("!!!")
+ f.Fuzz(func(t *testing.T, secret string) {
+ code := totpCode(secret, time.Unix(1234567890, 0))
+ if code != "" && len(code) != totpDigits {
+ t.Fatalf("totpCode(%q) = %q", secret, code)
+ }
+ if code == "" && totpStep(secret, "", time.Unix(1234567890, 0)) >= 0 {
+ t.Fatalf("an unreadable secret accepted an empty code")
+ }
+ })
+}
diff --git a/git-forge.initd b/git-forge.initd
new file mode 100755
index 0000000..25c0cfd
--- /dev/null
+++ b/git-forge.initd
@@ -0,0 +1,22 @@
+#!/sbin/openrc-run
+# OpenRC service for git-forge. Install as /etc/init.d/git-forge with a
+# forge user present; secrets and overrides go in /etc/conf.d/git-forge,
+# mode 0600, as VAR=value lines.
+
+description="git-forge"
+datadir="${GIT_FORGE_DATADIR:-/var/lib/git-forge}"
+command=/usr/local/bin/git-forge
+command_args="-datadir $datadir"
+command_user="forge:forge"
+command_background=yes
+pidfile=/run/git-forge.pid
+output_log=/var/log/git-forge.log
+error_log=/var/log/git-forge.log
+
+depend() {
+ need net
+}
+
+start_pre() {
+ checkpath -d -m 0700 -o forge:forge "$datadir"
+}
diff --git a/git.go b/git.go
new file mode 100644
index 0000000..17eaa81
--- /dev/null
+++ b/git.go
@@ -0,0 +1,296 @@
+package main
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "log"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+// Git is driven by exec'ing it: cgit and git-http-backend already require the
+// binary, and a Go implementation could only disagree with it.
+
+// gitEnv neutralises the system and global configuration.
+func gitEnv(extra []string) []string {
+ env := append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null")
+ return append(env, extra...)
+}
+
+// git returns stdout alone; stderr goes into the error, since stdout is
+// usually an object id.
+func git(dir string, env []string, args ...string) ([]byte, error) {
+ return gitIn(dir, nil, env, args...)
+}
+
+func gitIn(dir string, stdin []byte, env []string, args ...string) ([]byte, error) {
+ cmd := exec.Command("git", args...)
+ cmd.Dir = dir
+ cmd.Env = gitEnv(env)
+ if stdin != nil {
+ cmd.Stdin = bytes.NewReader(stdin)
+ }
+ var out, fail bytes.Buffer
+ cmd.Stdout, cmd.Stderr = &out, &fail
+ if err := cmd.Run(); err != nil {
+ // %w, so a caller can ask git's exit status.
+ if msg := bytes.TrimSpace(fail.Bytes()); len(msg) > 0 {
+ return out.Bytes(), fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, msg)
+ }
+ return out.Bytes(), fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
+ }
+ return out.Bytes(), nil
+}
+
+// gitInit uses an empty template, so no sample hooks or placeholder
+// description, and an explicit branch, so nothing is inherited from
+// init.defaultBranch.
+func gitInit(dir string, bare bool) error {
+ tmpl, err := os.MkdirTemp("", "git-forge-template")
+ if err != nil {
+ return err
+ }
+ defer os.RemoveAll(tmpl)
+
+ args := []string{"init", "--quiet", "--template=" + tmpl, "--initial-branch=master"}
+ if bare {
+ args = append(args, "--bare")
+ }
+ _, err = git("", nil, append(args, dir)...)
+ return err
+}
+
+// change is one path in a tree. Nil data removes it.
+type change struct {
+ path string
+ data []byte
+}
+
+// branch is the only branch git-forge ever writes.
+const branch = "refs/heads/master"
+
+// commitWith moves the branch with a compare and swap, retrying when it moved.
+// build is handed the commit the branch points at, "" when empty, on every
+// attempt: a sequential id must be read again after a lost race. It returns
+// the subject too.
+func commitWith(dir string, author, committer Author, build func(base string) ([]change, string, error)) error {
+ defer lockRepo(dir)()
+
+ var err error
+ for attempt := 0; attempt < 8; attempt++ {
+ if attempt > 0 {
+ // Git holds its ref lock for a moment.
+ time.Sleep(time.Duration(attempt) * 10 * time.Millisecond)
+ }
+ var base string
+ if base, err = resolveRef(dir, branch); err != nil {
+ return err
+ }
+ var changes []change
+ var subject string
+ if changes, subject, err = build(base); err != nil {
+ return err
+ }
+ var tree string
+ if tree, err = writeTree(dir, base, changes); err != nil {
+ return err
+ }
+ var commit string
+ if commit, err = commitTree(dir, tree, base, subject, author, committer); err != nil {
+ return err
+ }
+ // An empty old value means the branch must not exist yet.
+ if _, err = git(dir, nil, "update-ref", branch, commit, base); err == nil {
+ // Plumbing runs no maintenance, and nothing else collects these
+ // repositories.
+ gcAuto(dir)
+ return nil
+ }
+ }
+ return fmt.Errorf("%s: gave up after eight attempts: %v", dir, err)
+}
+
+// Git's own thresholds decide whether anything happens; gc.autoDetach keeps
+// the work off the request that triggered it.
+func gcAuto(dir string) {
+ env := []string{"GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=gc.autoDetach", "GIT_CONFIG_VALUE_0=true"}
+ if _, err := git(dir, env, "gc", "--auto", "--quiet"); err != nil {
+ log.Printf("git: gc --auto in %s: %v", dir, err)
+ }
+}
+
+// lockRepo serialises this process's writers to one repository, so they do not
+// burn their retries on each other. Git's compare and swap is what makes them
+// correct.
+var writeLocks sync.Map // repository directory to *sync.Mutex
+
+func lockRepo(dir string) func() {
+ value, _ := writeLocks.LoadOrStore(dir, &sync.Mutex{})
+ mu := value.(*sync.Mutex)
+ mu.Lock()
+ return mu.Unlock
+}
+
+// resolveRef returns "" for a ref that is not there, and an error for a
+// repository that is not: rev-parse exits 1 for the first and 128 for the
+// second, and a missing directory must not read as an empty page.
+func resolveRef(dir, ref string) (string, error) {
+ out, err := git(dir, nil, "rev-parse", "--verify", "--quiet", ref)
+ if err == nil {
+ return strings.TrimSpace(string(out)), nil
+ }
+ var exit *exec.ExitError
+ if errors.As(err, &exit) && exit.ExitCode() == 1 {
+ return "", nil
+ }
+ return "", err
+}
+
+// writeTree uses a temporary index rather than mktree, because these paths
+// nest and update-index rewrites the intermediate trees. The index sits
+// outside the repository: nothing the forge writes goes inside one.
+func writeTree(dir, base string, changes []change) (string, error) {
+ // A directory, not a file: git refuses an index file that exists and is
+ // empty.
+ tmp, err := os.MkdirTemp("", "git-forge-index")
+ if err != nil {
+ return "", err
+ }
+ defer os.RemoveAll(tmp)
+ env := []string{"GIT_INDEX_FILE=" + filepath.Join(tmp, "index")}
+
+ if base != "" {
+ if _, err := git(dir, env, "read-tree", base); err != nil {
+ return "", err
+ }
+ }
+ for _, c := range changes {
+ if c.data == nil {
+ if _, err := git(dir, env, "update-index", "--force-remove", c.path); err != nil {
+ return "", err
+ }
+ continue
+ }
+ blob, err := gitIn(dir, c.data, nil, "hash-object", "-w", "--stdin")
+ if err != nil {
+ return "", err
+ }
+ id := strings.TrimSpace(string(blob))
+ if _, err := git(dir, env, "update-index", "--add", "--cacheinfo", "100644,"+id+","+c.path); err != nil {
+ return "", err
+ }
+ }
+ tree, err := git(dir, env, "write-tree")
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(tree)), nil
+}
+
+func commitTree(dir, tree, parent, subject string, author, committer Author) (string, error) {
+ args := []string{"commit-tree", tree, "-m", subject}
+ if parent != "" {
+ args = append(args, "-p", parent)
+ }
+ env := []string{
+ "GIT_AUTHOR_NAME=" + author.Name,
+ "GIT_AUTHOR_EMAIL=" + author.Email,
+ "GIT_COMMITTER_NAME=" + committer.Name,
+ "GIT_COMMITTER_EMAIL=" + committer.Email,
+ }
+ out, err := git(dir, env, args...)
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(out)), nil
+}
+
+// catFile reads one blob, named by its id or as commit:path.
+func catFile(dir, rev string) ([]byte, error) {
+ return git(dir, nil, "cat-file", "blob", rev)
+}
+
+func listTreeRecursive(dir, commit string) ([]string, error) {
+ return treePaths(git(dir, nil, "ls-tree", "-r", "--name-only", commit))
+}
+
+func listTree(dir, commit, prefix string) ([]string, error) {
+ return treePaths(git(dir, nil, "ls-tree", "--name-only", commit, prefix))
+}
+
+func treePaths(out []byte, err error) ([]string, error) {
+ if err != nil {
+ return nil, err
+ }
+ var paths []string
+ for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
+ if line != "" {
+ paths = append(paths, line)
+ }
+ }
+ return paths, nil
+}
+
+// catFiles reads many blobs in one exec, in the order asked; a missing path
+// yields nil.
+func catFiles(dir string, specs []string) ([][]byte, error) {
+ if len(specs) == 0 {
+ return nil, nil
+ }
+ out, err := gitIn(dir, []byte(strings.Join(specs, "\n")+"\n"), nil, "cat-file", "--batch")
+ if err != nil {
+ return nil, err
+ }
+
+ blobs := make([][]byte, 0, len(specs))
+ for rest := out; len(blobs) < len(specs); {
+ line, tail, ok := bytes.Cut(rest, []byte("\n"))
+ if !ok {
+ break
+ }
+ fields := bytes.Fields(line)
+ // "<oid> <type> <size>" for a hit, "<spec> missing" for a miss.
+ if len(fields) != 3 {
+ blobs = append(blobs, nil)
+ rest = tail
+ continue
+ }
+ size, err := strconv.Atoi(string(fields[2]))
+ if err != nil || size > len(tail) {
+ return nil, fmt.Errorf("cat-file --batch: unreadable record %q", line)
+ }
+ blobs = append(blobs, tail[:size])
+ rest = tail[size:]
+ rest = bytes.TrimPrefix(rest, []byte("\n"))
+ }
+ return blobs, nil
+}
+
+// grepTree is the whole search. A fixed string, not a regular expression: the
+// input is hostile.
+func grepTree(dir, commit, query, pathspec string) ([]string, error) {
+ out, err := git(dir, nil, "grep", "-i", "-l", "-F", "-e", query, commit, "--", pathspec)
+ if err != nil {
+ // Exit 1 is nothing matched; anything else must not read as an empty
+ // result.
+ var exit *exec.ExitError
+ if errors.As(err, &exit) && exit.ExitCode() == 1 {
+ return nil, nil
+ }
+ return nil, err
+ }
+ var paths []string
+ for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
+ // Lines are "<commit>:<path>".
+ if _, path, ok := strings.Cut(line, ":"); ok {
+ paths = append(paths, path)
+ }
+ }
+ return paths, nil
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..e2f2f39
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,10 @@
+module git-forge
+
+go 1.25.0
+
+require (
+ github.com/yuin/goldmark v1.8.5
+ golang.org/x/crypto v0.55.0
+)
+
+require golang.org/x/sys v0.47.0 // indirect
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..2ee02dc
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,6 @@
+github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
+github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
+golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
diff --git a/httpgit.go b/httpgit.go
new file mode 100644
index 0000000..f8244ed
--- /dev/null
+++ b/httpgit.go
@@ -0,0 +1,175 @@
+package main
+
+import (
+ "log"
+ "net/http"
+ "net/http/cgi"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+)
+
+// gitBackend is git's own CGI, located by asking git. On first use, not at
+// startup: the filter subcommands must not fork git.
+var gitBackend = sync.OnceValue(func() string {
+ out, err := git("", nil, "--exec-path")
+ if err != nil {
+ log.Printf("git: no exec path, so no push or pull over http: %v", err)
+ return ""
+ }
+ return filepath.Join(strings.TrimSpace(string(out)), "git-http-backend")
+})
+
+var gitProtocolRE = regexp.MustCompile(`^[a-zA-Z0-9=:._-]+$`)
+
+// transferring bounds the git-http-backend children in flight: an upload-pack
+// packs with a thread per core.
+var transferring = make(chan struct{}, 8)
+
+// What one push may weigh; git has no bound of its own. The wiki is held to
+// far less, because its knob can be set to any account at all.
+const (
+ maxPushCode = 1 << 30
+ maxPushWiki = 16 << 20
+)
+
+// handleGitHTTP authorises before anything is exec'd. No form token and no
+// session cookie: a push authenticated by a cookie could be driven from
+// another site.
+func (f *Forge) handleGitHTTP(w http.ResponseWriter, r *http.Request, path, kind, rest string) {
+ backend := gitBackend()
+ if backend == "" {
+ http.Error(w, "git-http-backend is not installed\n", http.StatusInternalServerError)
+ return
+ }
+ v, ok := f.gitViewer(w, r, path)
+ if !ok {
+ return
+ }
+ // What the viewer may not read does not exist, as on the web. Anonymous is
+ // challenged instead, so git sends credentials, and challenged whether or
+ // not the repository is there: a 401 beside a 404 would name the private
+ // ones.
+ repo, exists := f.store.Repo(path)
+ if !exists || !v.CanRead(repo) {
+ if v.SignedIn() {
+ http.NotFound(w, r)
+ } else {
+ f.gitChallenge(w, "authentication required")
+ }
+ return
+ }
+ // A feature turned off keeps its repository on disk.
+ if (kind == "issues" && repo.Issues == featDisabled) || (kind == "wiki" && repo.Wiki == featDisabled) {
+ http.NotFound(w, r)
+ return
+ }
+ dir := path + gitSuffix(kind)
+ if _, err := os.Stat(filepath.Join(f.store.reposDir, dir)); err != nil {
+ http.NotFound(w, r)
+ return
+ }
+
+ write := isPush(r, rest)
+ if write && kind == "issues" {
+ // The tracker is well formed because the server is its only writer.
+ http.Error(w, "the issue tracker is written by the forge, not by push. clone and read it, but file issues through the web\n",
+ http.StatusForbidden)
+ return
+ }
+ if write {
+ // The wiki knob, not the code write grant: documentation fixes and
+ // code are separate permissions.
+ allowed := v.CanWrite(repo)
+ if kind == "wiki" {
+ allowed = v.CanWiki(repo)
+ }
+ switch {
+ case allowed:
+ case v.SignedIn():
+ http.Error(w, "you do not have that access to this repository\n", http.StatusForbidden)
+ return
+ default:
+ f.gitChallenge(w, "authentication required")
+ return
+ }
+ }
+
+ weight := maxPushCode
+ if kind == "wiki" {
+ weight = maxPushWiki
+ }
+ // PATH_INFO comes from the resolved repository, not the URL. The receive
+ // settings come through the environment: nothing the forge writes goes
+ // inside a repository.
+ env := []string{
+ "GIT_PROJECT_ROOT=" + f.store.reposDir,
+ "GIT_HTTP_EXPORT_ALL=1",
+ "REMOTE_USER=" + v.Name,
+ "PATH_INFO=/" + dir + "/" + rest,
+ "SCRIPT_NAME=",
+ "GIT_CONFIG_NOSYSTEM=1",
+ "GIT_CONFIG_GLOBAL=/dev/null",
+ "GIT_CONFIG_COUNT=2",
+ "GIT_CONFIG_KEY_0=receive.unpackLimit",
+ "GIT_CONFIG_VALUE_0=1",
+ "GIT_CONFIG_KEY_1=receive.maxInputSize",
+ "GIT_CONFIG_VALUE_1=" + strconv.Itoa(weight),
+ }
+ // As HTTP_GIT_PROTOCOL git-http-backend would not read it, and every
+ // client would fall back to protocol v0.
+ if p := r.Header.Get("Git-Protocol"); p != "" && gitProtocolRE.MatchString(p) {
+ env = append(env, "GIT_PROTOCOL="+p)
+ }
+ // git sends a push past http.postBuffer chunked, which net/http/cgi
+ // refuses; the framing is already decoded here.
+ r.TransferEncoding = nil
+ release, ok := childSlot(transferring, r)
+ if !ok {
+ return
+ }
+ defer release()
+ (&cgi.Handler{Path: backend, Env: env, Stderr: os.Stdout}).ServeHTTP(w, r)
+
+ if write {
+ // A push that failed leaves nothing to collect.
+ gcAuto(filepath.Join(f.store.reposDir, dir))
+ }
+}
+
+// gitViewer reads Basic auth carrying a scoped push token, never a password:
+// git cannot be asked for a second factor, and a token is separately scoped
+// and separately revoked. No credential is the anonymous viewer.
+func (f *Forge) gitViewer(w http.ResponseWriter, r *http.Request, path string) (Viewer, bool) {
+ given, secret, sent := r.BasicAuth()
+ if !sent {
+ return Viewer{}, true
+ }
+ name, u, ok := f.checkToken(given, secret, path)
+ if !ok {
+ f.gitChallenge(w, "bad account name or token")
+ return Viewer{}, false
+ }
+ // adminInEffect, not the raw flag: the web and git have to agree.
+ return Viewer{Name: name, Admin: u.adminInEffect()}, true
+}
+
+func (f *Forge) gitChallenge(w http.ResponseWriter, msg string) {
+ w.Header().Set("WWW-Authenticate", `Basic realm="`+f.siteName()+`", charset="UTF-8"`)
+ http.Error(w, msg+"\n", http.StatusUnauthorized)
+}
+
+// isPush reports whether this request is the start or the body of a push.
+func isPush(r *http.Request, rest string) bool {
+ return rest == "git-receive-pack" || r.URL.Query().Get("service") == "git-receive-pack"
+}
+
+func gitSuffix(kind string) string {
+ if kind == "" {
+ return ".git"
+ }
+ return "." + kind + ".git"
+}
diff --git a/httpgit_test.go b/httpgit_test.go
new file mode 100644
index 0000000..307d724
--- /dev/null
+++ b/httpgit_test.go
@@ -0,0 +1,457 @@
+package main
+
+import (
+ "bytes"
+ "crypto/rand"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// runGit drives the real git binary, which is what will be talking to the
+// forge.
+func runGit(t *testing.T, dir string, args ...string) (string, error) {
+ t.Helper()
+ cmd := exec.Command("git", args...)
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(),
+ "GIT_TERMINAL_PROMPT=0", // fail rather than block on a prompt
+ "GIT_CONFIG_NOSYSTEM=1",
+ "GIT_CONFIG_GLOBAL=/dev/null",
+ "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@example.com",
+ "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@example.com",
+ )
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+}
+
+func addRepo(t *testing.T, f *Forge, path string, repo Repo) {
+ t.Helper()
+ if repo.Labels == nil {
+ repo.Labels = []string{}
+ }
+ if repo.Grants == nil {
+ repo.Grants = map[string]string{}
+ }
+ if err := f.store.CreateRepo(path, repo, f.store.forgeAuthor()); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func addToken(t *testing.T, f *Forge, name, repo string) string {
+ t.Helper()
+ secret, token := mintToken(repo)
+ err := f.store.UpdateUsers("users: mint a push token for "+name, f.store.forgeAuthor(),
+ func(m map[string]User) error {
+ u := m[name]
+ u.Tokens = append(u.Tokens, token)
+ m[name] = u
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return secret
+}
+
+func TestGitOverHTTP(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addUser(t, f, "eve", "hunter2hunter2")
+ addRepo(t, f, "infra/dns", Repo{
+ Desc: "zones", Visibility: visPrivate, Issues: featMembers, Wiki: featDisabled,
+ Grants: map[string]string{"alice": grantWrite},
+ })
+ addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featDisabled})
+
+ srv := httptest.NewServer(f.routes())
+ defer srv.Close()
+ token := addToken(t, f, "alice", "infra/dns")
+ authed := strings.Replace(srv.URL, "http://", "http://alice:"+token+"@", 1)
+
+ refs := func(url string) *http.Response {
+ t.Helper()
+ resp, err := srv.Client().Get(url)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ return resp
+ }
+
+ // A public repository read anonymously must never see a challenge, or
+ // every clone starts with a password prompt.
+ public := refs(srv.URL + "/notes.git/info/refs?service=git-upload-pack")
+ if public.StatusCode != http.StatusOK {
+ t.Errorf("anonymous read of a public repo: %d", public.StatusCode)
+ }
+ if got := public.Header.Get("WWW-Authenticate"); got != "" {
+ t.Errorf("anonymous read of a public repo challenged: %q", got)
+ }
+
+ // A private one must, so that git knows to send credentials.
+ private := refs(srv.URL + "/infra/dns.git/info/refs?service=git-upload-pack")
+ if private.StatusCode != http.StatusUnauthorized {
+ t.Errorf("anonymous read of a private repo: %d", private.StatusCode)
+ }
+ if private.Header.Get("WWW-Authenticate") == "" {
+ t.Error("anonymous read of a private repo did not challenge")
+ }
+
+ // The tracker is readable and unpushable, even by an account that may push
+ // code.
+ if got := refs(authed + "/infra/dns.issues.git/info/refs?service=git-receive-pack"); got.StatusCode != http.StatusForbidden {
+ t.Errorf("push to the issues repo: %d", got.StatusCode)
+ }
+ // A wiki that is disabled has no repository at all, which is said to an
+ // account that may read the repository.
+ if got := refs(authed + "/infra/dns.wiki.git/info/refs?service=git-upload-pack"); got.StatusCode != http.StatusNotFound {
+ t.Errorf("a disabled wiki: %d", got.StatusCode)
+ }
+ // Anonymous is challenged for whatever it may not read, there or not, so a
+ // 401 beside a 404 cannot say which private names are taken.
+ for _, path := range []string{"/infra/dns.wiki.git", "/nothing.git", "/infra/nothing.git"} {
+ got := refs(srv.URL + path + "/info/refs?service=git-upload-pack")
+ if got.StatusCode != http.StatusUnauthorized || got.Header.Get("WWW-Authenticate") == "" {
+ t.Errorf("anonymous read of %s: %d, wanted a challenge", path, got.StatusCode)
+ }
+ }
+
+ work := t.TempDir()
+ if out, err := runGit(t, work, "clone", authed+"/infra/dns.git", "dns"); err != nil {
+ t.Fatalf("clone with a token: %v\n%s", err, out)
+ }
+ clone := filepath.Join(work, "dns")
+ if err := os.WriteFile(filepath.Join(clone, "zone"), []byte("example.com. IN A 192.0.2.1\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if out, err := runGit(t, clone, "add", "zone"); err != nil {
+ t.Fatalf("add: %v\n%s", err, out)
+ }
+ if out, err := runGit(t, clone, "commit", "-m", "dns: add a zone"); err != nil {
+ t.Fatalf("commit: %v\n%s", err, out)
+ }
+ if out, err := runGit(t, clone, "push", "origin", "master"); err != nil {
+ t.Fatalf("push with a token: %v\n%s", err, out)
+ }
+
+ bare := filepath.Join(f.store.reposDir, "infra/dns.git")
+ if out, err := runGit(t, bare, "rev-parse", "master"); err != nil {
+ t.Fatalf("the push did not land: %v\n%s", err, out)
+ }
+
+ // receive.unpackLimit is passed in the environment, so the push arrives as
+ // one packfile and the repository's own config is still untouched.
+ packs, err := filepath.Glob(filepath.Join(bare, "objects/pack/*.pack"))
+ if err != nil || len(packs) != 1 {
+ t.Errorf("expected one packfile, got %v", packs)
+ }
+ config, err := os.ReadFile(filepath.Join(bare, "config"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(config), "unpackLimit") || strings.Contains(string(config), "gc") {
+ t.Errorf("git-forge wrote into the repository's config:\n%s", config)
+ }
+
+ // A token is scoped to one repository and is not a password.
+ wrong := strings.Replace(srv.URL, "http://", "http://alice:"+addToken(t, f, "alice", "notes")+"@", 1)
+ if out, err := runGit(t, clone, "push", wrong+"/infra/dns.git", "master"); err == nil {
+ t.Errorf("a token for another repository pushed:\n%s", out)
+ }
+ byPassword := strings.Replace(srv.URL, "http://", "http://alice:hunter2hunter2@", 1)
+ if out, err := runGit(t, clone, "push", byPassword+"/infra/dns.git", "master"); err == nil {
+ t.Errorf("an account password pushed:\n%s", out)
+ }
+ // A grant on another repository is not a grant on this one.
+ eve := strings.Replace(srv.URL, "http://", "http://eve:"+addToken(t, f, "eve", "infra/dns")+"@", 1)
+ if out, err := runGit(t, clone, "push", eve+"/infra/dns.git", "master"); err == nil {
+ t.Errorf("an account with no write grant pushed:\n%s", out)
+ }
+ // An account that may not read it is told what the web tells it: nothing.
+ if got := refs(eve + "/infra/dns.git/info/refs?service=git-upload-pack"); got.StatusCode != http.StatusNotFound {
+ t.Errorf("a token without a read grant: %d", got.StatusCode)
+ }
+}
+
+// git sends whatever the credential helper holds, and the name is folded to
+// find the account. Everything after that keys off the account rather than what
+// was typed, or users.json gains a record it would refuse to load next start.
+func TestTokenNameIsTheAccountsOwn(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addRepo(t, f, "notes", Repo{
+ Visibility: visPrivate, Issues: featDisabled, Wiki: featDisabled,
+ Grants: map[string]string{"alice": grantWrite},
+ })
+ secret := addToken(t, f, "alice", "notes")
+
+ srv := httptest.NewServer(f.routes())
+ defer srv.Close()
+ authed := strings.Replace(srv.URL, "http://", "http://Alice:"+secret+"@", 1)
+ work := t.TempDir()
+ if out, err := runGit(t, work, "clone", authed+"/notes.git", "notes"); err != nil {
+ t.Fatalf("a token sent under a capitalised name: %v\n%s", err, out)
+ }
+
+ if users := f.store.Users(); len(users) != 1 {
+ t.Errorf("one authentication left %d accounts: %v", len(users), users)
+ }
+ if u, _ := f.store.User("alice"); len(u.Tokens) != 1 || u.Tokens[0].Used != today() {
+ t.Errorf("the token that was used is not the one marked used: %+v", u.Tokens)
+ }
+ if _, err := openStore(f.store.dir); err != nil {
+ t.Errorf("the datadir no longer opens: %v", err)
+ }
+}
+
+// git sends anything past its http.postBuffer with chunked transfer encoding,
+// and net/http/cgi refuses a chunked body. Every other push here is small
+// enough to hide it, so this one is over the line and is checked for coming
+// back whole rather than merely being accepted.
+func TestPushPastThePostBuffer(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addRepo(t, f, "notes", Repo{
+ Visibility: visPrivate, Issues: featDisabled, Wiki: featDisabled,
+ Grants: map[string]string{"alice": grantWrite},
+ })
+ srv := httptest.NewServer(f.routes())
+ defer srv.Close()
+ authed := strings.Replace(srv.URL, "http://", "http://alice:"+addToken(t, f, "alice", "notes")+"@", 1)
+
+ work := t.TempDir()
+ if out, err := runGit(t, work, "clone", authed+"/notes.git", "code"); err != nil {
+ t.Fatalf("clone: %v\n%s", err, out)
+ }
+ // Random, so that it does not compress back under the buffer.
+ blob := make([]byte, 4<<20)
+ if _, err := rand.Read(blob); err != nil {
+ t.Fatal(err)
+ }
+ code := filepath.Join(work, "code")
+ if err := os.WriteFile(filepath.Join(code, "blob"), blob, 0600); err != nil {
+ t.Fatal(err)
+ }
+ for _, args := range [][]string{{"add", "-A"}, {"commit", "-qm", "notes: a big one"}} {
+ if out, err := runGit(t, code, args...); err != nil {
+ t.Fatalf("%v: %v\n%s", args, err, out)
+ }
+ }
+ if out, err := runGit(t, code, "push", "origin", "master"); err != nil {
+ t.Fatalf("a push past the post buffer was refused: %v\n%s", err, out)
+ }
+
+ back := t.TempDir()
+ if out, err := runGit(t, back, "clone", authed+"/notes.git", "again"); err != nil {
+ t.Fatalf("cloning it back: %v\n%s", err, out)
+ }
+ got, err := os.ReadFile(filepath.Join(back, "again", "blob"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(got, blob) {
+ t.Errorf("%d bytes came back, of the %d that went in", len(got), len(blob))
+ }
+}
+
+// A token is a credential and not a permission: minting asks only that the
+// account can reach the repository, and what it may do is decided on arrival.
+// Gating the mint on the write grant instead would lock a read grant out of
+// cloning and a wiki editor out of pushing a page.
+func TestTokenCarriesTheAccountsOwnAccess(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "docs", "hunter2hunter2")
+ addRepo(t, f, "notes", Repo{
+ Visibility: visPrivate, Issues: featDisabled, Wiki: featMembers,
+ Grants: map[string]string{"docs": grantRead},
+ })
+ h := f.routes()
+ session := signIn(t, f, h, "docs")
+ form := url.Values{"csrf": {f.csrf(withCookies("/", session))}, "repo": {"notes"}}
+
+ w := post(h, "/settings/tokens", form, session)
+ if w.Code != http.StatusOK {
+ t.Fatalf("a read grant could not mint a token: status %d", w.Code)
+ }
+ secret := between(w.Body.String(), `<pre id="token">`, "</pre>")
+ if secret == "" {
+ t.Fatal("no token was shown")
+ }
+
+ srv := httptest.NewServer(h)
+ defer srv.Close()
+ authed := strings.Replace(srv.URL, "http://", "http://docs:"+secret+"@", 1)
+ work := t.TempDir()
+
+ if out, err := runGit(t, work, "clone", authed+"/notes.git", "code"); err != nil {
+ t.Fatalf("a read grant could not clone a private repository: %v\n%s", err, out)
+ }
+ if out, err := runGit(t, work, "clone", authed+"/notes.wiki.git", "wiki"); err != nil {
+ t.Fatalf("cloning the wiki: %v\n%s", err, out)
+ }
+
+ // The wiki knob says this account may write documentation.
+ wiki := filepath.Join(work, "wiki")
+ if err := os.WriteFile(filepath.Join(wiki, "index.md"), []byte("# hello\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ for _, args := range [][]string{{"add", "-A"}, {"commit", "-m", "wiki: a page"}} {
+ if out, err := runGit(t, wiki, args...); err != nil {
+ t.Fatalf("%v: %v\n%s", args, err, out)
+ }
+ }
+ if out, err := runGit(t, wiki, "push", "origin", "master"); err != nil {
+ t.Errorf("a wiki editor could not push the wiki: %v\n%s", err, out)
+ }
+
+ // The same token must not push code, because the account cannot.
+ code := filepath.Join(work, "code")
+ if out, err := runGit(t, code, "commit", "--allow-empty", "-m", "nope"); err != nil {
+ t.Fatalf("commit: %v\n%s", err, out)
+ }
+ if out, err := runGit(t, code, "push", "origin", "master"); err == nil {
+ t.Errorf("a read grant pushed code with a token:\n%s", out)
+ }
+}
+
+// git has no opinion about how large a push may be. The wiki is the case that
+// matters, since its knob can be set to any account at all.
+func TestPushIsBounded(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featUsers})
+ srv := httptest.NewServer(f.routes())
+ defer srv.Close()
+ authed := strings.Replace(srv.URL, "http://", "http://alice:"+addToken(t, f, "alice", "notes")+"@", 1)
+
+ work := t.TempDir()
+ if out, err := runGit(t, work, "clone", authed+"/notes.wiki.git", "wiki"); err != nil {
+ t.Fatalf("clone: %v\n%s", err, out)
+ }
+ wiki := filepath.Join(work, "wiki")
+ commit := func(name string, body []byte) {
+ t.Helper()
+ if err := os.WriteFile(filepath.Join(wiki, name), body, 0600); err != nil {
+ t.Fatal(err)
+ }
+ for _, args := range [][]string{{"add", "-A"}, {"commit", "-qm", "wiki: " + name}} {
+ if out, err := runGit(t, wiki, args...); err != nil {
+ t.Fatalf("%v: %v\n%s", args, err, out)
+ }
+ }
+ }
+
+ commit("index.md", []byte("# a page\n"))
+ if out, err := runGit(t, wiki, "push", "origin", "master"); err != nil {
+ t.Fatalf("a page sized push was refused: %v\n%s", err, out)
+ }
+
+ // Random, so that it does not compress: the bound is on the pack, and a
+ // megabyte of zeroes would sail through one meant to stop a gigabyte.
+ big := make([]byte, maxPushWiki+1<<20)
+ if _, err := rand.Read(big); err != nil {
+ t.Fatal(err)
+ }
+ commit("big.md", big)
+ out, err := runGit(t, wiki, "push", "origin", "master")
+ if err == nil {
+ t.Fatalf("a push past the bound was accepted:\n%s", out)
+ }
+ if !strings.Contains(out, "maximum allowed size") {
+ t.Errorf("refused for some reason other than the bound:\n%s", out)
+ }
+}
+
+// Any account can reach every public repository, so without a bound a loop
+// grows users.json, and the config repo's history with it, without end.
+func TestTokensAreBounded(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "bob", "hunter2hunter2")
+ addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featDisabled})
+ h := f.routes()
+ session := signIn(t, f, h, "bob")
+ form := url.Values{"csrf": {f.csrf(withCookies("/", session))}, "repo": {"notes"}}
+
+ // Fill the account through the store, so that what is being measured is
+ // the bound and not the rate limit that also guards this route.
+ for range maxTokens {
+ addToken(t, f, "bob", "notes")
+ }
+ if w := post(h, "/settings/tokens", form, session); w.Code != http.StatusConflict {
+ t.Errorf("minting past the bound: status %d", w.Code)
+ }
+ if u, _ := f.store.User("bob"); len(u.Tokens) != maxTokens {
+ t.Errorf("the account holds %d tokens, wanted %d", len(u.Tokens), maxTokens)
+ }
+
+ // Revoking one makes room for exactly one more.
+ u, _ := f.store.User("bob")
+ revoke := url.Values{"csrf": {form.Get("csrf")}, "id": {u.Tokens[0].ID}}
+ if w := post(h, "/settings/tokens/revoke", revoke, session); w.Code != http.StatusSeeOther {
+ t.Fatalf("revoking: status %d", w.Code)
+ }
+ if w := post(h, "/settings/tokens", form, session); w.Code != http.StatusOK {
+ t.Errorf("minting into the room a revoke made: status %d", w.Code)
+ }
+ if u, _ := f.store.User("bob"); len(u.Tokens) != maxTokens {
+ t.Errorf("the account holds %d tokens, wanted %d", len(u.Tokens), maxTokens)
+ }
+}
+
+// The whole token life through the pages, then a push with what came out.
+func TestTokenMintAndRevoke(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "bob", "hunter2hunter2")
+ addRepo(t, f, "notes", Repo{
+ Visibility: visPrivate, Issues: featDisabled, Wiki: featDisabled,
+ Grants: map[string]string{"bob": grantWrite},
+ })
+ addRepo(t, f, "closed", Repo{Visibility: visPrivate, Issues: featDisabled, Wiki: featDisabled})
+ h := f.routes()
+
+ session := signIn(t, f, h, "bob")
+ token := f.csrf(withCookies("/", session))
+
+ // A repository the account cannot reach at all mints nothing.
+ if w := post(h, "/settings/tokens", url.Values{"csrf": {token}, "repo": {"closed"}}, session); w.Code != http.StatusForbidden {
+ t.Errorf("minting for a repository the account cannot read: status %d", w.Code)
+ }
+ w := post(h, "/settings/tokens", url.Values{"csrf": {token}, "repo": {"notes"}}, session)
+ if w.Code != http.StatusOK {
+ t.Fatalf("minting: status %d", w.Code)
+ }
+ secret := between(w.Body.String(), `<pre id="token">`, "</pre>")
+ if secret == "" {
+ t.Fatal("no token was shown")
+ }
+ u, _ := f.store.User("bob")
+ if len(u.Tokens) != 1 || u.Tokens[0].Repo != "notes" || u.Tokens[0].Hash == secret {
+ t.Fatalf("the token record is wrong: %+v", u.Tokens)
+ }
+
+ srv := httptest.NewServer(h)
+ defer srv.Close()
+ authed := strings.Replace(srv.URL, "http://", "http://bob:"+secret+"@", 1)
+ work := t.TempDir()
+ if out, err := runGit(t, work, "clone", authed+"/notes.git", "notes"); err != nil {
+ t.Fatalf("clone with the minted token: %v\n%s", err, out)
+ }
+
+ // Revoking it stops it at once.
+ revoke := url.Values{"csrf": {token}, "id": {u.Tokens[0].ID}}
+ if w := post(h, "/settings/tokens/revoke", revoke, session); w.Code != http.StatusSeeOther {
+ t.Fatalf("revoking: status %d", w.Code)
+ }
+ if u, _ := f.store.User("bob"); len(u.Tokens) != 0 {
+ t.Errorf("the token survived revocation: %+v", u.Tokens)
+ }
+ if out, err := runGit(t, work, "clone", authed+"/notes.git", "again"); err == nil {
+ t.Errorf("a revoked token still cloned:\n%s", out)
+ }
+}
diff --git a/issues.go b/issues.go
new file mode 100644
index 0000000..6c300d1
--- /dev/null
+++ b/issues.go
@@ -0,0 +1,555 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "log"
+ "net/http"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// Issues live in {repo}.issues.git, where the server is the only writer. These
+// are paths inside the git tree, not files on disk:
+//
+// issues/.next next sequential id
+// issues/{id}.json one issue
+// issues/{id}/{nnnn}.json one comment, zero padded so they sort
+type Issue struct {
+ ID int `json:"id"`
+ Title string `json:"title"`
+ Body string `json:"body"`
+ State string `json:"state"`
+ Author string `json:"author"`
+ Labels []string `json:"labels"`
+ Created time.Time `json:"created"`
+ Updated time.Time `json:"updated"`
+}
+
+type Comment struct {
+ Author string `json:"author"`
+ Body string `json:"body"`
+ Created time.Time `json:"created"`
+}
+
+const (
+ issueOpen = "open"
+ issueClosed = "closed"
+
+ maxTitle = 200
+ maxBody = 64 << 10
+)
+
+func (f *Forge) issuesDir(path string) string {
+ return filepath.Join(f.store.reposDir, path+".issues.git")
+}
+
+// handleIssues routes everything under {repo}/issues.
+func (f *Forge) handleIssues(w http.ResponseWriter, r *http.Request, path string, repo Repo, rest string) {
+ if repo.Issues == featDisabled {
+ f.fail(w, r, http.StatusNotFound, "this repository has issues turned off")
+ return
+ }
+ // rest is issues, issues/new, issues/{id}, or issues/{id}/{action}.
+ parts := strings.Split(rest, "/")
+ switch {
+ case len(parts) == 1 && isGet(r):
+ f.issueList(w, r, path, repo)
+ case len(parts) == 1 && r.Method == http.MethodPost:
+ if f.checkPost(w, r) {
+ f.issueCreate(w, r, path, repo)
+ }
+ case len(parts) == 2 && parts[1] == "new" && isGet(r):
+ f.issueForm(w, r, path, repo)
+ case len(parts) == 2 && isGet(r):
+ f.issueShow(w, r, path, repo, parts[1])
+ case len(parts) == 3 && r.Method == http.MethodPost:
+ if f.checkPost(w, r) {
+ f.issueAction(w, r, path, repo, parts[1], parts[2])
+ }
+ default:
+ f.fail(w, r, http.StatusNotFound, "no such page")
+ }
+}
+
+type issueListPage struct {
+ Path string
+ Issues []Issue
+ Query string
+ State string
+ Labels []string
+ Can bool // whether the viewer may open one
+}
+
+type issuePage struct {
+ Path string
+ Issue Issue
+ Rendered template.HTML
+ Comments []renderedComment
+ Labels []labelState
+ Can bool // may comment
+ Triage bool // may close, reopen, and label
+}
+
+// labelState is a repository's labels with this issue's ticked.
+type labelState struct {
+ Name string
+ On bool
+}
+
+func labelStates(repo Repo, issue Issue) []labelState {
+ on := map[string]bool{}
+ for _, label := range issue.Labels {
+ on[label] = true
+ }
+ out := make([]labelState, 0, len(repo.Labels))
+ for _, label := range repo.Labels {
+ out = append(out, labelState{Name: label, On: on[label]})
+ }
+ return out
+}
+
+type renderedComment struct {
+ Comment Comment
+ Rendered template.HTML
+}
+
+// issueList is the index, filtered by state and by git grep.
+func (f *Forge) issueList(w http.ResponseWriter, r *http.Request, path string, repo Repo) {
+ dir := f.issuesDir(path)
+ state := r.URL.Query().Get("state")
+ if state != issueClosed {
+ state = issueOpen
+ }
+ query := strings.TrimSpace(r.URL.Query().Get("q"))
+ // The query becomes an argument to git grep, which cannot hold a NUL.
+ if hasControlByte(query) {
+ f.fail(w, r, http.StatusBadRequest, "that search has a control byte in it")
+ return
+ }
+
+ issues, err := f.readIssues(dir, query)
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return
+ }
+ shown := []Issue{}
+ for _, issue := range issues {
+ if issue.State == state {
+ shown = append(shown, issue)
+ }
+ }
+ sort.Slice(shown, func(i, j int) bool { return shown[i].ID > shown[j].ID })
+
+ d := f.page(r)
+ d.Repo = f.repoBar(r, path, "", "issues", repo)
+ d.Data = issueListPage{
+ Path: path, Issues: shown, Query: query, State: state,
+ Labels: repo.Labels, Can: f.viewer(r).CanIssues(repo),
+ }
+ f.render(w, "issues.html", http.StatusOK, d)
+}
+
+// readIssues loads every issue, or those a search matched; a hit in a comment
+// counts for its issue.
+func (f *Forge) readIssues(dir, query string) ([]Issue, error) {
+ head, err := resolveRef(dir, branch)
+ if err != nil || head == "" {
+ return nil, err
+ }
+
+ var want []string
+ if query == "" {
+ paths, err := listTree(dir, head, "issues/")
+ if err != nil {
+ return nil, err
+ }
+ for _, p := range paths {
+ if strings.HasSuffix(p, ".json") {
+ want = append(want, head+":"+p)
+ }
+ }
+ } else {
+ hits, err := grepTree(dir, head, query, "issues/")
+ if err != nil {
+ return nil, err
+ }
+ seen := map[string]bool{}
+ for _, p := range hits {
+ id, ok := issueIDFromPath(p)
+ if !ok || seen[id] {
+ continue
+ }
+ seen[id] = true
+ want = append(want, head+":issues/"+id+".json")
+ }
+ }
+
+ blobs, err := catFiles(dir, want)
+ if err != nil {
+ return nil, err
+ }
+ issues := []Issue{}
+ for i, blob := range blobs {
+ var issue Issue
+ if blob == nil || json.Unmarshal(blob, &issue) != nil {
+ // Only a hand edit or a bad restore puts one here.
+ log.Printf("issues: %q does not read as an issue, leaving it out", want[i])
+ continue
+ }
+ issues = append(issues, issue)
+ }
+ return issues, nil
+}
+
+// issueIDFromPath maps issues/7.json and issues/7/0003.json alike to 7.
+func issueIDFromPath(p string) (string, bool) {
+ rest, ok := strings.CutPrefix(p, "issues/")
+ if !ok {
+ return "", false
+ }
+ id, _, _ := strings.Cut(rest, "/")
+ id = strings.TrimSuffix(id, ".json")
+ if _, err := strconv.Atoi(id); err != nil {
+ return "", false
+ }
+ return id, true
+}
+
+func (f *Forge) issueForm(w http.ResponseWriter, r *http.Request, path string, repo Repo) {
+ if !f.viewer(r).CanIssues(repo) {
+ f.fail(w, r, http.StatusForbidden, "you may not open an issue here")
+ return
+ }
+ d := f.page(r)
+ d.Repo = f.repoBar(r, path, "", "issues", repo)
+ d.Data = issueListPage{Path: path, Labels: repo.Labels, Can: true}
+ f.render(w, "issue-new.html", http.StatusOK, d)
+}
+
+func (f *Forge) issueShow(w http.ResponseWriter, r *http.Request, path string, repo Repo, idText string) {
+ dir := f.issuesDir(path)
+ head, err := resolveRef(dir, branch)
+ if err != nil || head == "" {
+ f.fail(w, r, http.StatusNotFound, "no such issue")
+ return
+ }
+ id, err := strconv.Atoi(idText)
+ if err != nil || id < 1 {
+ f.fail(w, r, http.StatusNotFound, "no such issue")
+ return
+ }
+ issue, err := readIssue(dir, head, id)
+ if err != nil {
+ f.fail(w, r, http.StatusNotFound, "no such issue")
+ return
+ }
+
+ paths, err := listTree(dir, head, fmt.Sprintf("issues/%d/", id))
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return
+ }
+ sort.Strings(paths) // zero padded, so this is chronological
+ specs := make([]string, 0, len(paths))
+ for _, p := range paths {
+ specs = append(specs, head+":"+p)
+ }
+ blobs, err := catFiles(dir, specs)
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return
+ }
+ comments := []renderedComment{}
+ for i, b := range blobs {
+ var c Comment
+ if b == nil || json.Unmarshal(b, &c) != nil {
+ log.Printf("issues: %q does not read as a comment, leaving it out", specs[i])
+ continue
+ }
+ comments = append(comments, renderedComment{Comment: c, Rendered: renderMarkdown(c.Body)})
+ }
+
+ v := f.viewer(r)
+ d := f.page(r)
+ d.Repo = f.repoBar(r, path, "", "issues", repo)
+ d.Data = issuePage{
+ Path: path, Issue: issue, Rendered: renderMarkdown(issue.Body),
+ Comments: comments, Labels: labelStates(repo, issue),
+ Can: v.CanIssues(repo), Triage: f.canTriage(v, repo, issue),
+ }
+ f.render(w, "issue.html", http.StatusOK, d)
+}
+
+// canTriage is who may close, reopen, and label: the author, and whoever may
+// push.
+func (f *Forge) canTriage(v Viewer, repo Repo, issue Issue) bool {
+ return (v.SignedIn() && v.Name == issue.Author) || v.CanWrite(repo)
+}
+
+func (f *Forge) issueCreate(w http.ResponseWriter, r *http.Request, path string, repo Repo) {
+ v := f.viewer(r)
+ if !v.CanIssues(repo) {
+ f.fail(w, r, http.StatusForbidden, "you may not open an issue here")
+ return
+ }
+ if !f.issueLimit.allow(f.peer(r), v, issuesPerHour) {
+ f.fail(w, r, http.StatusTooManyRequests, "too many issues opened in the last hour, try again later")
+ return
+ }
+ title := strings.TrimSpace(r.PostFormValue("title"))
+ body := strings.TrimSpace(r.PostFormValue("body"))
+ if title == "" || len(title) > maxTitle || len(body) > maxBody {
+ f.fail(w, r, http.StatusBadRequest, "an issue needs a title, and neither it nor the body may be enormous")
+ return
+ }
+ // The title becomes a mail subject and a log line, both one line.
+ if hasControlByte(title) {
+ f.fail(w, r, http.StatusBadRequest, "a title is one line, with no control byte in it")
+ return
+ }
+
+ now := time.Now().UTC().Truncate(time.Second)
+ issue := Issue{
+ Title: title, Body: body, State: issueOpen, Author: v.Name,
+ Labels: pickLabels(r, repo), Created: now, Updated: now,
+ }
+ dir := f.issuesDir(path)
+ author, committer := f.actor(r), f.store.forgeAuthor()
+
+ // Allocated inside the retry, so two at once cannot get the same number.
+ err := commitWith(dir, author, committer, func(base string) ([]change, string, error) {
+ next := 1
+ if base != "" {
+ if data, err := catFile(dir, base+":issues/.next"); err == nil {
+ if n, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil {
+ next = n
+ }
+ }
+ }
+ issue.ID = next
+ data, err := marshalJSON(issue)
+ if err != nil {
+ return nil, "", err
+ }
+ return []change{
+ {path: "issues/.next", data: []byte(strconv.Itoa(next+1) + "\n")},
+ {path: fmt.Sprintf("issues/%d.json", next), data: data},
+ }, fmt.Sprintf("issues: open %d", next), nil
+ })
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return
+ }
+ f.notifyIssue(path, repo, issue, v.Name, "opened")
+ http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", path, issue.ID), http.StatusSeeOther)
+}
+
+// issueRef is one issue as a request resolved it; every write reads it again
+// inside the retry.
+type issueRef struct {
+ dir string
+ path string
+ id int
+ issue Issue
+}
+
+// Each action answers a refusal itself, and reports whether to redirect.
+func (f *Forge) issueAction(w http.ResponseWriter, r *http.Request, path string, repo Repo, idText, action string) {
+ ref := issueRef{dir: f.issuesDir(path), path: path}
+ id, err := strconv.Atoi(idText)
+ if err != nil || id < 1 {
+ f.fail(w, r, http.StatusNotFound, "no such issue")
+ return
+ }
+ ref.id = id
+ head, err := resolveRef(ref.dir, branch)
+ if err != nil || head == "" {
+ f.fail(w, r, http.StatusNotFound, "no such issue")
+ return
+ }
+ if ref.issue, err = readIssue(ref.dir, head, id); err != nil {
+ f.fail(w, r, http.StatusNotFound, "no such issue")
+ return
+ }
+
+ done := false
+ switch action {
+ case "comment":
+ done = f.issueComment(w, r, repo, ref)
+ case "state":
+ done = f.issueState(w, r, repo, ref)
+ case "labels":
+ done = f.issueLabels(w, r, repo, ref)
+ default:
+ f.fail(w, r, http.StatusNotFound, "no such page")
+ return
+ }
+ if done {
+ http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", ref.path, ref.id), http.StatusSeeOther)
+ }
+}
+
+func (f *Forge) issueComment(w http.ResponseWriter, r *http.Request, repo Repo, ref issueRef) bool {
+ v := f.viewer(r)
+ if !v.CanIssues(repo) {
+ f.fail(w, r, http.StatusForbidden, "you may not comment here")
+ return false
+ }
+ if !f.commentLimit.allow(f.peer(r), v, commentsPerHour) {
+ f.fail(w, r, http.StatusTooManyRequests, "too many comments in the last hour, try again later")
+ return false
+ }
+ body := strings.TrimSpace(r.PostFormValue("body"))
+ if body == "" || len(body) > maxBody {
+ f.fail(w, r, http.StatusBadRequest, "a comment needs a body, and it may not be enormous")
+ return false
+ }
+ comment := Comment{Author: v.Name, Body: body, Created: time.Now().UTC().Truncate(time.Second)}
+ data, err := marshalJSON(comment)
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return false
+ }
+
+ var updated Issue
+ err = commitWith(ref.dir, f.actor(r), f.store.forgeAuthor(), func(base string) ([]change, string, error) {
+ // The sequence number and timestamp come from the tree as it is now.
+ issue, err := readIssue(ref.dir, base, ref.id)
+ if err != nil {
+ return nil, "", err
+ }
+ seq, err := nextComment(ref.dir, base, ref.id)
+ if err != nil {
+ return nil, "", err
+ }
+ issue.Updated = comment.Created
+ meta, err := marshalJSON(issue)
+ if err != nil {
+ return nil, "", err
+ }
+ updated = issue
+ return []change{
+ {path: fmt.Sprintf("issues/%d/%04d.json", ref.id, seq), data: data},
+ {path: issuePath(ref.id), data: meta},
+ }, fmt.Sprintf("issues: comment on %d", ref.id), nil
+ })
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return false
+ }
+ f.notifyIssue(ref.path, repo, updated, v.Name, "commented on")
+ return true
+}
+
+func (f *Forge) issueState(w http.ResponseWriter, r *http.Request, repo Repo, ref issueRef) bool {
+ v := f.viewer(r)
+ if !f.canTriage(v, repo, ref.issue) {
+ f.fail(w, r, http.StatusForbidden, "you may not close or reopen this issue")
+ return false
+ }
+ // Decided against the tree, so two people pressing at once cannot both
+ // close it.
+ var verb string
+ issue, err := f.updateIssue(ref.dir, ref.id, f.actor(r), f.store.forgeAuthor(), func(i *Issue) string {
+ if i.State == issueClosed {
+ i.State, verb = issueOpen, "reopened"
+ return fmt.Sprintf("issues: reopen %d", ref.id)
+ }
+ i.State, verb = issueClosed, "closed"
+ return fmt.Sprintf("issues: close %d", ref.id)
+ })
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return false
+ }
+ f.notifyIssue(ref.path, repo, issue, v.Name, verb)
+ return true
+}
+
+func (f *Forge) issueLabels(w http.ResponseWriter, r *http.Request, repo Repo, ref issueRef) bool {
+ if !f.canTriage(f.viewer(r), repo, ref.issue) {
+ f.fail(w, r, http.StatusForbidden, "you may not label this issue")
+ return false
+ }
+ labels := pickLabels(r, repo)
+ _, err := f.updateIssue(ref.dir, ref.id, f.actor(r), f.store.forgeAuthor(), func(i *Issue) string {
+ i.Labels = labels
+ return fmt.Sprintf("issues: label %d", ref.id)
+ })
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return false
+ }
+ return true
+}
+
+func readIssue(dir, commit string, id int) (Issue, error) {
+ var issue Issue
+ blob, err := catFile(dir, commit+":"+issuePath(id))
+ if err != nil {
+ return issue, err
+ }
+ return issue, json.Unmarshal(blob, &issue)
+}
+
+func issuePath(id int) string { return fmt.Sprintf("issues/%d.json", id) }
+
+// updateIssue reads the issue again on every attempt, or the second writer
+// would commit a record built from an overtaken read. apply returns the
+// subject, known only after the read.
+func (f *Forge) updateIssue(dir string, id int, author, committer Author, apply func(*Issue) string) (Issue, error) {
+ var final Issue
+ err := commitWith(dir, author, committer, func(base string) ([]change, string, error) {
+ issue, err := readIssue(dir, base, id)
+ if err != nil {
+ return nil, "", err
+ }
+ subject := apply(&issue)
+ issue.Updated = time.Now().UTC().Truncate(time.Second)
+ data, err := marshalJSON(issue)
+ if err != nil {
+ return nil, "", err
+ }
+ final = issue
+ return []change{{path: issuePath(id), data: data}}, subject, nil
+ })
+ return final, err
+}
+
+func nextComment(dir, base string, id int) (int, error) {
+ if base == "" {
+ return 1, nil
+ }
+ paths, err := listTree(dir, base, fmt.Sprintf("issues/%d/", id))
+ if err != nil {
+ return 0, err
+ }
+ next := 1
+ for _, p := range paths {
+ seq, err := strconv.Atoi(strings.TrimSuffix(filepath.Base(p), ".json"))
+ if err == nil && seq >= next {
+ next = seq + 1
+ }
+ }
+ return next, nil
+}
+
+// pickLabels keeps only labels the repository defines.
+func pickLabels(r *http.Request, repo Repo) []string {
+ chosen := map[string]bool{}
+ for _, label := range r.PostForm["label"] {
+ chosen[label] = true
+ }
+ out := []string{}
+ for _, label := range repo.Labels {
+ if chosen[label] {
+ out = append(out, label)
+ }
+ }
+ return out
+}
diff --git a/issues_test.go b/issues_test.go
new file mode 100644
index 0000000..1dacb5e
--- /dev/null
+++ b/issues_test.go
@@ -0,0 +1,345 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+// The loser of a race has to read the counter again rather than reuse the
+// number it already had.
+func TestIssueIdsUnderConcurrency(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featDisabled})
+ h := f.routes()
+
+ session := signIn(t, f, h, "alice")
+ token := f.csrf(withCookies("/", session))
+
+ const n = 10
+ var wg sync.WaitGroup
+ codes := make([]int, n)
+ for i := range n {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ form := url.Values{"csrf": {token}, "title": {fmt.Sprintf("issue %d", i)}, "body": {"body"}}
+ codes[i] = post(h, "/notes/issues", form, session).Code
+ }()
+ }
+ wg.Wait()
+ for i, code := range codes {
+ if code != http.StatusSeeOther {
+ t.Fatalf("issue %d: status %d", i, code)
+ }
+ }
+
+ dir := f.issuesDir("notes")
+ head, err := resolveRef(dir, branch)
+ if err != nil || head == "" {
+ t.Fatalf("no commits in the tracker: %v", err)
+ }
+ paths, err := listTree(dir, head, "issues/")
+ if err != nil {
+ t.Fatal(err)
+ }
+ seen := map[int]bool{}
+ for _, p := range paths {
+ if !strings.HasSuffix(p, ".json") {
+ continue
+ }
+ blob, err := catFile(dir, head+":"+p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var issue Issue
+ if err := json.Unmarshal(blob, &issue); err != nil {
+ t.Fatalf("%s does not parse: %v", p, err)
+ }
+ if seen[issue.ID] {
+ t.Errorf("id %d was handed out twice", issue.ID)
+ }
+ seen[issue.ID] = true
+ }
+ for id := 1; id <= n; id++ {
+ if !seen[id] {
+ t.Errorf("id %d is missing", id)
+ }
+ }
+}
+
+// The compare and swap protects the branch; this protects the record. Two
+// people acting at once must not undo each other.
+func TestIssueNoLostUpdate(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featDisabled,
+ Labels: []string{"bug"}, Grants: map[string]string{"alice": grantWrite}})
+ h := f.routes()
+ session := signIn(t, f, h, "alice")
+ token := f.csrf(withCookies("/", session))
+ post(h, "/notes/issues", url.Values{"csrf": {token}, "title": {"t"}, "body": {"b"}}, session)
+
+ var wg sync.WaitGroup
+ wg.Add(2)
+ go func() { defer wg.Done(); post(h, "/notes/issues/1/state", url.Values{"csrf": {token}}, session) }()
+ go func() {
+ defer wg.Done()
+ post(h, "/notes/issues/1/labels", url.Values{"csrf": {token}, "label": {"bug"}}, session)
+ }()
+ wg.Wait()
+
+ dir := f.issuesDir("notes")
+ head, _ := resolveRef(dir, branch)
+ issue, err := readIssue(dir, head, 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if issue.State != issueClosed {
+ t.Errorf("the close was lost: state %q", issue.State)
+ }
+ if len(issue.Labels) != 1 || issue.Labels[0] != "bug" {
+ t.Errorf("the label was lost: %v", issue.Labels)
+ }
+}
+
+// The tracker is the one repository nothing else can collect: it is written
+// with plumbing, and it refuses every push.
+//
+// Git estimates the loose object count from one shard multiplied by 256, so a
+// shard of empty files puts a small repository over gc.auto.
+func TestTrackerIsCollected(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featDisabled})
+ h := f.routes()
+ session := signIn(t, f, h, "alice")
+
+ dir := f.issuesDir("notes")
+ shard := filepath.Join(dir, "objects", "17")
+ if err := os.MkdirAll(shard, 0700); err != nil {
+ t.Fatal(err)
+ }
+ for i := range 30 {
+ if err := os.WriteFile(filepath.Join(shard, fmt.Sprintf("%038x", i)), nil, 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ form := url.Values{"csrf": {f.csrf(withCookies("/", session))}, "title": {"one"}, "body": {"b"}}
+ if code := post(h, "/notes/issues", form, session).Code; code != http.StatusSeeOther {
+ t.Fatalf("opening an issue: status %d", code)
+ }
+
+ // gc.autoDetach puts the work in the background, so this waits for it.
+ for range 40 {
+ if packs, _ := filepath.Glob(filepath.Join(dir, "objects/pack/*.pack")); len(packs) > 0 {
+ return
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+ t.Error("the tracker is still unpacked two seconds after a write that should have collected it")
+}
+
+// A miss has to be a miss, a tracker that will not open has to be a failure
+// rather than an empty page, and a query that could never be an argument is
+// refused before anything is exec'd.
+func TestIssueSearch(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featDisabled})
+ h := f.routes()
+ session := signIn(t, f, h, "alice")
+ token := f.csrf(withCookies("/", session))
+ for _, issue := range [][2]string{
+ {"zone transfer fails", "the secondary does not answer axfr"},
+ {"something else", "unrelated"},
+ } {
+ form := url.Values{"csrf": {token}, "title": {issue[0]}, "body": {issue[1]}}
+ if code := post(h, "/notes/issues", form, session).Code; code != http.StatusSeeOther {
+ t.Fatalf("opening %q: status %d", issue[0], code)
+ }
+ }
+
+ for _, c := range []struct {
+ q string
+ code int
+ hit bool
+ }{
+ {"axfr", http.StatusOK, true}, // matched in the body
+ {"zone transfer", http.StatusOK, true}, // and in the title
+ {"nothing matches this", http.StatusOK, false},
+ {"", http.StatusOK, true}, // no search at all lists everything
+ {"a\x00b", http.StatusBadRequest, false},
+ {"a\nb", http.StatusBadRequest, false},
+ } {
+ w := get(h, "/notes/issues?q="+url.QueryEscape(c.q), nil)
+ if w.Code != c.code {
+ t.Errorf("q=%q: status %d, wanted %d", c.q, w.Code, c.code)
+ }
+ if hit := strings.Contains(w.Body.String(), "zone transfer fails"); hit != c.hit {
+ t.Errorf("q=%q: found the issue %v, wanted %v", c.q, hit, c.hit)
+ }
+ }
+
+ if err := os.RemoveAll(f.issuesDir("notes")); err != nil {
+ t.Fatal(err)
+ }
+ if code := get(h, "/notes/issues?q=axfr", nil).Code; code != http.StatusInternalServerError {
+ t.Errorf("a search of a tracker that will not open: status %d", code)
+ }
+}
+
+// The server is the tracker's only writer, so a record that does not parse
+// means a bad restore or a hand edit. Dropping it silently is an issue that
+// stops existing with nothing anywhere to say why.
+func TestARecordThatDoesNotParseIsNamed(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featDisabled})
+ h := f.routes()
+ session := signIn(t, f, h, "alice")
+ form := url.Values{
+ "csrf": {f.csrf(withCookies("/", session))}, "title": {"readable"}, "body": {"b"},
+ }
+ if code := post(h, "/notes/issues", form, session).Code; code != http.StatusSeeOther {
+ t.Fatalf("opening the readable issue: status %d", code)
+ }
+
+ dir := f.issuesDir("notes")
+ author := f.store.forgeAuthor()
+ err := commitWith(dir, author, author, func(base string) ([]change, string, error) {
+ return []change{
+ {path: "issues/2.json", data: []byte("not json\n")},
+ {path: "issues/1/0001.json", data: []byte("not json either\n")},
+ }, "issues: written by hand", nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var buf bytes.Buffer
+ old := log.Writer()
+ log.SetOutput(&buf)
+ list := get(h, "/notes/issues", nil)
+ show := get(h, "/notes/issues/1", nil)
+ log.SetOutput(old)
+
+ if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), "readable") {
+ t.Errorf("the unreadable record took the readable one with it: status %d", list.Code)
+ }
+ if show.Code != http.StatusOK {
+ t.Errorf("an issue with an unreadable comment: status %d", show.Code)
+ }
+ for _, want := range []string{"issues/2.json", "issues/1/0001.json"} {
+ if !strings.Contains(buf.String(), want) {
+ t.Errorf("%s was left out without a word:\n%s", want, buf.String())
+ }
+ }
+}
+
+func TestIssueRateLimit(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featDisabled})
+ h := f.routes()
+
+ session := signIn(t, f, h, "alice")
+ token := f.csrf(withCookies("/", session))
+ form := url.Values{"csrf": {token}, "title": {"spam"}, "body": {"spam"}}
+
+ for i := range issuesPerHour {
+ if code := post(h, "/notes/issues", form, session).Code; code != http.StatusSeeOther {
+ t.Fatalf("issue %d: status %d", i, code)
+ }
+ }
+ if code := post(h, "/notes/issues", form, session).Code; code != http.StatusTooManyRequests {
+ t.Errorf("issue %d in an hour: status %d", issuesPerHour+1, code)
+ }
+}
+
+// A title is bounded in length, and has to be bounded in shape too: it becomes
+// a mail subject, and the none driver writes whole messages to the log, so a
+// title carrying a second line writes that line into the log itself.
+func TestIssueTitleCannotForgeALogLine(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addUser(t, f, "bob", "hunter2hunter2")
+ confirm(t, f, "bob", "bob@example.com", true)
+ addRepo(t, f, "notes", Repo{
+ Visibility: visPublic, Issues: featUsers, Wiki: featDisabled,
+ Grants: map[string]string{"bob": grantRead},
+ })
+ h := f.routes()
+ session := signIn(t, f, h, "alice")
+ open := func(title string) int {
+ return post(h, "/notes/issues", url.Values{
+ "csrf": {f.csrf(withCookies("/", session))}, "title": {title}, "body": {"b"},
+ }, session).Code
+ }
+
+ forged := "innocent\n2026-08-22 00:00:00 203.0.113.9 POST /admin/user/delete 303 0 1ms"
+ if code := open(forged); code != http.StatusBadRequest {
+ t.Errorf("a title with a line break in it: status %d", code)
+ }
+ for _, title := range []string{"a\rb", "a\x00b", "a\x7fb"} {
+ if code := open(title); code != http.StatusBadRequest {
+ t.Errorf("a title holding %q: status %d", title, code)
+ }
+ }
+ if code := open("a real title, with punctuation: and éncoding"); code != http.StatusSeeOther {
+ t.Fatalf("an ordinary title was refused: status %d", code)
+ }
+
+ // Nothing the refused titles carried reached the log by way of mail.
+ var buf bytes.Buffer
+ old := log.Writer()
+ log.SetOutput(&buf)
+ f.mail.drainOnce()
+ log.SetOutput(old)
+ for _, line := range strings.Split(buf.String(), "\n") {
+ if strings.Contains(line, "POST /admin/user/delete") {
+ t.Errorf("a line the user wrote is in the log: %q", line)
+ }
+ }
+}
+
+// The form is the one page template nothing else here draws, so a field
+// renamed out from under it would be a 500 that only the container test sees.
+func TestIssueFormIsDrawn(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addRepo(t, f, "notes", Repo{
+ Visibility: visPublic, Issues: featUsers, Wiki: featDisabled,
+ Labels: []string{"bug", "docs"},
+ })
+ h := f.routes()
+
+ // It is a form for writing, so a visitor who may not write does not get it.
+ if code := get(h, "/notes/issues/new", nil).Code; code != http.StatusForbidden {
+ t.Errorf("a visitor got the form: status %d", code)
+ }
+ w := get(h, "/notes/issues/new", signIn(t, f, h, "alice"))
+ if w.Code != http.StatusOK {
+ t.Fatalf("the form: status %d", w.Code)
+ }
+ body := w.Body.String()
+ for _, want := range []string{
+ `action="/notes/issues"`, `name="csrf"`, `name="title"`, `name="body"`,
+ `value="bug"`, `value="docs"`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("the form has no %s:\n%s", want, body)
+ }
+ }
+}
diff --git a/mail.go b/mail.go
new file mode 100644
index 0000000..d6495be
--- /dev/null
+++ b/mail.go
@@ -0,0 +1,280 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+)
+
+// Mail is outbound only, plain text. Unsubscribe says the message respects the
+// notify toggle, and puts List-Unsubscribe on it.
+type Message struct {
+ To string `json:"to"`
+ Subject string `json:"subject"`
+ Body string `json:"body"`
+ Unsubscribe bool `json:"unsubscribe,omitempty"`
+ Attempts int `json:"attempts,omitempty"`
+}
+
+type Mailer interface {
+ Send(m Message) error
+}
+
+const (
+ mailIdle = 60 * time.Second // how often to look when nothing woke Drain
+ mailBackoff = 5 * time.Minute // the ceiling on backing off after a failure
+ mailAttempts = 6
+)
+
+// Mail is the spool and its drainer: a directory, where adding and removing
+// are atomic.
+type Mail struct {
+ dir string
+ driver Mailer
+ wake chan struct{}
+}
+
+func newMail(cfg *Config, dir string) *Mail {
+ var driver Mailer = dropMailer{}
+ switch cfg.Mail.Driver {
+ case "log":
+ driver = logMailer{from: cfg.Mail.From}
+ case "mailgun":
+ driver = &mailgunMailer{
+ from: cfg.Mail.From,
+ base: cfg.Mail.Mailgun.BaseURL,
+ domain: cfg.Mail.Mailgun.Domain,
+ key: cfg.Mail.Mailgun.APIKey,
+ list: cfg.BaseURL + "/settings",
+ client: &http.Client{Timeout: 30 * time.Second},
+ }
+ }
+ return &Mail{dir: dir, driver: driver, wake: make(chan struct{}, 1)}
+}
+
+// Enqueue never fails the request that caused it: mail is a side effect.
+func (m *Mail) Enqueue(msg Message) {
+ if msg.To == "" {
+ return
+ }
+ data, err := marshalJSON(msg)
+ if err != nil {
+ log.Printf("mail: %v", err)
+ return
+ }
+ // A sortable timestamp plus a random suffix, so oldest first is a
+ // directory sort.
+ name := time.Now().UTC().Format("20060102T150405.000000000Z") + "-" + randomToken(4) + ".json"
+ if err := writeFile(filepath.Join(m.dir, name), data, 0600); err != nil {
+ log.Printf("mail: %v", err)
+ return
+ }
+ select {
+ case m.wake <- struct{}{}:
+ default:
+ }
+}
+
+// Drain is the only background goroutine. Delivery is at-least-once: a crash
+// between sending and deleting resends once.
+func (m *Mail) Drain(ctx context.Context) {
+ backoff := time.Second
+ for {
+ failed := m.drainOnce()
+ wait := mailIdle
+ if failed {
+ wait = backoff
+ if backoff *= 2; backoff > mailBackoff {
+ backoff = mailBackoff
+ }
+ } else {
+ backoff = time.Second
+ }
+ // After a failure a wake would only retry the head early. A nil
+ // channel never fires.
+ wake := m.wake
+ if failed {
+ wake = nil
+ }
+ select {
+ case <-ctx.Done():
+ return
+ case <-wake:
+ case <-time.After(wait):
+ }
+ }
+}
+
+// drainOnce reports whether it stopped on a failure; it stops rather than
+// skipping ahead, to keep oldest first.
+func (m *Mail) drainOnce() bool {
+ entries, err := os.ReadDir(m.dir)
+ if err != nil {
+ log.Printf("mail: %v", err)
+ return true
+ }
+ names := make([]string, 0, len(entries))
+ for _, entry := range entries {
+ if strings.HasSuffix(entry.Name(), ".json") {
+ names = append(names, entry.Name())
+ }
+ }
+ sort.Strings(names)
+
+ for _, name := range names {
+ path := filepath.Join(m.dir, name)
+ data, err := os.ReadFile(path)
+ if err != nil {
+ log.Printf("mail: %s: %v", name, err)
+ return true
+ }
+ var msg Message
+ if err := json.Unmarshal(data, &msg); err != nil {
+ log.Printf("mail: %s does not parse, dropping it: %v", name, err)
+ os.Remove(path)
+ continue
+ }
+ if err := m.driver.Send(msg); err != nil {
+ msg.Attempts++
+ if msg.Attempts >= mailAttempts {
+ // Never the body: it may carry a set-password link.
+ log.Printf("mail: giving up on %q to %s after %d attempts: %v",
+ msg.Subject, msg.To, msg.Attempts, err)
+ os.Remove(path)
+ return true
+ }
+ log.Printf("mail: %q to %s failed, attempt %d: %v", msg.Subject, msg.To, msg.Attempts, err)
+ // Unrecorded, the attempt would repeat for ever.
+ again, err := marshalJSON(msg)
+ if err == nil {
+ err = writeFile(path, again, 0600)
+ }
+ if err != nil {
+ log.Printf("mail: cannot record the attempt on %q to %s, dropping it: %v",
+ msg.Subject, msg.To, err)
+ os.Remove(path)
+ }
+ return true
+ }
+ if err := os.Remove(path); err != nil {
+ // Or it is sent again on every drain, silently.
+ log.Printf("mail: %q to %s was sent but is still spooled: %v", msg.Subject, msg.To, err)
+ return true
+ }
+ }
+ return false
+}
+
+// dropMailer is the none driver, and the default: nothing is sent, and the log
+// names what was not, never the body.
+type dropMailer struct{}
+
+func (dropMailer) Send(m Message) error {
+ log.Printf("mail: none, dropped %q to %s", m.Subject, m.To)
+ return nil
+}
+
+// logMailer writes whole messages to the log, for development: the one driver
+// that puts a set-password link there.
+type logMailer struct{ from string }
+
+func (l logMailer) Send(m Message) error {
+ log.Printf("mail: from %s to %s\nSubject: %s\n\n%s", l.from, m.To, m.Subject, m.Body)
+ return nil
+}
+
+// mailgunMailer is one form-encoded POST, the whole API surface in use.
+type mailgunMailer struct {
+ from, base, domain, key, list string
+ client *http.Client
+}
+
+func (g *mailgunMailer) Send(m Message) error {
+ form := url.Values{
+ "from": {g.from},
+ "to": {m.To},
+ "subject": {m.Subject},
+ "text": {m.Body},
+ }
+ if m.Unsubscribe {
+ form.Set("h:List-Unsubscribe", "<"+g.list+">")
+ }
+ endpoint := g.base + "/" + g.domain + "/messages"
+ req, err := http.NewRequest(http.MethodPost, endpoint, strings.NewReader(form.Encode()))
+ if err != nil {
+ return err
+ }
+ req.SetBasicAuth("api", g.key)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ resp, err := g.client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode/100 != 2 {
+ return fmt.Errorf("mailgun: %s", resp.Status)
+ }
+ return nil
+}
+
+// The notification rules. Mail is sent for these events and no others.
+
+// mailAccount tells the account holder about their own account: it ignores
+// the notify toggle, and still needs a proved address.
+func (f *Forge) mailAccount(u User, subject, body string) {
+ if !u.EmailConfirmed {
+ return
+ }
+ f.mail.Enqueue(Message{To: u.Email, Subject: f.subject(subject), Body: body})
+}
+
+// mailLink carries a capability to an address confirmed or not: following it
+// is what confirms it.
+func (f *Forge) mailLink(to, subject, body string) {
+ f.mail.Enqueue(Message{To: to, Subject: f.subject(subject), Body: body})
+}
+
+// mailRepo is a message to everyone holding any grant on a repository,
+// respecting the toggle and never to whoever caused the event.
+func (f *Forge) mailRepo(repo Repo, except, subject, body string) {
+ for name := range repo.Grants {
+ if name == except {
+ continue
+ }
+ if u, ok := f.store.User(name); ok && u.Notify && u.EmailConfirmed {
+ f.mail.Enqueue(Message{To: u.Email, Subject: f.subject(subject), Body: body, Unsubscribe: true})
+ }
+ }
+}
+
+// mailAdmins is a message to every global admin, respecting the toggle.
+func (f *Forge) mailAdmins(except, subject, body string) {
+ for name, u := range f.store.Users() {
+ if name == except || !u.Admin || !u.Notify || !u.EmailConfirmed {
+ continue
+ }
+ f.mail.Enqueue(Message{To: u.Email, Subject: f.subject(subject), Body: body, Unsubscribe: true})
+ }
+}
+
+func (f *Forge) subject(s string) string { return "[" + f.siteName() + "] " + s }
+
+func (f *Forge) url(path string) string { return f.cfg.BaseURL + path }
+
+// notifyIssue tells everyone with a grant that an issue moved.
+func (f *Forge) notifyIssue(path string, repo Repo, issue Issue, actor, verb string) {
+ subject := fmt.Sprintf("%s#%d %s: %s", path, issue.ID, verb, issue.Title)
+ body := fmt.Sprintf("%s %s %s#%d.\n\n%s\n\n%s\n",
+ actor, verb, path, issue.ID, issue.Title,
+ f.url(fmt.Sprintf("/%s/issues/%d", path, issue.ID)))
+ f.mailRepo(repo, actor, subject, body)
+}
diff --git a/mail_test.go b/mail_test.go
new file mode 100644
index 0000000..c9170f6
--- /dev/null
+++ b/mail_test.go
@@ -0,0 +1,331 @@
+package main
+
+import (
+ "bytes"
+ "io"
+ "log"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "strings"
+ "sync"
+ "testing"
+)
+
+// recorder is a Mailer that remembers instead of sending.
+type recorder struct {
+ mu sync.Mutex
+ sent []Message
+ fail bool
+}
+
+func (m *recorder) Send(msg Message) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.fail {
+ return io.ErrUnexpectedEOF
+ }
+ m.sent = append(m.sent, msg)
+ return nil
+}
+
+func (m *recorder) messages() []Message {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return append([]Message(nil), m.sent...)
+}
+
+func TestSpoolDrainsOldestFirst(t *testing.T) {
+ dir := t.TempDir()
+ driver := &recorder{}
+ spool := &Mail{dir: dir, driver: driver, wake: make(chan struct{}, 1)}
+
+ for _, to := range []string{"one@example.com", "two@example.com", "three@example.com"} {
+ spool.Enqueue(Message{To: to, Subject: "hello", Body: "body"})
+ }
+ if entries, _ := os.ReadDir(dir); len(entries) != 3 {
+ t.Fatalf("expected three spooled messages, got %d", len(entries))
+ }
+ if spool.drainOnce() {
+ t.Fatal("draining reported a failure")
+ }
+ sent := driver.messages()
+ if len(sent) != 3 || sent[0].To != "one@example.com" || sent[2].To != "three@example.com" {
+ t.Errorf("sent out of order: %v", sent)
+ }
+ if entries, _ := os.ReadDir(dir); len(entries) != 0 {
+ t.Errorf("the spool still holds %d messages", len(entries))
+ }
+ // A message with no recipient is not a message.
+ spool.Enqueue(Message{Subject: "nobody"})
+ if entries, _ := os.ReadDir(dir); len(entries) != 0 {
+ t.Error("a message with no recipient was spooled")
+ }
+}
+
+// The line that says a message was dropped has to name it without carrying it:
+// the body of one of these is a capability.
+func TestSpoolGivesUpLoudly(t *testing.T) {
+ dir := t.TempDir()
+ spool := &Mail{dir: dir, driver: &recorder{fail: true}, wake: make(chan struct{}, 1)}
+ spool.Enqueue(Message{To: "one@example.com", Subject: "set a new password",
+ Body: "https://forge.example/set-password?name=bob&t=the-capability\n"})
+
+ var buf bytes.Buffer
+ old := log.Writer()
+ log.SetOutput(&buf)
+ for i := 0; i < mailAttempts; i++ {
+ if !spool.drainOnce() {
+ log.SetOutput(old)
+ t.Fatalf("attempt %d: a failing driver reported success", i)
+ }
+ }
+ log.SetOutput(old)
+
+ if entries, _ := os.ReadDir(dir); len(entries) != 0 {
+ t.Errorf("a message that will never send is still queued after %d attempts", mailAttempts)
+ }
+ for _, want := range []string{"giving up", "set a new password", "one@example.com"} {
+ if !strings.Contains(buf.String(), want) {
+ t.Errorf("the log does not say %q:\n%s", want, buf.String())
+ }
+ }
+ if strings.Contains(buf.String(), "the-capability") {
+ t.Errorf("a one time link reached the log:\n%s", buf.String())
+ }
+}
+
+// The none driver is off: the log names what was not sent and never carries
+// it, since the body of one of these is a capability.
+func TestNoneDriverNamesWhatItDrops(t *testing.T) {
+ if _, ok := newMail(&Config{Mail: MailConfig{Driver: "log"}}, t.TempDir()).driver.(logMailer); !ok {
+ t.Error("the log driver is not the one that logs")
+ }
+ if _, ok := newMail(defaultConfig(), t.TempDir()).driver.(dropMailer); !ok {
+ t.Error("the default driver sends somewhere")
+ }
+
+ var buf bytes.Buffer
+ old := log.Writer()
+ log.SetOutput(&buf)
+ err := dropMailer{}.Send(Message{To: "bob@example.com", Subject: "set a new password",
+ Body: "https://forge.example/set-password?name=bob&t=the-capability\n"})
+ log.SetOutput(old)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{"set a new password", "bob@example.com"} {
+ if !strings.Contains(buf.String(), want) {
+ t.Errorf("the log does not say %q:\n%s", want, buf.String())
+ }
+ }
+ if strings.Contains(buf.String(), "the-capability") {
+ t.Errorf("a one time link reached the log:\n%s", buf.String())
+ }
+}
+
+func TestMailgunDriver(t *testing.T) {
+ var got url.Values
+ var user, key string
+ var ok bool
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ user, key, ok = r.BasicAuth()
+ r.ParseForm()
+ got = r.PostForm
+ if r.URL.Path != "/v3/mg.example.com/messages" {
+ t.Errorf("posted to %s", r.URL.Path)
+ }
+ }))
+ defer srv.Close()
+
+ driver := &mailgunMailer{
+ from: "git-forge <forge@example.com>", base: srv.URL + "/v3", domain: "mg.example.com",
+ key: "secret", list: "https://git.example.com/settings", client: srv.Client(),
+ }
+ err := driver.Send(Message{To: "bob@example.com", Subject: "hello", Body: "body", Unsubscribe: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !ok || user != "api" || key != "secret" {
+ t.Errorf("basic auth was %q %q %v", user, key, ok)
+ }
+ for field, want := range map[string]string{
+ "from": "git-forge <forge@example.com>", "to": "bob@example.com",
+ "subject": "hello", "text": "body",
+ "h:List-Unsubscribe": "<https://git.example.com/settings>",
+ } {
+ if got.Get(field) != want {
+ t.Errorf("%s was %q, wanted %q", field, got.Get(field), want)
+ }
+ }
+
+ // A message that ignores the toggle carries no unsubscribe header.
+ if err := driver.Send(Message{To: "bob@example.com", Subject: "hello", Body: "body"}); err != nil {
+ t.Fatal(err)
+ }
+ if got.Has("h:List-Unsubscribe") {
+ t.Error("a notice about the account itself carried List-Unsubscribe")
+ }
+}
+
+func TestIssueNotifiesGranteesOnly(t *testing.T) {
+ f := testForge(t)
+ driver := &recorder{}
+ f.mail.driver = driver
+ addUser(t, f, "alice", "hunter2hunter2")
+ addUser(t, f, "bob", "hunter2hunter2")
+ addUser(t, f, "carol", "hunter2hunter2")
+ confirm(t, f, "alice", "alice@example.com", true)
+ confirm(t, f, "bob", "bob@example.com", true)
+ confirm(t, f, "carol", "carol@example.com", false) // notify off
+ addRepo(t, f, "notes", Repo{
+ Visibility: visPublic, Issues: featUsers, Wiki: featDisabled,
+ Grants: map[string]string{"alice": grantWrite, "bob": grantRead, "carol": grantRead},
+ })
+
+ h := f.routes()
+ session := signIn(t, f, h, "alice")
+ form := url.Values{"csrf": {f.csrf(withCookies("/", session))}, "title": {"A bug"}, "body": {"Details."}}
+ if code := post(h, "/notes/issues", form, session).Code; code != http.StatusSeeOther {
+ t.Fatalf("opening an issue: status %d", code)
+ }
+ f.mail.drainOnce()
+
+ sent := driver.messages()
+ if len(sent) != 1 {
+ t.Fatalf("expected one message, got %v", sent)
+ }
+ if sent[0].To != "bob@example.com" {
+ t.Errorf("mailed %s: alice caused it and carol has notifications off", sent[0].To)
+ }
+ if !sent[0].Unsubscribe {
+ t.Error("an activity message did not carry the unsubscribe flag")
+ }
+ if !strings.Contains(sent[0].Body, "/notes/issues/1") {
+ t.Errorf("the message does not link to the issue: %q", sent[0].Body)
+ }
+}
+
+// An address receives nothing until a link sent to it has been followed, and
+// changing it again unproves it and spends the link sent to the old one.
+func TestAddressIsProvedByALink(t *testing.T) {
+ f := testForge(t)
+ driver := &recorder{}
+ f.mail.driver = driver
+ addUser(t, f, "bob", "hunter2hunter2")
+ h := f.routes()
+ session := signIn(t, f, h, "bob")
+ token := f.csrf(withCookies("/", session))
+
+ set := func(email string) {
+ t.Helper()
+ w := post(h, "/settings/email", url.Values{
+ "csrf": {token}, "email": {email}, "notify": {"yes"}}, session)
+ if w.Code != http.StatusOK {
+ t.Fatalf("setting %q: status %d", email, w.Code)
+ }
+ f.mail.drainOnce()
+ }
+
+ set("bob@example.com")
+ if u, _ := f.store.User("bob"); u.EmailConfirmed {
+ t.Fatal("an address was confirmed without the link being followed")
+ }
+ sent := driver.messages()
+ if len(sent) != 1 || sent[0].To != "bob@example.com" {
+ t.Fatalf("expected one message to the address, got %v", sent)
+ }
+ link := between(sent[0].Body, f.cfg.BaseURL, "\n")
+ if !strings.HasPrefix(link, "/confirm-email?") {
+ t.Fatalf("no confirmation link in %q", sent[0].Body)
+ }
+ if code := get(h, link, session).Code; code != http.StatusOK {
+ t.Fatalf("following the link: status %d", code)
+ }
+ if u, _ := f.store.User("bob"); !u.EmailConfirmed {
+ t.Error("the address is still unconfirmed after the link was followed")
+ }
+
+ set("other@example.com")
+ if u, _ := f.store.User("bob"); u.EmailConfirmed {
+ t.Error("a changed address kept the old one's confirmation")
+ }
+ if code := get(h, link, session).Code; code != http.StatusNotFound {
+ t.Errorf("the link to the old address still works: status %d", code)
+ }
+}
+
+// The one unauthenticated path that sends a capability. The answer is the same
+// either way, or the form is a list of which names are taken.
+func TestResetTellsNobodyWhichAccountsExist(t *testing.T) {
+ f := testForge(t)
+ driver := &recorder{}
+ f.mail.driver = driver
+ addUser(t, f, "bob", "hunter2hunter2")
+ confirm(t, f, "bob", "bob@example.com", true)
+ h := f.routes()
+
+ ask := func(name string) string {
+ t.Helper()
+ w := post(h, "/reset", url.Values{
+ "csrf": {f.csrf(withCookies("/", nil))}, "name": {name}}, nil)
+ if w.Code != http.StatusOK {
+ t.Fatalf("asking for %q: status %d", name, w.Code)
+ }
+ f.mail.drainOnce()
+ return w.Body.String()
+ }
+
+ const same = "if that account exists"
+ if page := ask("bob"); !strings.Contains(page, same) {
+ t.Error("the answer for an account that exists does not carry the notice")
+ }
+ sent := driver.messages()
+ if len(sent) != 1 || sent[0].To != "bob@example.com" {
+ t.Fatalf("no link went to the account: %v", sent)
+ }
+ if page := ask("nobody"); !strings.Contains(page, same) {
+ t.Error("the answer for an account that does not exist reads differently")
+ }
+ if n := len(driver.messages()); n != 1 {
+ t.Errorf("a reset for an account that does not exist sent mail: %d", n)
+ }
+
+ // And the link it did send sets a password once.
+ link := between(sent[0].Body, f.cfg.BaseURL, "\n")
+ page := get(h, link, nil)
+ if page.Code != http.StatusOK {
+ t.Fatalf("following the link: status %d", page.Code)
+ }
+ body := page.Body.String()
+ form := url.Values{
+ "csrf": {f.csrf(withCookies("/", nil))},
+ "name": {between(body, `name="name" value="`, `"`)},
+ "expires": {between(body, `name="expires" value="`, `"`)},
+ "t": {between(body, `name="t" value="`, `"`)},
+ "new": {"opensesame1"}, "again": {"opensesame1"},
+ }
+ if w := post(h, "/set-password", form, nil); w.Code != http.StatusSeeOther {
+ t.Fatalf("setting the password: status %d", w.Code)
+ }
+ if u, _ := f.store.User("bob"); !passwordOK(u.Password, "opensesame1") {
+ t.Error("the new password was not stored")
+ }
+ if w := post(h, "/set-password", form, nil); w.Code != http.StatusNotFound {
+ t.Errorf("the link worked twice: status %d", w.Code)
+ }
+}
+
+func confirm(t *testing.T, f *Forge, name, email string, notify bool) {
+ t.Helper()
+ err := f.store.UpdateUsers("users: address", f.store.forgeAuthor(), func(m map[string]User) error {
+ u := m[name]
+ u.Email, u.EmailConfirmed, u.Notify = email, true, notify
+ m[name] = u
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..931de06
--- /dev/null
+++ b/main.go
@@ -0,0 +1,733 @@
+// git-forge is a small self-hosted git forge: one binary, one data directory,
+// one listener.
+package main
+
+import (
+ "bytes"
+ "context"
+ "embed"
+ "errors"
+ "flag"
+ "fmt"
+ "html/template"
+ "io"
+ "io/fs"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "os/signal"
+ "path"
+ "path/filepath"
+ "slices"
+ "strings"
+ "sync/atomic"
+ "syscall"
+ "time"
+)
+
+const defaultDatadir = "/var/lib/git-forge"
+
+func main() {
+ // cgit's filters: the blob on stdin, and neither the datadir nor a listener.
+ if len(os.Args) > 1 {
+ switch os.Args[1] {
+ case "md":
+ os.Exit(runMarkdown())
+ case "highlight":
+ os.Exit(runHighlight(os.Args[2:]))
+ }
+ }
+
+ log.SetFlags(log.LstdFlags | log.LUTC)
+ log.SetOutput(os.Stdout)
+
+ // run rather than log.Fatalf, which exits without running any defer.
+ if err := run(); err != nil {
+ log.Fatalf("%v", err)
+ }
+}
+
+func run() error {
+ datadir := flag.String("datadir", "", "data directory (default $GIT_FORGE_DATADIR, else "+defaultDatadir+")")
+ flag.Parse()
+ if flag.NArg() > 0 {
+ return fmt.Errorf("unexpected argument %q", flag.Arg(0))
+ }
+
+ dir, err := resolveDatadir(*datadir)
+ if err != nil {
+ return fmt.Errorf("datadir: %v", err)
+ }
+ unlock, err := lockDatadir(dir)
+ if err != nil {
+ return err
+ }
+ defer unlock()
+
+ store, err := openStore(dir)
+ if err != nil {
+ return fmt.Errorf("datadir %s: %v", dir, err)
+ }
+ cfg := store.cfg
+ log.Printf("datadir %s, site %q, base url %s", store.dir, cfg.SiteName, cfg.BaseURL)
+ if cfg.ClientIPHeader != "" {
+ log.Printf("client address from the %s header, trusted", cfg.ClientIPHeader)
+ }
+ for _, p := range cfg.OIDC {
+ // Whether the secret arrived, never the secret.
+ held := "no client secret"
+ if p.ClientSecret != "" {
+ held = "client secret held"
+ }
+ log.Printf("oidc %s: %s, callback %s/oidc/%s/callback, %s",
+ p.Key, p.Issuer, cfg.BaseURL, p.Key, held)
+ }
+
+ forge, err := newForge(store)
+ if err != nil {
+ return err
+ }
+ defer forge.cgit.close()
+ if len(store.Users()) == 0 {
+ token := randomToken(16)
+ forge.setupToken.Store(&token)
+ log.Printf("no accounts yet: open %s/setup and give it this token: %s", cfg.BaseURL, token)
+ }
+ drain, stopDraining := context.WithCancel(context.Background())
+ defer stopDraining()
+ go forge.mail.Drain(drain)
+
+ return serve(cfg, forge.routes())
+}
+
+// Forge is the whole server: the config, the datadir, and the pages.
+type Forge struct {
+ cfg *Config
+ store *Store
+ cgit *Cgit
+ mail *Mail
+ oidc *OIDC
+ pages map[string]*template.Template
+ nav *template.Template // the bar, on forge pages and injected into cgit's
+ site atomic.Pointer[Site] // the name, subtitle, and logo an admin edits
+
+ issueLimit *limiter
+ commentLimit *limiter
+ resetLimit *limiter
+ totpLimit *limiter
+ loginLimit *limiter
+ tokenLimit *limiter
+
+ // setupToken lives in memory only: a restart prints a new one and a
+ // stolen datadir carries none.
+ setupToken atomic.Pointer[string]
+}
+
+//go:embed templates static
+var assets embed.FS
+
+// layout is parsed into every page. Everything else in templates/ is a page.
+var layout = []string{"templates/base.html", "templates/nav.html"}
+
+func newForge(store *Store) (*Forge, error) {
+ cfg := store.cfg
+ // One template set per page, so two pages can both define main.
+ files, err := fs.Glob(assets, "templates/*.html")
+ if err != nil {
+ return nil, err
+ }
+ pages := map[string]*template.Template{}
+ for _, file := range files {
+ if slices.Contains(layout, file) {
+ continue
+ }
+ t, err := template.ParseFS(assets, append(slices.Clone(layout), file)...)
+ if err != nil {
+ return nil, err
+ }
+ pages[path.Base(file)] = t
+ }
+ nav, err := template.ParseFS(assets, "templates/nav.html")
+ if err != nil {
+ return nil, err
+ }
+ forgeCSS, err := assets.ReadFile("static/forge.css")
+ if err != nil {
+ return nil, err
+ }
+ browser, err := newCgit(store, forgeCSS)
+ if err != nil {
+ return nil, err
+ }
+ site, err := loadSite(store.configDir, cfg.SiteName, cfg.SiteDesc, cfg.Logo, cfg.Icon)
+ if err != nil {
+ return nil, err
+ }
+ f := &Forge{
+ cfg: cfg, store: store, cgit: browser, mail: newMail(cfg, store.mailqDir), oidc: newOIDC(),
+ pages: pages, nav: nav,
+ issueLimit: newLimiter(), commentLimit: newLimiter(), resetLimit: newLimiter(),
+ totpLimit: newLimiter(), loginLimit: newLimiter(), tokenLimit: newLimiter(),
+ }
+ f.site.Store(site)
+ return f, nil
+}
+
+// Site is what the header draws, swapped whole so a reader never sees half a
+// change.
+type Site struct {
+ Name string
+ Desc string
+ Logo Image // the header image
+ Icon Image // the favicon
+}
+
+// Image is a configured image: the path in the config, its bytes, and the
+// sniffed type. An empty Name is cgit's own.
+type Image struct {
+ Name string
+ Body []byte
+ Kind string
+}
+
+// siteName is the name in effect; the config holds only what the process
+// started with.
+func (f *Forge) siteName() string { return f.site.Load().Name }
+
+// Served back to browsers, so decided from the bytes. No SVG: it can carry
+// script.
+var imageKinds = map[string]bool{
+ "image/png": true, "image/jpeg": true, "image/gif": true,
+ "image/webp": true, "image/x-icon": true,
+}
+
+const maxImage = 512 << 10
+
+func loadSite(dir, name, desc, logo, icon string) (*Site, error) {
+ site := &Site{Name: name, Desc: desc}
+ var err error
+ if site.Logo, err = loadImage(dir, logo); err != nil {
+ return nil, err
+ }
+ if site.Icon, err = loadImage(dir, icon); err != nil {
+ return nil, err
+ }
+ return site, nil
+}
+
+// A relative path is under the config directory, where an upload lands.
+func loadImage(dir, name string) (Image, error) {
+ if name == "" {
+ return Image{}, nil
+ }
+ path := name
+ if !filepath.IsAbs(path) {
+ path = filepath.Join(dir, path)
+ }
+ body, err := os.ReadFile(path)
+ if err != nil {
+ return Image{}, err
+ }
+ if err := checkImage(body); err != nil {
+ return Image{}, err
+ }
+ return Image{Name: name, Body: body, Kind: http.DetectContentType(body)}, nil
+}
+
+func checkImage(body []byte) error {
+ if len(body) > maxImage {
+ return userError(fmt.Sprintf("that image is larger than %d KiB", maxImage>>10))
+ }
+ if kind := http.DetectContentType(body); !imageKinds[kind] {
+ return userError("that is a " + kind + ", and it has to be a png, jpeg, gif, webp, or icon")
+ }
+ return nil
+}
+
+func (f *Forge) routes() http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /healthz", healthz)
+ mux.HandleFunc("GET /cgit.css", f.handleCgitCSS)
+ mux.HandleFunc("GET /cgit.png", f.handleCgitLogo)
+ mux.HandleFunc("GET /favicon.ico", f.handleFavicon)
+ mux.HandleFunc("GET /logo", f.handleLogo)
+ mux.HandleFunc("GET /avatar/{name}", f.handleAvatar)
+ mux.HandleFunc("GET /login", f.handleLogin)
+ mux.HandleFunc("POST /login", f.handleLoginPost)
+ mux.HandleFunc("POST /login/totp", f.handleLoginTOTP)
+ mux.HandleFunc("POST /logout", f.handleLogout)
+ mux.HandleFunc("GET /settings", f.handleSettings)
+ mux.HandleFunc("POST /settings/password", f.handlePassword)
+ mux.HandleFunc("POST /settings/sessions", f.handleSignOut)
+ mux.HandleFunc("POST /settings/email", f.handleEmail)
+ mux.HandleFunc("POST /settings/tokens", f.handleTokenMint)
+ mux.HandleFunc("POST /settings/tokens/revoke", f.handleTokenRevoke)
+ mux.HandleFunc("GET /settings/totp", f.handleTOTP)
+ mux.HandleFunc("POST /settings/totp", f.handleTOTPEnroll)
+ mux.HandleFunc("POST /settings/totp/remove", f.handleTOTPRemove)
+ mux.HandleFunc("POST /settings/totp/codes", f.handleTOTPCodes)
+ mux.HandleFunc("GET /confirm-email", f.handleConfirmEmail)
+ mux.HandleFunc("GET /oidc/{key}", f.handleOIDCStart)
+ mux.HandleFunc("GET /oidc/{key}/callback", f.handleOIDCCallback)
+ mux.HandleFunc("GET /reset", f.handleReset)
+ mux.HandleFunc("POST /reset", f.handleResetPost)
+ mux.HandleFunc("GET /set-password", f.handleSetPassword)
+ mux.HandleFunc("POST /set-password", f.handleSetPasswordPost)
+ mux.HandleFunc("GET /setup", f.handleSetup)
+ mux.HandleFunc("POST /setup", f.handleSetupPost)
+ mux.HandleFunc("GET /admin", f.handleAdmin)
+ mux.HandleFunc("POST /admin/site", f.handleSiteSettings)
+ mux.HandleFunc("POST /admin/repo/create", f.handleRepoCreate)
+ mux.HandleFunc("POST /admin/repo/adopt", f.handleRepoAdopt)
+ mux.HandleFunc("POST /admin/user/create", f.handleUserCreate)
+ mux.HandleFunc("POST /admin/user/admin", f.handleUserAdmin)
+ mux.HandleFunc("POST /admin/user/totp", f.handleUserTOTP)
+ mux.HandleFunc("POST /admin/user/delete", f.handleUserDelete)
+ mux.HandleFunc("GET /{$}", f.handleHome)
+ mux.HandleFunc("/{path...}", f.handleRepo)
+ return f.logRequests(secureHeaders(mux))
+}
+
+// secureHeaders is the whole header policy, on cgit's pages too. No script
+// runs anywhere; images are same origin, so a markdown src cannot tell a third
+// party who reads a private page; nothing is cached, because cgit dates its
+// own pages ahead. Asset routes override the cache header.
+func secureHeaders(h http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ head := w.Header()
+ head.Set("Content-Security-Policy",
+ "script-src 'none'; object-src 'none'; base-uri 'none'; img-src 'self'; frame-ancestors 'none'")
+ head.Set("X-Content-Type-Options", "nosniff")
+ head.Set("Cache-Control", "no-store")
+ h.ServeHTTP(w, r)
+ })
+}
+
+func (f *Forge) handleHome(w http.ResponseWriter, r *http.Request) {
+ f.render(w, "home.html", http.StatusOK, f.withIndex(r, f.page(r)))
+}
+
+func (f *Forge) withIndex(r *http.Request, d pageData) pageData {
+ v := f.viewer(r)
+ visible := []RepoEntry{}
+ for _, entry := range f.store.Repos() {
+ if v.CanRead(entry.Repo) {
+ visible = append(visible, entry)
+ }
+ }
+ d.Data = indexPage{Repos: visible}
+ return d
+}
+
+type indexPage struct{ Repos []RepoEntry }
+
+// handleRepo owns everything under a repository path: the longest prefix the
+// tree holds is the repository, and the rest is the action.
+func (f *Forge) handleRepo(w http.ResponseWriter, r *http.Request) {
+ if !servablePath(r.URL.Path) {
+ f.fail(w, r, http.StatusNotFound, "no such page")
+ return
+ }
+ // .git paths authorise differently and answer in git's protocol.
+ if path, kind, rest, ok := splitGit(r.URL.Path); ok {
+ f.handleGitHTTP(w, r, path, kind, rest)
+ return
+ }
+ path, kind, rest, ok := f.store.splitRepo(r.URL.Path)
+ if !ok {
+ f.fail(w, r, http.StatusNotFound, "no such page")
+ return
+ }
+ repo, _ := f.store.Repo(path)
+ // A repository the viewer may not read does not exist.
+ if !f.viewer(r).CanRead(repo) {
+ f.fail(w, r, http.StatusNotFound, "no such page")
+ return
+ }
+
+ // {repo}.issues and {repo}.wiki are the siblings' history, cgit only.
+ if kind != "" {
+ if (kind == "issues" && repo.Issues == featDisabled) || (kind == "wiki" && repo.Wiki == featDisabled) {
+ f.fail(w, r, http.StatusNotFound, "no such page")
+ return
+ }
+ f.handleCgit(w, r, path, kind, rest, repo)
+ return
+ }
+
+ // The forge's by name, cgit's by default; cgit has no route by these names.
+ head, _, _ := strings.Cut(rest, "/")
+ switch head {
+ case "settings":
+ f.repoSettingsRoutes(w, r, path, repo, rest)
+ case "issues":
+ f.handleIssues(w, r, path, repo, rest)
+ case "wiki":
+ f.handleWiki(w, r, path, repo, rest)
+ default:
+ f.handleCgit(w, r, path, "", rest, repo)
+ }
+}
+
+// servablePath is what both CGIs will take: the path goes into an environment
+// variable, which cannot hold NUL, and git-http-backend answers a . or .. or
+// empty component with a 500. Percent encoded, these survive the mux's
+// cleaning.
+func servablePath(p string) bool {
+ if hasControlByte(p) {
+ return false
+ }
+ parts := strings.Split(strings.TrimPrefix(p, "/"), "/")
+ for i, part := range parts {
+ // A trailing slash is kept: cgit's own links carry one.
+ if part == "." || part == ".." || (part == "" && i < len(parts)-1) {
+ return false
+ }
+ }
+ return true
+}
+
+func hasControlByte(p string) bool {
+ return strings.ContainsFunc(p, func(c rune) bool { return c < ' ' || c == 0x7f })
+}
+
+// isGet treats HEAD as GET: net/http drops the body on the way out.
+func isGet(r *http.Request) bool {
+ return r.Method == http.MethodGet || r.Method == http.MethodHead
+}
+
+// childSlot takes one of a bounded number of forked children, or reports
+// false once the client has hung up.
+func childSlot(budget chan struct{}, r *http.Request) (release func(), ok bool) {
+ select {
+ case budget <- struct{}{}:
+ return func() { <-budget }, true
+ case <-r.Context().Done():
+ return nil, false
+ }
+}
+
+func (f *Forge) repoSettingsRoutes(w http.ResponseWriter, r *http.Request, path string, repo Repo, rest string) {
+ switch {
+ case rest == "settings" && isGet(r):
+ f.repoSettings(w, r, path, repo)
+ case rest == "settings" && r.Method == http.MethodPost:
+ if f.checkPost(w, r) {
+ f.repoSettingsSave(w, r, path, repo)
+ }
+ case rest == "settings/grant" && r.Method == http.MethodPost:
+ if f.checkPost(w, r) {
+ f.repoGrant(w, r, path, repo)
+ }
+ case rest == "settings/delete" && r.Method == http.MethodPost:
+ if f.checkPost(w, r) {
+ f.repoDelete(w, r, path, repo)
+ }
+ default:
+ f.fail(w, r, http.StatusNotFound, "no such page")
+ }
+}
+
+// viewer is whoever is asking.
+func (f *Forge) viewer(r *http.Request) Viewer {
+ if name, u, ok := f.user(r); ok {
+ return Viewer{Name: name, Admin: u.adminInEffect()}
+ }
+ return Viewer{}
+}
+
+// actor is the identity a change made by this request is recorded under.
+func (f *Forge) actor(r *http.Request) Author {
+ name, u, ok := f.user(r)
+ if !ok {
+ return f.store.forgeAuthor()
+ }
+ return f.store.author(name, u)
+}
+
+// One stylesheet for both kinds of page.
+func (f *Forge) handleCgitCSS(w http.ResponseWriter, r *http.Request) {
+ if len(f.cgit.css) == 0 {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "text/css; charset=utf-8")
+ w.Header().Set("Cache-Control", "max-age=300")
+ w.Write(f.cgit.css)
+}
+
+func (f *Forge) handleCgitLogo(w http.ResponseWriter, r *http.Request) {
+ serveAsset(w, r, f.cgit.logo, "image/png")
+}
+
+// Browsers ask for this unprompted, so it falls back to cgit's rather than 404.
+func (f *Forge) handleFavicon(w http.ResponseWriter, r *http.Request) {
+ if icon := f.site.Load().Icon; icon.Name != "" {
+ serveAsset(w, r, icon.Body, icon.Kind)
+ return
+ }
+ serveAsset(w, r, f.cgit.favicon, "image/vnd.microsoft.icon")
+}
+
+func (f *Forge) handleLogo(w http.ResponseWriter, r *http.Request) {
+ logo := f.site.Load().Logo
+ serveAsset(w, r, logo.Body, logo.Kind)
+}
+
+func serveAsset(w http.ResponseWriter, r *http.Request, body []byte, kind string) {
+ if len(body) == 0 {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", kind)
+ w.Header().Set("Cache-Control", "max-age=3600")
+ w.Write(body)
+}
+
+// userError is an error whose text is meant for the person who caused it.
+type userError string
+
+func (e userError) Error() string { return string(e) }
+
+// problem shows a userError and hides anything else behind the log.
+func (f *Forge) problem(w http.ResponseWriter, r *http.Request, status int, err error) {
+ var mine userError
+ if errors.As(err, &mine) {
+ f.fail(w, r, status, mine.Error())
+ return
+ }
+ log.Printf("%s %q: %v", r.Method, r.URL.Path, err)
+ f.fail(w, r, http.StatusInternalServerError, "something went wrong here, and the log has the detail")
+}
+
+// pageData is what every template is given. Data is the page's own.
+type pageData struct {
+ Site string
+ Base string // the base url, for the clone lines on the page
+ Now string // for the footer, in the format cgit's own footer uses
+ Desc string
+ Logo string
+ // MailOff stops pages offering what only mail can deliver.
+ MailOff bool
+ User string // empty when signed out
+ // MustEnrol is the flag waiting on an authenticator; the admin link is
+ // where enrolling starts.
+ Admin bool
+ MustEnrol bool
+ CSRF string
+ Error string
+ Notice string
+ Repo *repoBar // set on a repository page, rendered or injected
+ Data any
+}
+
+// repoBar is the bar every repository page carries.
+type repoBar struct {
+ Path string
+ Desc string
+ Issues bool
+ Wiki bool
+ Settings bool
+ Active string // the forge's own tab this page is, empty on cgit's pages
+}
+
+func (f *Forge) repoBar(r *http.Request, path, kind, active string, repo Repo) *repoBar {
+ desc := repo.Desc
+ switch kind {
+ case "issues":
+ desc = "the issue tracker of " + path
+ case "wiki":
+ desc = "the wiki of " + path
+ }
+ v := f.viewer(r)
+ return &repoBar{
+ Path: path,
+ Desc: desc,
+ Active: active,
+ Issues: repo.Issues != featDisabled,
+ Wiki: repo.Wiki != featDisabled,
+ Settings: v.CanAdmin(repo),
+ }
+}
+
+type errorPage struct {
+ Status int
+ Text string
+}
+
+func (f *Forge) page(r *http.Request) pageData {
+ d := f.chrome(f.csrf(r))
+ if name, u, ok := f.user(r); ok {
+ d.signedInAs(name, u)
+ }
+ return d
+}
+
+// pageFor builds the page for a reissued cookie, which the request does not
+// carry yet.
+func (f *Forge) pageFor(name string, u User, session string) pageData {
+ d := f.chrome(f.mac("csrf", session))
+ d.signedInAs(name, u)
+ return d
+}
+
+// chrome is everything on a page that does not depend on who is asking.
+func (f *Forge) chrome(csrf string) pageData {
+ site := f.site.Load()
+ logo := "/cgit.png"
+ if site.Logo.Name != "" {
+ logo = "/logo"
+ }
+ return pageData{
+ Site: site.Name, Base: f.cfg.BaseURL, Desc: site.Desc, Logo: logo,
+ Now: time.Now().Format("2006-01-02 15:04:05 -0700"),
+ MailOff: f.cfg.Mail.Driver == "none", CSRF: csrf,
+ }
+}
+
+func (d *pageData) signedInAs(name string, u User) {
+ d.User = name
+ d.Admin, d.MustEnrol = u.adminInEffect(), u.Admin && u.TOTP == nil
+}
+
+// requireUser answers the request itself when there is no account.
+func (f *Forge) requireUser(w http.ResponseWriter, r *http.Request) (string, User, bool) {
+ name, u, ok := f.user(r)
+ if !ok {
+ http.Redirect(w, r, "/login", http.StatusSeeOther)
+ }
+ return name, u, ok
+}
+
+// render buffers, so a template failing halfway leaves no half page behind a
+// 200.
+func (f *Forge) render(w http.ResponseWriter, page string, status int, d pageData) {
+ t, ok := f.pages[page]
+ if !ok {
+ log.Printf("template %s: not embedded", page)
+ http.Error(w, "template missing", http.StatusInternalServerError)
+ return
+ }
+ var buf bytes.Buffer
+ if err := t.ExecuteTemplate(&buf, "base.html", d); err != nil {
+ log.Printf("template %s: %v", page, err)
+ http.Error(w, "template failed", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ w.Write(buf.Bytes())
+}
+
+func (f *Forge) fail(w http.ResponseWriter, r *http.Request, status int, msg string) {
+ d := f.page(r)
+ d.Error = msg
+ d.Data = errorPage{Status: status, Text: http.StatusText(status)}
+ f.render(w, "error.html", status, d)
+}
+
+// Fixed before the config is read, so the config cannot move it.
+func resolveDatadir(flagValue string) (string, error) {
+ dir := flagValue
+ if dir == "" {
+ dir = os.Getenv("GIT_FORGE_DATADIR")
+ }
+ if dir == "" {
+ dir = defaultDatadir
+ }
+ return filepath.Abs(dir)
+}
+
+func serve(cfg *Config, h http.Handler) error {
+ srv := &http.Server{
+ Handler: h,
+ // No read or write deadline: a push sends an entire packfile in one
+ // request and a clone streams one back.
+ ReadHeaderTimeout: 30 * time.Second,
+ IdleTimeout: 2 * time.Minute,
+ }
+
+ ln, err := net.Listen("tcp", cfg.Listen)
+ if err != nil {
+ return err
+ }
+ log.Printf("listening on %s", ln.Addr())
+
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+ defer stop()
+
+ errc := make(chan error, 1)
+ go func() { errc <- srv.Serve(ln) }()
+
+ select {
+ case err := <-errc:
+ if errors.Is(err, http.ErrServerClosed) {
+ return nil
+ }
+ return err
+ case <-ctx.Done():
+ stop() // a second signal now kills a drain that will not finish
+ log.Printf("draining")
+ sctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ return srv.Shutdown(sctx)
+ }
+}
+
+func healthz(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ io.WriteString(w, "ok\n")
+}
+
+// logRequests writes one line per request to stdout.
+func (f *Forge) logRequests(h http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ start := time.Now()
+ lw := &logWriter{ResponseWriter: w}
+ h.ServeHTTP(lw, r)
+ if lw.status == 0 {
+ lw.status = http.StatusOK
+ }
+ // No query string: reset links and OIDC codes arrive in one. Quoted,
+ // so a newline in the path cannot write the next line.
+ log.Printf("%s %s %q %d %d %dms", f.peer(r), r.Method, r.URL.Path,
+ lw.status, lw.bytes, time.Since(start).Milliseconds())
+ })
+}
+
+// peer is the address rate limits count against. The header is read only when
+// named, and only as one parsed address: anything else would be a key anybody
+// could invent.
+func (f *Forge) peer(r *http.Request) string {
+ if name := f.cfg.ClientIPHeader; name != "" {
+ if ip := net.ParseIP(strings.TrimSpace(r.Header.Get(name))); ip != nil {
+ return ip.String()
+ }
+ }
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ return r.RemoteAddr
+ }
+ return host
+}
+
+type logWriter struct {
+ http.ResponseWriter
+ status int
+ bytes int
+}
+
+func (w *logWriter) WriteHeader(status int) {
+ w.status = status
+ w.ResponseWriter.WriteHeader(status)
+}
+
+func (w *logWriter) Write(b []byte) (int, error) {
+ if w.status == 0 {
+ w.status = http.StatusOK
+ }
+ n, err := w.ResponseWriter.Write(b)
+ w.bytes += n
+ return n, err
+}
diff --git a/oidc.go b/oidc.go
new file mode 100644
index 0000000..f3df1ec
--- /dev/null
+++ b/oidc.go
@@ -0,0 +1,598 @@
+package main
+
+import (
+ "crypto"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/hmac"
+ "crypto/rsa"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "math/big"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+// OIDC is generic: discovery, authorization code with PKCE, and JWKS
+// verification. Identity is iss plus sub; the name is derived once, since
+// preferred_username promises neither uniqueness nor stability.
+
+const (
+ oidcCookie = "oidc"
+ oidcLife = 10 * time.Minute // how long a login may take
+ oidcRefresh = time.Hour // how long discovery and keys are kept
+ oidcTimeout = 15 * time.Second
+)
+
+// discovery is the part of a provider's metadata that gets used.
+type discovery struct {
+ Issuer string `json:"issuer"`
+ Authorization string `json:"authorization_endpoint"`
+ Token string `json:"token_endpoint"`
+ JWKS string `json:"jwks_uri"`
+}
+
+type providerCache struct {
+ meta discovery
+ keys []jwk
+ metaAt time.Time
+ keysAt time.Time
+}
+
+// OIDC is a cache; no session state lives here.
+type OIDC struct {
+ client *http.Client
+ mu sync.Mutex
+ cache map[string]*providerCache
+}
+
+func newOIDC() *OIDC {
+ return &OIDC{client: &http.Client{Timeout: oidcTimeout}, cache: map[string]*providerCache{}}
+}
+
+func (o *OIDC) entry(issuer string) *providerCache {
+ o.mu.Lock()
+ defer o.mu.Unlock()
+ if o.cache[issuer] == nil {
+ o.cache[issuer] = &providerCache{}
+ }
+ return o.cache[issuer]
+}
+
+// meta refuses a document that does not name its own issuer.
+func (o *OIDC) meta(issuer string) (discovery, error) {
+ entry := o.entry(issuer)
+ o.mu.Lock()
+ if time.Since(entry.metaAt) < oidcRefresh && entry.meta.Issuer != "" {
+ meta := entry.meta
+ o.mu.Unlock()
+ return meta, nil
+ }
+ o.mu.Unlock()
+
+ var meta discovery
+ if err := o.fetch(issuer+"/.well-known/openid-configuration", &meta); err != nil {
+ return meta, err
+ }
+ switch {
+ case meta.Issuer != issuer:
+ return meta, fmt.Errorf("oidc: %s says it is %q", issuer, meta.Issuer)
+ case meta.Authorization == "" || meta.Token == "" || meta.JWKS == "":
+ return meta, fmt.Errorf("oidc: %s is missing an endpoint", issuer)
+ }
+
+ o.mu.Lock()
+ entry.meta, entry.metaAt = meta, time.Now()
+ o.mu.Unlock()
+ return meta, nil
+}
+
+// signingKeys fetches the key set, again when a token names a key not held: a
+// rotation.
+func (o *OIDC) signingKeys(issuer, jwksURL string, force bool) ([]jwk, error) {
+ entry := o.entry(issuer)
+ o.mu.Lock()
+ fresh := time.Since(entry.keysAt) < oidcRefresh && len(entry.keys) > 0
+ keys := entry.keys
+ o.mu.Unlock()
+ if fresh && !force {
+ return keys, nil
+ }
+
+ var set struct {
+ Keys []jwk `json:"keys"`
+ }
+ if err := o.fetch(jwksURL, &set); err != nil {
+ return nil, err
+ }
+ o.mu.Lock()
+ entry.keys, entry.keysAt = set.Keys, time.Now()
+ o.mu.Unlock()
+ return set.Keys, nil
+}
+
+func (o *OIDC) fetch(url string, into any) error {
+ resp, err := o.client.Get(url)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("%s: %s", url, resp.Status)
+ }
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return err
+ }
+ return json.Unmarshal(body, into)
+}
+
+// The state and PKCE verifier go into a short lived signed cookie, which
+// survives a restart.
+func (f *Forge) handleOIDCStart(w http.ResponseWriter, r *http.Request) {
+ provider, ok := f.provider(r.PathValue("key"))
+ if !ok {
+ f.fail(w, r, http.StatusNotFound, "no such provider")
+ return
+ }
+ meta, err := f.oidc.meta(provider.Issuer)
+ if err != nil {
+ log.Printf("oidc: %v", err)
+ f.fail(w, r, http.StatusBadGateway, "that provider is not answering")
+ return
+ }
+
+ state, verifier := randomToken(16), randomToken(32)
+ f.setOIDCCookie(w, provider.Key, state, verifier)
+
+ sum := sha256.Sum256([]byte(verifier))
+ q := url.Values{
+ "response_type": {"code"},
+ "client_id": {provider.ClientID},
+ "redirect_uri": {f.oidcRedirect(provider.Key)},
+ "scope": {"openid email profile"},
+ "state": {state},
+ "code_challenge": {base64.RawURLEncoding.EncodeToString(sum[:])},
+ "code_challenge_method": {"S256"},
+ }
+ http.Redirect(w, r, meta.Authorization+"?"+q.Encode(), http.StatusSeeOther)
+}
+
+func (f *Forge) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
+ provider, ok := f.provider(r.PathValue("key"))
+ if !ok {
+ f.fail(w, r, http.StatusNotFound, "no such provider")
+ return
+ }
+ key, state, verifier, ok := f.oidcCookie(r)
+ f.clearOIDCCookie(w)
+ if !ok || key != provider.Key {
+ f.fail(w, r, http.StatusForbidden, "that login took too long, or did not start here. try again")
+ return
+ }
+ q := r.URL.Query()
+ if !hmac.Equal([]byte(q.Get("state")), []byte(state)) {
+ f.fail(w, r, http.StatusForbidden, "that login did not start here. try again")
+ return
+ }
+ if reason := q.Get("error"); reason != "" {
+ f.fail(w, r, http.StatusForbidden, "the provider refused: "+reason)
+ return
+ }
+
+ claims, err := f.oidcExchange(provider, q.Get("code"), verifier)
+ if err != nil {
+ log.Printf("oidc: %v", err)
+ f.fail(w, r, http.StatusForbidden, "that login could not be verified")
+ return
+ }
+ // Without a list, every address the provider will vouch for mints an
+ // account.
+ if !provider.allows(claims.Email) {
+ log.Printf("oidc: %s refused %q: not in the allow list", provider.Key, claims.Email)
+ f.fail(w, r, http.StatusForbidden, "that address may not sign in here")
+ return
+ }
+
+ name, u, err := f.oidcAccount(provider, claims)
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return
+ }
+ // The provider is the first factor, not the second.
+ if u.TOTP != nil {
+ f.askForCode(w, r, name, u, "")
+ return
+ }
+ f.setSession(w, name, u.Epoch)
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+// oidcExchange trades the code for an id token and verifies it.
+func (f *Forge) oidcExchange(provider OIDCProvider, code, verifier string) (*claims, error) {
+ if code == "" {
+ return nil, errors.New("no code")
+ }
+ meta, err := f.oidc.meta(provider.Issuer)
+ if err != nil {
+ return nil, err
+ }
+
+ form := url.Values{
+ "grant_type": {"authorization_code"},
+ "code": {code},
+ "redirect_uri": {f.oidcRedirect(provider.Key)},
+ "client_id": {provider.ClientID},
+ "code_verifier": {verifier},
+ }
+ req, err := http.NewRequest(http.MethodPost, meta.Token, strings.NewReader(form.Encode()))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Accept", "application/json")
+ if provider.ClientSecret != "" {
+ req.SetBasicAuth(url.QueryEscape(provider.ClientID), url.QueryEscape(provider.ClientSecret))
+ }
+ resp, err := f.oidc.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("token endpoint: %s: %s", resp.Status, strings.TrimSpace(string(body)))
+ }
+ var token struct {
+ IDToken string `json:"id_token"`
+ }
+ if err := json.Unmarshal(body, &token); err != nil {
+ return nil, err
+ }
+ if token.IDToken == "" {
+ return nil, errors.New("no id_token in the token response")
+ }
+ return f.verifyIDToken(provider, token.IDToken)
+}
+
+func (f *Forge) verifyIDToken(provider OIDCProvider, token string) (*claims, error) {
+ signed, signature, kid, alg, payload, err := splitJWT(token)
+ if err != nil {
+ return nil, err
+ }
+ meta, err := f.oidc.meta(provider.Issuer)
+ if err != nil {
+ return nil, err
+ }
+ keys, err := f.oidc.signingKeys(provider.Issuer, meta.JWKS, false)
+ if err != nil {
+ return nil, err
+ }
+ key, ok := findKey(keys, kid)
+ if !ok {
+ if keys, err = f.oidc.signingKeys(provider.Issuer, meta.JWKS, true); err != nil {
+ return nil, err
+ }
+ if key, ok = findKey(keys, kid); !ok {
+ return nil, fmt.Errorf("id token signed by unknown key %q", kid)
+ }
+ }
+ if err := verifySignature(key, alg, signed, signature); err != nil {
+ return nil, err
+ }
+
+ var c claims
+ if err := json.Unmarshal(payload, &c); err != nil {
+ return nil, err
+ }
+ now := time.Now()
+ switch {
+ case c.Iss != provider.Issuer:
+ return nil, fmt.Errorf("id token issued by %q, not %q", c.Iss, provider.Issuer)
+ case c.Sub == "":
+ return nil, errors.New("id token has no subject")
+ case !c.Aud.has(provider.ClientID):
+ return nil, fmt.Errorf("id token is for %v, not for %s", c.Aud, provider.ClientID)
+ case c.Exp == 0 || now.After(time.Unix(c.Exp, 0).Add(time.Minute)):
+ return nil, errors.New("id token has expired")
+ case c.Iat != 0 && time.Unix(c.Iat, 0).After(now.Add(5*time.Minute)):
+ return nil, errors.New("id token was issued in the future")
+ case c.EmailVerified == nil || !*c.EmailVerified:
+ return nil, errors.New("the provider did not say the address is verified")
+ case c.Email == "" || !validEmail(c.Email):
+ // This reaches git's author field and a mail envelope.
+ return nil, fmt.Errorf("id token carries %q, which is not an address", c.Email)
+ }
+ return &c, nil
+}
+
+// oidcAccount finds the account this identity belongs to, or provisions one.
+func (f *Forge) oidcAccount(provider OIDCProvider, c *claims) (string, User, error) {
+ for name, u := range f.store.Users() {
+ if u.Origin == originOIDC && u.Iss == c.Iss && u.Sub == c.Sub {
+ // The provider owns the address; the name is what grants point at
+ // and never changes.
+ if u.Email != c.Email || !u.EmailConfirmed {
+ err := f.store.UpdateUser(name, "users: refresh the address of "+name, Author{Name: name, Email: c.Email},
+ func(acct *User) error {
+ acct.Email, acct.EmailConfirmed = c.Email, true
+ return nil
+ })
+ if err != nil {
+ return "", User{}, err
+ }
+ u, _ = f.store.User(name)
+ }
+ return name, u, nil
+ }
+ }
+
+ name := f.deriveName(provider.Key, c)
+ acct := User{
+ Origin: originOIDC, Iss: c.Iss, Sub: c.Sub,
+ Email: c.Email, EmailConfirmed: true, Notify: true,
+ }
+ err := f.store.UpdateUsers("users: provision "+name+" from "+provider.Label, Author{Name: name, Email: c.Email},
+ func(m map[string]User) error {
+ if _, taken := m[name]; taken {
+ return fmt.Errorf("%s was taken while provisioning it", name)
+ }
+ m[name] = acct
+ return nil
+ })
+ if err != nil {
+ return "", User{}, err
+ }
+ log.Printf("oidc: provisioned %s from %s with no grants", name, provider.Issuer)
+ f.mailAdmins("", "new account "+name+" is waiting for a grant",
+ name+" signed in through "+provider.Label+" for the first time and holds no grants yet.\n\n"+
+ f.url("/admin")+"\n")
+ return name, acct, nil
+}
+
+// deriveName is run once, at first login.
+func (f *Forge) deriveName(key string, c *claims) string {
+ base := sanitizeName(c.Username)
+ if base == "" {
+ local, _, _ := strings.Cut(c.Email, "@")
+ base = sanitizeName(local)
+ }
+ if base == "" {
+ base = sanitizeName(c.Sub)
+ }
+ if base == "" {
+ base = "user"
+ }
+ if len(base) > 24 {
+ base = base[:24]
+ }
+
+ users := f.store.Users()
+ name := key + ":" + base
+ for n := 2; ; n++ {
+ if _, taken := users[name]; !taken {
+ return name
+ }
+ name = key + ":" + base + strconv.Itoa(n)
+ }
+}
+
+var unsafeName = regexp.MustCompile(`[^a-z0-9._-]+`)
+
+// sanitizeName is blunt: the name becomes a key in repos.json and part of a
+// URL, out of a claim from elsewhere.
+func sanitizeName(s string) string {
+ s = unsafeName.ReplaceAllString(strings.ToLower(s), "-")
+ s = strings.Trim(s, "-._")
+ if s == "" || !localNameRE.MatchString(s) {
+ return ""
+ }
+ return s
+}
+
+func (f *Forge) provider(key string) (OIDCProvider, bool) {
+ for _, p := range f.cfg.OIDC {
+ if p.Key == key {
+ return p, true
+ }
+ }
+ return OIDCProvider{}, false
+}
+
+func (f *Forge) oidcRedirect(key string) string { return f.cfg.BaseURL + "/oidc/" + key + "/callback" }
+
+// The login cookie: the provider, the state, the verifier, and an expiry.
+func (f *Forge) setOIDCCookie(w http.ResponseWriter, key, state, verifier string) {
+ expires := strconv.FormatInt(time.Now().Add(oidcLife).Unix(), 10)
+ value := strings.Join([]string{key, state, verifier, expires}, ".")
+ http.SetCookie(w, &http.Cookie{
+ Name: oidcCookie,
+ Value: value + "." + f.mac("oidc", key, state, verifier, expires),
+ Path: "/oidc/",
+ MaxAge: int(oidcLife / time.Second),
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode, // the callback arrives from the provider
+ Secure: strings.HasPrefix(f.cfg.BaseURL, "https://"),
+ })
+}
+
+func (f *Forge) oidcCookie(r *http.Request) (key, state, verifier string, ok bool) {
+ c, err := r.Cookie(oidcCookie)
+ if err != nil {
+ return "", "", "", false
+ }
+ parts := strings.Split(c.Value, ".")
+ if len(parts) != 5 {
+ return "", "", "", false
+ }
+ key, state, verifier, expires, mac := parts[0], parts[1], parts[2], parts[3], parts[4]
+ unix, err := strconv.ParseInt(expires, 10, 64)
+ if err != nil || time.Now().After(time.Unix(unix, 0)) {
+ return "", "", "", false
+ }
+ if !hmac.Equal([]byte(mac), []byte(f.mac("oidc", key, state, verifier, expires))) {
+ return "", "", "", false
+ }
+ return key, state, verifier, true
+}
+
+func (f *Forge) clearOIDCCookie(w http.ResponseWriter) {
+ http.SetCookie(w, &http.Cookie{Name: oidcCookie, Path: "/oidc/", MaxAge: -1, HttpOnly: true})
+}
+
+// claims is the part of an id token that gets used.
+type claims struct {
+ Iss string `json:"iss"`
+ Sub string `json:"sub"`
+ Aud audience `json:"aud"`
+ Exp int64 `json:"exp"`
+ Iat int64 `json:"iat"`
+ Email string `json:"email"`
+ EmailVerified *bool `json:"email_verified"`
+ Username string `json:"preferred_username"`
+}
+
+// audience is one string or several, as providers send it.
+type audience []string
+
+func (a *audience) UnmarshalJSON(data []byte) error {
+ var one string
+ if err := json.Unmarshal(data, &one); err == nil {
+ *a = audience{one}
+ return nil
+ }
+ var many []string
+ if err := json.Unmarshal(data, &many); err != nil {
+ return err
+ }
+ *a = many
+ return nil
+}
+
+func (a audience) has(want string) bool {
+ for _, s := range a {
+ if s == want {
+ return true
+ }
+ }
+ return false
+}
+
+// jwk is one signing key from the provider's key set.
+type jwk struct {
+ Kty string `json:"kty"`
+ Kid string `json:"kid"`
+ Use string `json:"use"`
+ N string `json:"n"`
+ E string `json:"e"`
+ Crv string `json:"crv"`
+ X string `json:"x"`
+ Y string `json:"y"`
+}
+
+// An exact kid wins over a key naming none, or a rotation would hide behind a
+// failed verification.
+func findKey(keys []jwk, kid string) (jwk, bool) {
+ for _, k := range keys {
+ if signingKey(k) && kid != "" && k.Kid == kid {
+ return k, true
+ }
+ }
+ for _, k := range keys {
+ if signingKey(k) && (kid == "" || k.Kid == "") {
+ return k, true
+ }
+ }
+ return jwk{}, false
+}
+
+// An absent use is unrestricted.
+func signingKey(k jwk) bool { return k.Use == "" || k.Use == "sig" }
+
+// splitJWT takes a token apart without trusting any of it.
+func splitJWT(token string) (signed, signature []byte, kid, alg string, payload []byte, err error) {
+ parts := strings.Split(token, ".")
+ if len(parts) != 3 {
+ return nil, nil, "", "", nil, errors.New("id token is not a jwt")
+ }
+ head, err := base64.RawURLEncoding.DecodeString(parts[0])
+ if err != nil {
+ return nil, nil, "", "", nil, err
+ }
+ var header struct {
+ Alg string `json:"alg"`
+ Kid string `json:"kid"`
+ }
+ if err := json.Unmarshal(head, &header); err != nil {
+ return nil, nil, "", "", nil, err
+ }
+ payload, err = base64.RawURLEncoding.DecodeString(parts[1])
+ if err != nil {
+ return nil, nil, "", "", nil, err
+ }
+ signature, err = base64.RawURLEncoding.DecodeString(parts[2])
+ if err != nil {
+ return nil, nil, "", "", nil, err
+ }
+ return []byte(parts[0] + "." + parts[1]), signature, header.Kid, header.Alg, payload, nil
+}
+
+// RS256 and ES256 only.
+func verifySignature(key jwk, alg string, signed, signature []byte) error {
+ sum := sha256.Sum256(signed)
+ switch {
+ case alg == "RS256" && key.Kty == "RSA":
+ n, err := bigFromJWK(key.N)
+ if err != nil {
+ return err
+ }
+ e, err := base64.RawURLEncoding.DecodeString(key.E)
+ if err != nil {
+ return err
+ }
+ pub := &rsa.PublicKey{N: n, E: int(new(big.Int).SetBytes(e).Int64())}
+ return rsa.VerifyPKCS1v15(pub, crypto.SHA256, sum[:], signature)
+
+ case alg == "ES256" && key.Kty == "EC" && key.Crv == "P-256":
+ x, err := bigFromJWK(key.X)
+ if err != nil {
+ return err
+ }
+ y, err := bigFromJWK(key.Y)
+ if err != nil {
+ return err
+ }
+ if len(signature) != 64 {
+ return errors.New("es256 signature is the wrong size")
+ }
+ pub := &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}
+ r := new(big.Int).SetBytes(signature[:32])
+ s := new(big.Int).SetBytes(signature[32:])
+ if !ecdsa.Verify(pub, sum[:], r, s) {
+ return errors.New("id token signature does not verify")
+ }
+ return nil
+ }
+ return fmt.Errorf("id token algorithm %q with key type %q is not supported", alg, key.Kty)
+}
+
+func bigFromJWK(field string) (*big.Int, error) {
+ raw, err := base64.RawURLEncoding.DecodeString(field)
+ if err != nil {
+ return nil, err
+ }
+ return new(big.Int).SetBytes(raw), nil
+}
diff --git a/oidc_test.go b/oidc_test.go
new file mode 100644
index 0000000..a065db3
--- /dev/null
+++ b/oidc_test.go
@@ -0,0 +1,474 @@
+package main
+
+import (
+ "crypto"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "math/big"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+)
+
+// fakeProvider is discovery, a key set, and a token endpoint. A real provider
+// is checked by hand.
+type fakeProvider struct {
+ *httptest.Server
+ key *rsa.PrivateKey
+ signer *rsa.PrivateKey // what it signs with, which is key unless a test says otherwise
+ claims map[string]any
+ verifier string // the code_verifier the token endpoint was given
+}
+
+func newFakeProvider(t *testing.T) *fakeProvider {
+ t.Helper()
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatal(err)
+ }
+ p := &fakeProvider{key: key, signer: key}
+ mux := http.NewServeMux()
+ mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]string{
+ "issuer": p.URL,
+ "authorization_endpoint": p.URL + "/auth",
+ "token_endpoint": p.URL + "/token",
+ "jwks_uri": p.URL + "/jwks",
+ })
+ })
+ mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
+ pub := key.Public().(*rsa.PublicKey)
+ json.NewEncoder(w).Encode(map[string]any{"keys": []map[string]string{{
+ "kty": "RSA", "kid": "k1", "alg": "RS256", "use": "sig",
+ "n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
+ "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()),
+ }}})
+ })
+ mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
+ r.ParseForm()
+ p.verifier = r.PostFormValue("code_verifier")
+ json.NewEncoder(w).Encode(map[string]string{"id_token": p.sign(t)})
+ })
+ p.Server = httptest.NewServer(mux)
+ p.claims = map[string]any{
+ "iss": p.URL, "sub": "3719", "aud": "git-forge",
+ "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(),
+ "email": "carol@example.com", "email_verified": true,
+ "preferred_username": "Carol",
+ }
+ return p
+}
+
+func (p *fakeProvider) sign(t *testing.T) string {
+ t.Helper()
+ b64 := base64.RawURLEncoding.EncodeToString
+ header, err := json.Marshal(map[string]string{"alg": "RS256", "kid": "k1", "typ": "JWT"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ payload, err := json.Marshal(p.claims)
+ if err != nil {
+ t.Fatal(err)
+ }
+ signed := b64(header) + "." + b64(payload)
+ sum := sha256.Sum256([]byte(signed))
+ sig, err := rsa.SignPKCS1v15(rand.Reader, p.signer, crypto.SHA256, sum[:])
+ if err != nil {
+ t.Fatal(err)
+ }
+ return signed + "." + b64(sig)
+}
+
+// login drives one whole flow and returns the callback's response.
+func login(t *testing.T, f *Forge, h http.Handler, p *fakeProvider, mangle func(url.Values)) *httptest.ResponseRecorder {
+ t.Helper()
+ start := get(h, "/oidc/sso", nil)
+ if start.Code != http.StatusSeeOther {
+ t.Fatalf("starting a login: status %d", start.Code)
+ }
+ to, err := url.Parse(start.Header().Get("Location"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ sent := to.Query()
+ if sent.Get("code_challenge_method") != "S256" || sent.Get("code_challenge") == "" {
+ t.Errorf("no pkce challenge in %v", sent)
+ }
+
+ back := url.Values{"code": {"the-code"}, "state": {sent.Get("state")}}
+ if mangle != nil {
+ mangle(back)
+ }
+ w := get(h, "/oidc/sso/callback?"+back.Encode(), start.Result().Cookies())
+
+ // The verifier the token endpoint saw has to hash to the challenge sent.
+ if p.verifier != "" {
+ sum := sha256.Sum256([]byte(p.verifier))
+ if got := base64.RawURLEncoding.EncodeToString(sum[:]); got != sent.Get("code_challenge") {
+ t.Errorf("the verifier does not match the challenge")
+ }
+ }
+ return w
+}
+
+func oidcForge(t *testing.T, p *fakeProvider) *Forge {
+ t.Helper()
+ f := testForge(t)
+ // Assigned rather than loaded: the config refuses an issuer that is not
+ // https, and this one is a test server.
+ f.cfg.OIDC = []OIDCProvider{{
+ Key: "sso", Label: "Example SSO", Issuer: p.URL,
+ ClientID: "git-forge", ClientSecret: "shh",
+ }}
+ return f
+}
+
+func TestOIDCLoginProvisions(t *testing.T) {
+ p := newFakeProvider(t)
+ defer p.Close()
+ f := oidcForge(t, p)
+ h := f.routes()
+
+ w := login(t, f, h, p, nil)
+ if w.Code != http.StatusSeeOther {
+ t.Fatalf("callback: status %d, %s", w.Code, w.Body.String())
+ }
+ u, ok := f.store.User("sso:carol")
+ if !ok {
+ t.Fatalf("no account was provisioned: %v", f.store.Users())
+ }
+ if u.Origin != originOIDC || u.Iss != p.URL || u.Sub != "3719" {
+ t.Errorf("identity is %+v", u)
+ }
+ if u.Email != "carol@example.com" || !u.EmailConfirmed {
+ t.Errorf("address is %q, confirmed %v", u.Email, u.EmailConfirmed)
+ }
+ if u.Admin || u.Password != "" {
+ t.Error("a provisioned account should hold no grants and no password")
+ }
+ if body := get(h, "/", w.Result().Cookies()).Body.String(); !strings.Contains(body, "sso:carol") {
+ t.Error("the session from an external login does not work")
+ }
+
+ // A second login is the same account: identity is iss plus sub, and the
+ // name is never matched on.
+ p.claims["preferred_username"] = "Carol Renamed"
+ if w := login(t, f, h, p, nil); w.Code != http.StatusSeeOther {
+ t.Fatalf("second login: status %d", w.Code)
+ }
+ if len(f.store.Users()) != 1 {
+ t.Errorf("a second login made a second account: %v", f.store.Users())
+ }
+
+ // A different subject with the same username gets a suffix rather than
+ // the other account.
+ p.claims["sub"] = "9999"
+ p.claims["preferred_username"] = "Carol"
+ p.claims["email"] = "carol2@example.com"
+ if w := login(t, f, h, p, nil); w.Code != http.StatusSeeOther {
+ t.Fatalf("third login: status %d", w.Code)
+ }
+ if _, ok := f.store.User("sso:carol2"); !ok {
+ t.Errorf("a colliding name was not suffixed: %v", f.store.Users())
+ }
+}
+
+// The provider is the first factor and not the second, and the global admin
+// flag rests on there being a second one.
+func TestOIDCLoginAsksForTheSecondFactor(t *testing.T) {
+ p := newFakeProvider(t)
+ defer p.Close()
+ f := oidcForge(t, p)
+ h := f.routes()
+
+ if w := login(t, f, h, p, nil); w.Code != http.StatusSeeOther {
+ t.Fatalf("the login that provisions the account: status %d", w.Code)
+ }
+ addTOTP(t, f, "sso:carol")
+
+ first := login(t, f, h, p, nil)
+ if first.Code != http.StatusOK || !strings.Contains(first.Body.String(), "Authenticator code") {
+ t.Fatalf("the callback did not ask for a code: status %d", first.Code)
+ }
+ for _, c := range first.Result().Cookies() {
+ if c.Name == sessionCookie && c.Value != "" {
+ t.Fatal("the provider alone issued a session")
+ }
+ }
+
+ second := post(h, "/login/totp", url.Values{
+ "csrf": {f.csrf(withCookies("/", nil))},
+ "name": {between(first.Body.String(), `name="name" value="`, `"`)},
+ "expires": {between(first.Body.String(), `name="expires" value="`, `"`)},
+ "t": {between(first.Body.String(), `name="t" value="`, `"`)},
+ "code": {totpCode(knownSecret, time.Now())},
+ }, nil)
+ if second.Code != http.StatusSeeOther {
+ t.Fatalf("the second step refused a good code: status %d", second.Code)
+ }
+ if body := get(h, "/", second.Result().Cookies()).Body.String(); !strings.Contains(body, "sso:carol") {
+ t.Error("the session the second step issued does not work")
+ }
+}
+
+// The note carrying a half-finished login is bound to the epoch, so signing the
+// account out everywhere while it is in flight kills it.
+func TestPendingLoginDiesWithTheEpoch(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addTOTP(t, f, "alice")
+ h := f.routes()
+ first := post(h, "/login", url.Values{"name": {"alice"}, "password": {"hunter2hunter2"}}, nil)
+ pending := url.Values{
+ "csrf": {f.csrf(withCookies("/", nil))},
+ "name": {between(first.Body.String(), `name="name" value="`, `"`)},
+ "expires": {between(first.Body.String(), `name="expires" value="`, `"`)},
+ "t": {between(first.Body.String(), `name="t" value="`, `"`)},
+ "code": {totpCode(knownSecret, time.Now())},
+ }
+ err := f.store.UpdateUsers("users: sign out", f.store.forgeAuthor(), func(m map[string]User) error {
+ u := m["alice"]
+ u.Epoch++
+ m["alice"] = u
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if w := post(h, "/login/totp", pending, nil); w.Code != http.StatusForbidden {
+ t.Errorf("a login in flight survived the account being signed out: status %d", w.Code)
+ }
+}
+
+func TestOIDCRefusals(t *testing.T) {
+ cases := []struct {
+ what string
+ claims map[string]any
+ mangle func(url.Values)
+ }{
+ {what: "an unverified address", claims: map[string]any{"email_verified": false}},
+ {what: "a missing email_verified", claims: map[string]any{"email_verified": nil}},
+ {what: "an id token for somebody else", claims: map[string]any{"aud": "another-client"}},
+ {what: "an expired id token", claims: map[string]any{"exp": time.Now().Add(-2 * time.Hour).Unix()}},
+ {what: "an id token from another issuer", claims: map[string]any{"iss": "https://evil.example"}},
+ {what: "an address with no domain", claims: map[string]any{"email": "carol"}},
+ {what: "an address with a line break in it", claims: map[string]any{"email": "carol@example.com\nx@evil.example"}},
+ {what: "a state that did not come from us", mangle: func(v url.Values) { v.Set("state", "made-up") }},
+ }
+ for _, c := range cases {
+ p := newFakeProvider(t)
+ f := oidcForge(t, p)
+ for k, v := range c.claims {
+ if v == nil {
+ delete(p.claims, k)
+ } else {
+ p.claims[k] = v
+ }
+ }
+ w := login(t, f, f.routes(), p, c.mangle)
+ if w.Code != http.StatusForbidden {
+ t.Errorf("%s: status %d", c.what, w.Code)
+ }
+ if len(f.store.Users()) != 0 {
+ t.Errorf("%s: an account was provisioned anyway", c.what)
+ }
+ p.Close()
+ }
+}
+
+// Every claim above is read out of a payload that has already been trusted, so
+// a token the provider's own key set cannot vouch for has to be refused before
+// any of them is looked at. The key set still publishes k1 and the token still
+// names it: only the signature is somebody else's.
+func TestAnIdTokenTheKeySetCannotVouchForIsRefused(t *testing.T) {
+ p := newFakeProvider(t)
+ defer p.Close()
+ other, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatal(err)
+ }
+ p.signer = other
+
+ f := oidcForge(t, p)
+ if w := login(t, f, f.routes(), p, nil); w.Code != http.StatusForbidden {
+ t.Errorf("a token signed by a key the provider does not publish: status %d", w.Code)
+ }
+ if len(f.store.Users()) != 0 {
+ t.Errorf("an account was provisioned from a token nothing vouches for: %v", f.store.Users())
+ }
+}
+
+// An exact kid wins: taking a key that merely names none ahead of the match
+// fails verification and hides a rotation from the refetch.
+func TestFindKey(t *testing.T) {
+ mixed := []jwk{{Kid: ""}, {Kid: "k2"}}
+ if k, ok := findKey(mixed, "k2"); !ok || k.Kid != "k2" {
+ t.Errorf("an exact kid was passed over for %+v, %v", k, ok)
+ }
+ if k, ok := findKey(mixed, ""); !ok || k.Kid != "" {
+ t.Errorf("a token naming no kid got %+v, %v", k, ok)
+ }
+ if k, ok := findKey([]jwk{{Kid: "sole", Use: "sig"}}, ""); !ok || k.Kid != "sole" {
+ t.Errorf("the one signing key was refused: %+v, %v", k, ok)
+ }
+ // A rotation: every key names a kid and none of them is this one, so the
+ // answer has to be no, or the key set is never fetched again.
+ if _, ok := findKey([]jwk{{Kid: "k1"}, {Kid: "k2"}}, "k9"); ok {
+ t.Error("an unknown kid found a key anyway")
+ }
+ if _, ok := findKey([]jwk{{Kid: "k1", Use: "enc"}}, ""); ok {
+ t.Error("an encryption key was offered for a signature")
+ }
+}
+
+func TestSanitizeName(t *testing.T) {
+ cases := map[string]string{
+ "Carol": "carol",
+ "carol.smith": "carol.smith",
+ "../../config": "config",
+ "Ünïcodé": "n-cod", // no transliteration: unsafe runs become a dash
+ "": "",
+ "...": "",
+ "a b": "a-b",
+ "sso:carol": "sso-carol",
+ strings.Repeat("x", 40): "",
+ }
+ for in, want := range cases {
+ if got := sanitizeName(in); got != want {
+ t.Errorf("sanitizeName(%q) = %q, wanted %q", in, got, want)
+ }
+ }
+}
+
+func TestOIDCAllows(t *testing.T) {
+ cases := []struct {
+ allow []string
+ email string
+ want bool
+ }{
+ {nil, "anyone@example.com", true},
+ {[]string{}, "anyone@example.com", true},
+ {[]string{"carol@example.com"}, "carol@example.com", true},
+ {[]string{"carol@example.com"}, "dave@example.com", false},
+ {[]string{"@example.com"}, "anyone@example.com", true},
+ {[]string{"@example.com"}, "anyone@evil.example", false},
+ // A domain entry is a whole label, not a suffix.
+ {[]string{"@example.com"}, "anyone@sub.example.com", false},
+ {[]string{"@example.com"}, "anyone@example.com.attacker.test", false},
+ {[]string{"@example.com"}, "anyone@notexample.com", false},
+ // The provider decides the case of what it sends.
+ {[]string{"Carol@Example.COM"}, "carol@example.com", true},
+ {[]string{"@Example.COM"}, "CAROL@example.com", true},
+ {[]string{"@example.com", "dave@other.example"}, "dave@other.example", true},
+ {[]string{"@example.com"}, "no-at-sign-at-all", false},
+ }
+ for _, c := range cases {
+ p := OIDCProvider{Allow: c.allow}
+ if got := p.allows(c.email); got != c.want {
+ t.Errorf("allow %v, address %q: got %v", c.allow, c.email, got)
+ }
+ }
+}
+
+// An address outside the list is refused before an account is made.
+func TestOIDCLoginRefusesUnlistedAddress(t *testing.T) {
+ p := newFakeProvider(t)
+ defer p.Close()
+ f := oidcForge(t, p)
+ f.cfg.OIDC[0].Allow = []string{"@allowed.example", "specific@other.example"}
+
+ if w := login(t, f, f.routes(), p, nil); w.Code != http.StatusForbidden {
+ t.Fatalf("an unlisted address got status %d", w.Code)
+ }
+ if n := len(f.store.Users()); n != 0 {
+ t.Fatalf("%d accounts were provisioned anyway: %v", n, f.store.Users())
+ }
+
+ // And the listed one gets in, so the refusal is the list and not the flow.
+ p.claims["email"] = "carol@allowed.example"
+ if w := login(t, f, f.routes(), p, nil); w.Code != http.StatusSeeOther {
+ t.Fatalf("a listed address got status %d", w.Code)
+ }
+ if _, ok := f.store.User("sso:carol"); !ok {
+ t.Fatalf("no account for a listed address: %v", f.store.Users())
+ }
+}
+
+// Removing an entry revokes, rather than only refusing new accounts.
+func TestOIDCAllowListRevokes(t *testing.T) {
+ p := newFakeProvider(t)
+ defer p.Close()
+ f := oidcForge(t, p)
+ h := f.routes()
+
+ if w := login(t, f, h, p, nil); w.Code != http.StatusSeeOther {
+ t.Fatalf("first login: %d", w.Code)
+ }
+ if _, ok := f.store.User("sso:carol"); !ok {
+ t.Fatal("the first login provisioned nothing")
+ }
+
+ f.cfg.OIDC[0].Allow = []string{"@somewhere-else.example"}
+ if w := login(t, f, h, p, nil); w.Code != http.StatusForbidden {
+ t.Errorf("an account that exists still signed in after being removed: %d", w.Code)
+ }
+ if _, ok := f.store.User("sso:carol"); !ok {
+ t.Error("the account was deleted rather than refused")
+ }
+}
+
+// The other algorithm. A provider that signs with ES256 is verified by code
+// that nothing else here runs, so it is exercised directly rather than through
+// a second fake provider.
+func TestES256IdTokens(t *testing.T) {
+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ b64 := base64.RawURLEncoding.EncodeToString
+ pub := key.Public().(*ecdsa.PublicKey)
+ jwk := jwk{Kty: "EC", Kid: "e1", Use: "sig", Crv: "P-256",
+ X: b64(pub.X.Bytes()), Y: b64(pub.Y.Bytes())}
+
+ signed := []byte("the.token")
+ sum := sha256.Sum256(signed)
+ r, s, err := ecdsa.Sign(rand.Reader, key, sum[:])
+ if err != nil {
+ t.Fatal(err)
+ }
+ sig := make([]byte, 64)
+ r.FillBytes(sig[:32])
+ s.FillBytes(sig[32:])
+
+ if err := verifySignature(jwk, "ES256", signed, sig); err != nil {
+ t.Errorf("a good signature was refused: %v", err)
+ }
+ if err := verifySignature(jwk, "ES256", []byte("another.token"), sig); err == nil {
+ t.Error("the signature verified against a different token")
+ }
+ // An algorithm the key cannot carry is refused rather than guessed at.
+ if err := verifySignature(jwk, "RS256", signed, sig); err == nil {
+ t.Error("an ec key verified an rs256 signature")
+ }
+ if err := verifySignature(jwk, "none", signed, nil); err == nil {
+ t.Error("an unsigned token verified")
+ }
+ // A signature of the wrong size, and coordinates that are not on the
+ // curve, both of which a hostile key set could hold.
+ if err := verifySignature(jwk, "ES256", signed, sig[:63]); err == nil {
+ t.Error("a short signature verified")
+ }
+ off := jwk
+ off.X = b64([]byte{1, 2, 3})
+ if err := verifySignature(off, "ES256", signed, sig); err == nil {
+ t.Error("a point that is not on the curve verified")
+ }
+}
diff --git a/perm.go b/perm.go
new file mode 100644
index 0000000..a915a95
--- /dev/null
+++ b/perm.go
@@ -0,0 +1,202 @@
+package main
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+)
+
+// Repo is one entry in repos.json, keyed by {section}/{repo} or {repo}.
+type Repo struct {
+ Desc string `json:"desc"`
+ Visibility string `json:"visibility"`
+ Issues string `json:"issues"`
+ Wiki string `json:"wiki"`
+ Labels []string `json:"labels"`
+ Grants map[string]string `json:"grants"`
+}
+
+// visibility governs reading everything; issues and wiki each govern writing
+// to that feature.
+const (
+ visPublic = "public"
+ visPrivate = "private"
+
+ featDisabled = "disabled"
+ featMembers = "members"
+ featUsers = "users"
+
+ grantRead = "read"
+ grantWrite = "write"
+ grantAdmin = "admin"
+)
+
+// orphanRepo is how a directory with no entry is served: private, both
+// features off.
+func orphanRepo() Repo {
+ return Repo{
+ Visibility: visPrivate, Issues: featDisabled, Wiki: featDisabled,
+ Labels: []string{}, Grants: map[string]string{},
+ }
+}
+
+// clone deep copies, so a writer rebuilding grants is never seen half done.
+func (r Repo) clone() Repo {
+ r.Labels = append([]string(nil), r.Labels...)
+ grants := make(map[string]string, len(r.Grants))
+ for who, level := range r.Grants {
+ grants[who] = level
+ }
+ r.Grants = grants
+ return r
+}
+
+// A section or repository name. The first character rules out . and .., so a
+// path built from these cannot traverse.
+var repoNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`)
+
+// The forge's own route names, refused in both components: /infra/settings is
+// the settings of infra.
+var reservedNames = map[string]bool{
+ "login": true, "logout": true, "settings": true, "admin": true,
+ "setup": true, "healthz": true, "set-password": true,
+ "cgit.css": true, "cgit.png": true, "favicon.ico": true,
+ "logo": true, "avatar": true, "reset": true, "confirm-email": true, "oidc": true,
+ "issues": true, "wiki": true,
+}
+
+// cgit's routes, refused in the second component: /infra/log is the log of
+// infra. cgit's command table, less info and objects, which it refuses itself.
+var cgitNames = map[string]bool{
+ "about": true, "atom": true, "blame": true, "blob": true, "commit": true,
+ "diff": true, "log": true, "ls_cache": true, "patch": true, "plain": true,
+ "rawdiff": true, "refs": true, "repolist": true, "snapshot": true,
+ "stats": true, "summary": true, "tag": true, "tree": true,
+}
+
+// Suffixes the tree gives meaning to.
+var reservedSuffixes = []string{".git", ".issues", ".wiki"}
+
+// validRepoPath checks a path from a URL or repos.json: one or two components.
+func validRepoPath(path string) bool {
+ parts := strings.Split(path, "/")
+ if len(parts) > 2 {
+ return false
+ }
+ for i, part := range parts {
+ if !repoNameRE.MatchString(part) || reservedNames[part] {
+ return false
+ }
+ if i == 1 && cgitNames[part] {
+ return false
+ }
+ for _, suffix := range reservedSuffixes {
+ if strings.HasSuffix(part, suffix) {
+ return false
+ }
+ }
+ }
+ return true
+}
+
+// Viewer is whoever is asking. The zero value is an anonymous visitor.
+type Viewer struct {
+ Name string // empty when signed out
+ Admin bool // the global admin flag
+}
+
+func (v Viewer) SignedIn() bool { return v.Name != "" }
+
+// Grant is the viewer's level on the repository, or "" for none. A global
+// admin holds admin everywhere.
+func (v Viewer) Grant(repo Repo) string {
+ if v.Admin {
+ return grantAdmin
+ }
+ if v.Name == "" {
+ return ""
+ }
+ return repo.Grants[v.Name]
+}
+
+// CanRead covers code, browsing, cloning, issues, and wiki alike.
+func (v Viewer) CanRead(repo Repo) bool {
+ return repo.Visibility == visPublic || v.Grant(repo) != ""
+}
+
+// CanWrite is push to {repo}.git alone; issues and wiki have their own knobs.
+func (v Viewer) CanWrite(repo Repo) bool {
+ g := v.Grant(repo)
+ return g == grantWrite || g == grantAdmin
+}
+
+// CanAdmin is this repository's settings and grants.
+func (v Viewer) CanAdmin(repo Repo) bool { return v.Grant(repo) == grantAdmin }
+
+// CanIssues and CanWiki are writing; reading is visibility's alone.
+func (v Viewer) CanIssues(repo Repo) bool { return v.canFeature(repo, repo.Issues) }
+func (v Viewer) CanWiki(repo Repo) bool { return v.canFeature(repo, repo.Wiki) }
+
+func (v Viewer) canFeature(repo Repo, knob string) bool {
+ if !v.CanRead(repo) {
+ return false
+ }
+ switch knob {
+ case featMembers:
+ return v.Grant(repo) != ""
+ case featUsers:
+ return v.SignedIn()
+ }
+ return false
+}
+
+// validate checks one repos.json record. Refused rather than coerced: a
+// coerced permission is one nobody knows they have.
+func (r Repo) validate(path string) []error {
+ var errs []error
+ fail := func(format string, a ...any) {
+ errs = append(errs, fmt.Errorf(format, a...))
+ }
+
+ if !validRepoPath(path) {
+ fail("%q is not a usable repository path", path)
+ }
+ if r.Visibility != visPublic && r.Visibility != visPrivate {
+ fail("%s: visibility %q is neither public nor private", path, r.Visibility)
+ }
+ for _, knob := range []struct{ name, value string }{{"issues", r.Issues}, {"wiki", r.Wiki}} {
+ switch knob.value {
+ case featDisabled, featMembers:
+ case featUsers:
+ if r.Visibility == visPrivate {
+ fail("%s: %s is users on a private repository, so an account that cannot read it could write to it", path, knob.name)
+ }
+ default:
+ fail("%s: %s %q is not disabled, members, or users", path, knob.name, knob.value)
+ }
+ }
+ for name, level := range r.Grants {
+ if !accountNameRE.MatchString(name) {
+ fail("%s: %q is not a usable account name", path, name)
+ }
+ if level != grantRead && level != grantWrite && level != grantAdmin {
+ fail("%s: grant %q for %s is not read, write, or admin", path, level, name)
+ }
+ }
+ // Every label is a checkbox on every issue form.
+ if len(r.Labels) > maxLabels {
+ fail("%s: %d labels, and %d is all a repository may have", path, len(r.Labels), maxLabels)
+ }
+ for _, label := range r.Labels {
+ if !validLabel(label) {
+ fail("%s: label %q is empty, too long, or has a line break in it", path, label)
+ }
+ }
+ return errs
+}
+
+const maxLabels = 64
+
+func validLabel(label string) bool {
+ return label != "" && len(label) <= 32 && !strings.ContainsAny(label, "\r\n")
+}
diff --git a/perm_test.go b/perm_test.go
new file mode 100644
index 0000000..dc874fe
--- /dev/null
+++ b/perm_test.go
@@ -0,0 +1,132 @@
+package main
+
+import (
+ "net/http"
+ "testing"
+)
+
+func TestValidRepoPath(t *testing.T) {
+ ok := []string{
+ "dns", "infra/dns", "a", "x-1.2_3", "infra/x.y", "static",
+ // cgit's routes are only taken one component deep, because that is the
+ // only place they can be reached.
+ "log", "tree", "about", "summary",
+ }
+ bad := []string{
+ "", ".", "..", "../etc", "infra/../etc", "a/b/c", "/dns", "dns/",
+ "Infra/dns", "in fra/dns", "dns.git", "dns.issues", "dns.wiki",
+ "admin", "settings", "login", "-dns",
+ // The forge's own names are taken in both components: /infra/settings
+ // has to be the settings of the repository infra, so a repository called
+ // settings inside the section infra would make that page unreachable.
+ "infra/settings", "infra/issues", "infra/wiki", "issues", "wiki",
+ // And cgit's, in the second component, for the same reason.
+ "infra/log", "infra/tree", "infra/about", "infra/summary",
+ "infra/atom", "infra/plain", "infra/snapshot",
+ }
+ for _, p := range ok {
+ if !validRepoPath(p) {
+ t.Errorf("%q was refused", p)
+ }
+ }
+ for _, p := range bad {
+ if validRepoPath(p) {
+ t.Errorf("%q was allowed", p)
+ }
+ }
+}
+
+func TestPermissions(t *testing.T) {
+ anon := Viewer{}
+ stranger := Viewer{Name: "eve"}
+ reader := Viewer{Name: "carol"}
+ writer := Viewer{Name: "bob"}
+ owner := Viewer{Name: "dave"}
+ root := Viewer{Name: "alice", Admin: true}
+
+ grants := map[string]string{"carol": grantRead, "bob": grantWrite, "dave": grantAdmin}
+
+ private := Repo{Visibility: visPrivate, Issues: featMembers, Wiki: featDisabled, Grants: grants}
+ public := Repo{Visibility: visPublic, Issues: featUsers, Wiki: featMembers, Grants: grants}
+
+ cases := []struct {
+ what string
+ v Viewer
+ repo Repo
+ read, write, admin, issue, wiki bool
+ }{
+ {"anon on private", anon, private, false, false, false, false, false},
+ {"stranger on private", stranger, private, false, false, false, false, false},
+ {"reader on private", reader, private, true, false, false, true, false},
+ {"writer on private", writer, private, true, true, false, true, false},
+ {"owner on private", owner, private, true, true, true, true, false},
+ {"global admin on private", root, private, true, true, true, true, false},
+
+ {"anon on public", anon, public, true, false, false, false, false},
+ {"stranger on public", stranger, public, true, false, false, true, false},
+ {"reader on public", reader, public, true, false, false, true, true},
+ {"writer on public", writer, public, true, true, false, true, true},
+ {"global admin on public", root, public, true, true, true, true, true},
+ }
+ for _, c := range cases {
+ if got := c.v.CanRead(c.repo); got != c.read {
+ t.Errorf("%s: read %v", c.what, got)
+ }
+ if got := c.v.CanWrite(c.repo); got != c.write {
+ t.Errorf("%s: write %v", c.what, got)
+ }
+ if got := c.v.CanAdmin(c.repo); got != c.admin {
+ t.Errorf("%s: admin %v", c.what, got)
+ }
+ if got := c.v.CanIssues(c.repo); got != c.issue {
+ t.Errorf("%s: issues %v", c.what, got)
+ }
+ if got := c.v.CanWiki(c.repo); got != c.wiki {
+ t.Errorf("%s: wiki %v", c.what, got)
+ }
+ }
+
+ off := Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featDisabled, Grants: grants}
+ for _, v := range []Viewer{anon, reader, writer, owner, root} {
+ if v.CanIssues(off) || v.CanWiki(off) {
+ t.Errorf("%s writes to a disabled feature", v.Name)
+ }
+ }
+}
+
+func TestUsersOnPrivateIsAnError(t *testing.T) {
+ repo := Repo{Visibility: visPrivate, Issues: featUsers, Wiki: featDisabled}
+ if errs := repo.validate("infra/dns"); len(errs) != 1 {
+ t.Fatalf("expected one error, got %v", errs)
+ }
+ repo.Visibility = visPublic
+ if errs := repo.validate("infra/dns"); len(errs) != 0 {
+ t.Fatalf("expected none, got %v", errs)
+ }
+}
+
+// Turning a feature off leaves its repository on disk, and the answer has to
+// be the same at both doors.
+func TestDisabledFeatureIsDisabledEverywhere(t *testing.T) {
+ f := testForge(t)
+ addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featUsers})
+ err := f.store.UpdateRepos("off", f.store.forgeAuthor(), func(m map[string]Repo) error {
+ repo := m["notes"]
+ repo.Issues, repo.Wiki = featDisabled, featDisabled
+ m["notes"] = repo
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ h := f.routes()
+ for _, path := range []string{
+ "/notes.issues/", "/notes.wiki/",
+ "/notes.issues.git/info/refs?service=git-upload-pack",
+ "/notes.wiki.git/info/refs?service=git-upload-pack",
+ } {
+ if code := get(h, path, nil).Code; code != http.StatusNotFound {
+ t.Errorf("%s answered %d for a disabled feature", path, code)
+ }
+ }
+}
diff --git a/static/forge.css b/static/forge.css
new file mode 100644
index 0000000..f03fa5a
--- /dev/null
+++ b/static/forge.css
@@ -0,0 +1,135 @@
+/*
+ * cgit's own stylesheet does the work; this is the rest. The forge's pages
+ * wrap themselves in div#cgit and use cgit's markup, so both kinds of page are
+ * the same page. Nothing here may carry meaning of its own: every page still
+ * has to work with all of it missing.
+ */
+
+/* Readability. cgit's 10pt suits a dense table; prose wants more. */
+
+div#cgit { color: #222; }
+div#cgit .dim { color: #666; }
+div#cgit article { font-size: 11pt; line-height: 1.5; max-width: 46em; }
+
+/* A row of alternating shade, which is what makes a long list scannable. */
+div#cgit table.list tr:nth-child(even) { background: #f6f6f6; }
+div#cgit table.list tr.nohover { background: #fff; }
+
+/* cgit sizes the header cell and not the image in it, so a large upload drags
+ * the header, and the page under it, out to its own width. Both axes, or a
+ * tall one pushes the page down instead. */
+div#cgit table#header td.logo img { max-width: 96px; max-height: 96px; }
+
+/* Forms. cgit only styles the two in its own header. */
+
+div#cgit label { display: block; color: #666; margin-bottom: 0.2em; }
+div#cgit label.inline, div#cgit label.choice { display: inline-block; color: #222; }
+div#cgit label.choice { display: block; margin: 0.3em 0; }
+div#cgit label.inline { margin-right: 1em; }
+div#cgit fieldset { border: solid 1px #ccc; margin: 1em 0; padding: 0.5em 1em; }
+div#cgit legend { color: #666; padding: 0 0.3em; }
+div#cgit form p { margin: 0.9em 0; }
+
+div#cgit input, div#cgit textarea, div#cgit select, div#cgit button {
+ font: inherit;
+ color: #222;
+ border: solid 1px #bbb;
+ padding: 0.3em;
+}
+
+div#cgit input[type=checkbox], div#cgit input[type=radio] { width: auto; }
+/* cgit's own header and tab controls size themselves, and the forge's must not
+ * stretch them across the row. */
+div#cgit table#header td.form input, div#cgit table#header td.form select,
+div#cgit table.tabs td.form input, div#cgit table.tabs td.form select {
+ width: auto;
+ max-width: none;
+ font-size: 90%;
+}
+/* A control inside a list row sizes itself, or it stretches the column. */
+div#cgit table.list td input { width: auto; }
+div#cgit input, div#cgit textarea { width: 100%; max-width: 34em; }
+div#cgit textarea { max-width: none; min-height: 12em; font-family: monospace; }
+div#cgit button { background: #eee; padding: 0.3em 1em; cursor: pointer; }
+div#cgit button:hover { background: #ddd; }
+
+/* Sign out is a form because it changes something. Inline, or the button lands
+ * on its own line away from the name it belongs to. */
+div#cgit table#header td.form form { display: inline; }
+div#cgit table#header td.form a { padding: 0 0.2em; }
+
+div#cgit button.link {
+ background: none;
+ border: none;
+ padding: 0;
+ color: blue;
+ cursor: pointer;
+}
+div#cgit button.link:hover { text-decoration: underline; }
+
+/* A row of controls that belong together. */
+div#cgit .bar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1em;
+ align-items: center;
+ margin: 1em 0;
+ padding: 0.5em 0;
+ border-top: solid 1px #eee;
+ border-bottom: solid 1px #eee;
+}
+div#cgit .bar form { display: flex; gap: 0.5em; align-items: center; margin: 0; }
+div#cgit .bar input { width: auto; }
+div#cgit .bar span { display: flex; gap: 0.8em; }
+
+/* One person's say in a thread. */
+div#cgit .comment { border: solid 1px #ddd; margin: 1em 0; }
+div#cgit .comment .byline { background: #f6f6f6; border-bottom: solid 1px #ddd; padding: 0.3em 0.6em; }
+div#cgit .comment article { margin: 0; padding: 0.2em 0.8em 0.8em 0.8em; }
+div#cgit img.avatar { vertical-align: middle; border: solid 1px #ccc; }
+
+div#cgit .label {
+ background: #eee;
+ border: solid 1px #ccc;
+ padding: 0 0.4em;
+ margin-right: 0.3em;
+ font-size: 90%;
+}
+
+div#cgit .state-open { color: #007700; font-weight: bold; }
+div#cgit .state-closed { color: #770000; font-weight: bold; }
+div#cgit .notice { color: #007700; font-weight: bold; margin: 1em 0; }
+
+/* Markdown, so that a comment reads as one. */
+div#cgit article h1, div#cgit article h2, div#cgit article h3 { font-size: 120%; margin: 1em 0 0.4em 0; }
+div#cgit article p, div#cgit article ul, div#cgit article ol { margin: 0.6em 0; }
+div#cgit article code { background: #f0f0f0; padding: 0 0.2em; font-size: 95%; }
+div#cgit article pre { background: #f6f6f6; border: solid 1px #e0e0e0; padding: 0.6em; overflow-x: auto; }
+div#cgit article pre code { background: none; padding: 0; }
+div#cgit article blockquote { border-left: solid 3px #ccc; margin: 0.6em 0; padding-left: 0.8em; color: #555; }
+
+/* What cannot be undone is marked, so that it is never a slip. */
+
+div#cgit .danger {
+ border: solid 1px #770000;
+ border-left-width: 4px;
+ background: #fff6f6;
+ padding: 0.5em 1em;
+ margin: 2em 0;
+}
+
+div#cgit .danger h2, div#cgit .danger h3, div#cgit .danger h4 { color: #770000; }
+div#cgit .danger button { background: #ffdddd; border-color: #770000; }
+
+/* highlight's classes, which cgit's stylesheet has nothing to say about. */
+
+.hl.com, .hl.slc { color: #767676; font-style: italic; }
+.hl.sng, .hl.str { color: #a11; }
+.hl.num, .hl.esc { color: #7a5600; }
+.hl.ppc, .hl.pps, .hl.ipl { color: #616100; }
+.hl.kwa { color: #000; font-weight: bold; }
+.hl.kwb { color: #0057ae; }
+.hl.kwc { color: #000; }
+.hl.kwd, .hl.kwe, .hl.kwf { color: #010181; }
+.hl.lin { color: #555; }
+.hl.err, .hl.erm { color: #c00; font-weight: bold; }
diff --git a/store.go b/store.go
new file mode 100644
index 0000000..7ba9ac7
--- /dev/null
+++ b/store.go
@@ -0,0 +1,761 @@
+package main
+
+import (
+ "bytes"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/fs"
+ "log"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "sync"
+ "syscall"
+)
+
+// Store is the data directory, which holds four entries and nothing else:
+//
+// config/ git repo with a working tree: forge.json, users.json, repos.json
+// cookie.key generated secret, deliberately outside the config repo
+// mailq/ spooled outbound mail, transient
+// repos/ bare git repos, holding nothing the forge wrote
+type Store struct {
+ cfg *Config
+ dir string
+ configDir string
+ reposDir string
+ mailqDir string
+ cookieKey []byte
+
+ // mu guards the tables, the git commands, and the directories: one lock,
+ // so there is no order to get wrong. Tables are replaced whole and handed
+ // out as copies.
+ mu sync.Mutex
+ users map[string]User
+ repos map[string]Repo // repos.json
+ dirs map[string]bool // what the tree actually holds, from the startup scan
+}
+
+// RepoEntry is one repository as the tree has it. Registered is false for a
+// directory with no entry in repos.json.
+type RepoEntry struct {
+ Path string
+ Repo Repo
+ Registered bool
+}
+
+// Author is a git identity in the config repo's audit log.
+type Author struct{ Name, Email string }
+
+// User is one record in users.json, for either origin.
+type User struct {
+ Origin string `json:"origin"`
+ Iss string `json:"iss,omitempty"` // external accounts: identity is
+ Sub string `json:"sub,omitempty"` // iss plus sub, and nothing else
+ Admin bool `json:"admin"`
+ Email string `json:"email,omitempty"`
+ EmailConfirmed bool `json:"email_confirmed"`
+ Notify bool `json:"notify"`
+ Epoch int `json:"epoch"`
+ Password string `json:"password,omitempty"` // argon2id, local accounts
+ TOTP *TOTP `json:"totp,omitempty"`
+ Tokens []Token `json:"tokens,omitempty"`
+}
+
+// TOTP is the second factor. Recovery codes are kept as digests, like push
+// tokens. Used is the last time step a code was accepted at, so a code cannot
+// be replayed within its window.
+type TOTP struct {
+ Secret string `json:"secret"`
+ Recovery []string `json:"recovery"`
+ Used int64 `json:"used,omitempty"`
+}
+
+// Token is one scoped push token. Dates are days: an instant would mean a
+// write per request.
+type Token struct {
+ ID string `json:"id"`
+ Repo string `json:"repo"`
+ Hash string `json:"hash"`
+ Created string `json:"created,omitempty"`
+ Used string `json:"used,omitempty"`
+}
+
+// The admin flag does nothing until the account holds a second factor.
+func (u User) adminInEffect() bool { return u.Admin && u.TOTP != nil }
+
+func (u User) clone() User {
+ u.Tokens = append([]Token(nil), u.Tokens...)
+ if u.TOTP != nil {
+ totp := *u.TOTP
+ totp.Recovery = append([]string(nil), totp.Recovery...)
+ u.TOTP = &totp
+ }
+ return u
+}
+
+const (
+ originLocal = "local"
+ originOIDC = "oidc"
+)
+
+// An account name is what grants point at, and is immutable. An external one
+// is prefixed by its provider's key, as in sso:carol.
+var accountNameRE = regexp.MustCompile(`^([a-z][a-z0-9]{0,15}:)?[a-z0-9][a-z0-9._-]{0,31}$`)
+
+// A local name has no provider prefix, so it cannot collide with a minted one.
+var localNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,31}$`)
+
+// accountName forgives the shift key: names are lowercase by construction.
+func accountName(typed string) string { return strings.ToLower(strings.TrimSpace(typed)) }
+
+// lockDatadir refuses a second process, which would lose writes. On the
+// directory itself, so the kernel drops it however the process exits.
+func lockDatadir(dir string) (func(), error) {
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ return nil, fmt.Errorf("datadir %s: %v", dir, err)
+ }
+ f, err := os.Open(dir)
+ if err != nil {
+ return nil, fmt.Errorf("datadir %s: %v", dir, err)
+ }
+ if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
+ f.Close()
+ return nil, fmt.Errorf("datadir %s is already being served by another git-forge: %v", dir, err)
+ }
+ return func() { f.Close() }, nil
+}
+
+// openStore creates whatever is missing, idempotently, and reads the config.
+func openStore(dir string) (*Store, error) {
+ s := &Store{
+ dir: dir,
+ configDir: filepath.Join(dir, "config"),
+ reposDir: filepath.Join(dir, "repos"),
+ mailqDir: filepath.Join(dir, "mailq"),
+ }
+ // 0700 throughout: the whole datadir is credential material.
+ for _, d := range []string{s.dir, s.configDir, s.reposDir, s.mailqDir} {
+ if err := os.MkdirAll(d, 0700); err != nil {
+ return nil, err
+ }
+ }
+
+ // git is only the audit log; one that will not initialise must not stop
+ // the forge.
+ if err := s.ensureConfigRepo(); err != nil {
+ log.Printf("config: no audit log: %v", err)
+ }
+
+ forge, err := marshalJSON(defaultConfig())
+ if err != nil {
+ return nil, err
+ }
+ defaults := []struct {
+ name string
+ data []byte
+ }{
+ {"forge.json", forge},
+ {"users.json", []byte("{}\n")},
+ {"repos.json", []byte("{}\n")},
+ }
+ var made []string
+ for _, d := range defaults {
+ created, err := ensureFile(s.File(d.name), d.data)
+ if err != nil {
+ return nil, err
+ }
+ if created {
+ log.Printf("config: created %s", d.name)
+ made = append(made, d.name)
+ }
+ }
+
+ // Before the first commit: the committer's address needs the host.
+ cfg, err := loadConfig(s.File("forge.json"))
+ if err != nil {
+ return nil, fmt.Errorf("%s: %v", s.File("forge.json"), err)
+ }
+ s.cfg = cfg
+ if len(made) > 0 {
+ s.Commit("config: add "+strings.Join(made, ", "), s.forgeAuthor())
+ }
+
+ if err := s.loadUsers(); err != nil {
+ return nil, err
+ }
+ if err := s.loadRepos(); err != nil {
+ return nil, err
+ }
+ if err := s.scanRepos(); err != nil {
+ return nil, err
+ }
+
+ key, err := cookieKey(filepath.Join(dir, "cookie.key"))
+ if err != nil {
+ return nil, err
+ }
+ s.cookieKey = key
+ return s, nil
+}
+
+func (s *Store) loadUsers() error {
+ data, err := os.ReadFile(s.File("users.json"))
+ if err != nil {
+ return err
+ }
+ users := map[string]User{}
+ dec := json.NewDecoder(bytes.NewReader(data))
+ dec.DisallowUnknownFields()
+ if err := dec.Decode(&users); err != nil {
+ return fmt.Errorf("users.json: %v", err)
+ }
+ for name, u := range users {
+ if !accountNameRE.MatchString(name) {
+ return fmt.Errorf("users.json: %q is not a usable account name", name)
+ }
+ if u.Origin != originLocal && u.Origin != originOIDC {
+ return fmt.Errorf("users.json: %s: origin %q is neither local nor oidc", name, u.Origin)
+ }
+ }
+ s.users = users
+ return nil
+}
+
+func (s *Store) User(name string) (User, bool) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ u, ok := s.users[name]
+ return u.clone(), ok
+}
+
+func (s *Store) Users() map[string]User {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ out := make(map[string]User, len(s.users))
+ for name, u := range s.users {
+ out[name] = u.clone()
+ }
+ return out
+}
+
+// UpdateUsers edits under the lock, writes, and commits. fn is handed a copy;
+// returning an error changes nothing.
+func (s *Store) UpdateUsers(subject string, author Author, fn func(map[string]User) error) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if err := s.writeUsersLocked(fn); err != nil {
+ return err
+ }
+ s.Commit(subject, author)
+ return nil
+}
+
+// UpdateUser edits one account.
+func (s *Store) UpdateUser(name, subject string, author Author, fn func(*User) error) error {
+ return s.UpdateUsers(subject, author, editUser(name, fn))
+}
+
+// editUser leaves an account that is gone alone: writing it back would
+// recreate it with an origin the next start refuses.
+func editUser(name string, fn func(*User) error) func(map[string]User) error {
+ return func(m map[string]User) error {
+ acct, ok := m[name]
+ if !ok {
+ return nil
+ }
+ if err := fn(&acct); err != nil {
+ return err
+ }
+ m[name] = acct
+ return nil
+ }
+}
+
+// WriteConfig rewrites forge.json. Read without the environment on top, so an
+// override never becomes the file's own value; the name is the only edited
+// field with a rule.
+func (s *Store) WriteConfig(author Author, apply func(*Config)) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ cfg, err := readConfig(s.File("forge.json"))
+ if err != nil {
+ return err
+ }
+ apply(cfg)
+ if !validSiteName(cfg.SiteName) {
+ return errSiteName
+ }
+ data, err := marshalJSON(cfg)
+ if err != nil {
+ return err
+ }
+ if err := writeFile(s.File("forge.json"), data, 0600); err != nil {
+ return err
+ }
+ s.Commit("config: update the site", author)
+ return nil
+}
+
+// touchUser writes without committing: telemetry would bury the audit log. The
+// next commit sweeps it in.
+func (s *Store) touchUser(name string, fn func(*User) error) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.writeUsersLocked(editUser(name, fn))
+}
+
+func (s *Store) writeUsersLocked(fn func(map[string]User) error) error {
+ next := make(map[string]User, len(s.users))
+ for name, u := range s.users {
+ next[name] = u.clone()
+ }
+ if err := fn(next); err != nil {
+ return err
+ }
+ data, err := marshalJSON(next)
+ if err != nil {
+ return err
+ }
+ if err := writeFile(s.File("users.json"), data, 0600); err != nil {
+ return err
+ }
+ s.users = next
+ return nil
+}
+
+func (s *Store) loadRepos() error {
+ data, err := os.ReadFile(s.File("repos.json"))
+ if err != nil {
+ return err
+ }
+ repos := map[string]Repo{}
+ dec := json.NewDecoder(bytes.NewReader(data))
+ dec.DisallowUnknownFields()
+ if err := dec.Decode(&repos); err != nil {
+ return fmt.Errorf("repos.json: %v", err)
+ }
+ var errs []error
+ for path, repo := range repos {
+ errs = append(errs, repo.validate(path)...)
+ }
+ if err := errors.Join(errs...); err != nil {
+ return fmt.Errorf("repos.json:\n%v", err)
+ }
+ s.repos = repos
+ return nil
+}
+
+// scanRepos reconciles repos.json against the tree: a directory ending in .git
+// is a repository, anything else is a section one level deep. Disagreements
+// are logged, never fatal. Every name is quoted: this is the one place a name
+// has been through no rule, and a newline in one would write the next log
+// line.
+func (s *Store) scanRepos() error {
+ dirs := map[string]bool{}
+ top, err := os.ReadDir(s.reposDir)
+ if err != nil {
+ return err
+ }
+ for _, entry := range top {
+ if !entry.IsDir() {
+ log.Printf("repos: %q is not a directory, ignoring it", entry.Name())
+ continue
+ }
+ if siblingDir(entry.Name()) {
+ continue
+ }
+ if path, ok := strings.CutSuffix(entry.Name(), ".git"); ok {
+ dirs[path] = true
+ continue
+ }
+ section := entry.Name()
+ inner, err := os.ReadDir(filepath.Join(s.reposDir, section))
+ if err != nil {
+ log.Printf("repos: %q cannot be read, ignoring it: %v", section, err)
+ continue
+ }
+ for _, entry := range inner {
+ if siblingDir(entry.Name()) {
+ continue
+ }
+ path, ok := strings.CutSuffix(entry.Name(), ".git")
+ if !entry.IsDir() || !ok {
+ log.Printf("repos: %q is neither a repository nor a section, ignoring it", section+"/"+entry.Name())
+ continue
+ }
+ dirs[section+"/"+path] = true
+ }
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for path := range dirs {
+ if !validRepoPath(path) {
+ log.Printf("repos: %q is not a usable repository path, so it is not served at all", path+".git")
+ delete(dirs, path)
+ continue
+ }
+ if _, ok := s.repos[path]; !ok {
+ log.Printf("repos: %q has no entry in repos.json, serving it private with issues and wiki off until an admin adopts or removes it", path)
+ }
+ }
+ for path := range s.repos {
+ if !dirs[path] {
+ log.Printf("repos: %q has an entry in repos.json but no directory, ignoring it", path)
+ }
+ }
+ s.dirs = dirs
+ return nil
+}
+
+// siblingDir is a repository's issues or wiki directory, not a section.
+func siblingDir(name string) bool {
+ path, ok := strings.CutSuffix(name, ".git")
+ return ok && (strings.HasSuffix(path, ".issues") || strings.HasSuffix(path, ".wiki"))
+}
+
+// The tree decides existence; a directory with no entry is served as an
+// orphan.
+func (s *Store) Repo(path string) (Repo, bool) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if !s.dirs[path] {
+ return Repo{}, false
+ }
+ if repo, ok := s.repos[path]; ok {
+ return repo.clone(), true
+ }
+ return orphanRepo(), true
+}
+
+// Repos is sorted by path.
+func (s *Store) Repos() []RepoEntry {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ out := make([]RepoEntry, 0, len(s.dirs))
+ for path := range s.dirs {
+ repo, ok := s.repos[path]
+ if !ok {
+ repo = orphanRepo()
+ }
+ out = append(out, RepoEntry{Path: path, Repo: repo.clone(), Registered: ok})
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
+ return out
+}
+
+// splitRepo resolves the repository a URL names, which of the three it is, and
+// the rest. Two components are tried before one, so a section wins over a root
+// repository of the same name.
+func (s *Store) splitRepo(p string) (repo, kind, rest string, ok bool) {
+ p = strings.TrimPrefix(p, "/")
+ parts := strings.SplitN(p, "/", 3)
+ for n := min(2, len(parts)); n >= 1; n-- {
+ head := strings.Join(parts[:n], "/")
+ base, kind := head, ""
+ if cut, found := strings.CutSuffix(head, ".issues"); found {
+ base, kind = cut, "issues"
+ } else if cut, found := strings.CutSuffix(head, ".wiki"); found {
+ base, kind = cut, "wiki"
+ }
+ if _, exists := s.Repo(base); !exists {
+ continue
+ }
+ return base, kind, strings.TrimPrefix(strings.TrimPrefix(p, head), "/"), true
+ }
+ return "", "", "", false
+}
+
+// splitGit resolves a /{repo}.git/... path without asking whether the
+// repository exists: that is decided where the answer can be a challenge.
+// kind is "" for the repository, or issues or wiki for a sibling.
+func splitGit(p string) (repo, kind, rest string, ok bool) {
+ p = strings.TrimPrefix(p, "/")
+ parts := strings.SplitN(p, "/", 3)
+ for n := min(2, len(parts)); n >= 1; n-- {
+ dir := strings.Join(parts[:n], "/")
+ base, isGit := strings.CutSuffix(dir, ".git")
+ if !isGit {
+ continue
+ }
+ if cut, ok := strings.CutSuffix(base, ".issues"); ok {
+ base, kind = cut, "issues"
+ } else if cut, ok := strings.CutSuffix(base, ".wiki"); ok {
+ base, kind = cut, "wiki"
+ }
+ return base, kind, strings.TrimPrefix(strings.TrimPrefix(p, dir), "/"), true
+ }
+ return "", "", "", false
+}
+
+// UpdateRepos edits repos.json under the same rules as UpdateUsers.
+func (s *Store) UpdateRepos(subject string, author Author, fn func(map[string]Repo) error) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.updateReposLocked(subject, author, fn)
+}
+
+func (s *Store) updateReposLocked(subject string, author Author, fn func(map[string]Repo) error) error {
+ next := make(map[string]Repo, len(s.repos))
+ for path, repo := range s.repos {
+ next[path] = repo.clone()
+ }
+ if err := fn(next); err != nil {
+ return err
+ }
+ data, err := marshalJSON(next)
+ if err != nil {
+ return err
+ }
+ if err := writeFile(s.File("repos.json"), data, 0600); err != nil {
+ return err
+ }
+ s.repos = next
+ s.Commit(subject, author)
+ return nil
+}
+
+// CreateRepo initialises the repository and a sibling per enabled feature, and
+// records it.
+func (s *Store) CreateRepo(path string, repo Repo, author Author) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if s.dirs[path] {
+ return userError(path + " already exists")
+ }
+ if errs := repo.validate(path); len(errs) > 0 {
+ return userError(errors.Join(errs...).Error())
+ }
+ for _, dir := range s.repoDirs(path, repo) {
+ if err := gitInit(dir, true); err != nil {
+ return err
+ }
+ }
+ err := s.updateReposLocked("repos: create "+path, author, func(m map[string]Repo) error {
+ m[path] = repo
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+ s.dirs[path] = true
+ return nil
+}
+
+// EnsureSiblings creates what a knob turned on. One turned off keeps its
+// repository; the knob governs access.
+func (s *Store) EnsureSiblings(path string, repo Repo) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for _, dir := range s.repoDirs(path, repo) {
+ if _, err := os.Stat(dir); err == nil {
+ continue
+ }
+ if err := gitInit(dir, true); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// DeleteRepo removes all three directories, and an emptied section.
+func (s *Store) DeleteRepo(path string, author Author) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ for _, suffix := range []string{".git", ".issues.git", ".wiki.git"} {
+ if err := os.RemoveAll(filepath.Join(s.reposDir, path+suffix)); err != nil {
+ return err
+ }
+ }
+ if section, _, ok := strings.Cut(path, "/"); ok {
+ dir := filepath.Join(s.reposDir, section)
+ if entries, err := os.ReadDir(dir); err == nil && len(entries) == 0 {
+ if err := os.Remove(dir); err != nil {
+ return err
+ }
+ }
+ }
+ delete(s.dirs, path)
+ return s.updateReposLocked("repos: delete "+path, author, func(m map[string]Repo) error {
+ delete(m, path)
+ return nil
+ })
+}
+
+// repoDirs is what a repository has on disk, given its knobs.
+func (s *Store) repoDirs(path string, repo Repo) []string {
+ dirs := []string{filepath.Join(s.reposDir, path+".git")}
+ if repo.Issues != featDisabled {
+ dirs = append(dirs, filepath.Join(s.reposDir, path+".issues.git"))
+ }
+ if repo.Wiki != featDisabled {
+ dirs = append(dirs, filepath.Join(s.reposDir, path+".wiki.git"))
+ }
+ return dirs
+}
+
+// author is the identity a change is recorded under; git wants an address that
+// parses.
+func (s *Store) author(name string, u User) Author {
+ if u.Email != "" {
+ return Author{Name: name, Email: u.Email}
+ }
+ return Author{Name: name, Email: name + "@" + s.cfg.host}
+}
+
+func (s *Store) File(name string) string { return filepath.Join(s.configDir, name) }
+
+func (s *Store) ensureConfigRepo() error {
+ _, err := os.Stat(filepath.Join(s.configDir, ".git"))
+ if err == nil {
+ return nil
+ }
+ if !errors.Is(err, fs.ErrNotExist) {
+ return err
+ }
+ return gitInit(s.configDir, false)
+}
+
+// Commit records a change in the config repo. Failure is logged, never fatal:
+// history must not take the forge down. add -A, so a manual edit is picked up
+// rather than clobbered.
+func (s *Store) Commit(subject string, author Author) {
+ if err := s.commit(subject, author); err != nil {
+ log.Printf("config: %q not committed, the working tree is still authoritative: %v", subject, err)
+ }
+}
+
+func (s *Store) commit(subject string, author Author) error {
+ if _, err := git(s.configDir, nil, "add", "-A"); err != nil {
+ return err
+ }
+ out, err := git(s.configDir, nil, "status", "--porcelain")
+ if err != nil {
+ return err
+ }
+ if len(bytes.TrimSpace(out)) == 0 {
+ return nil
+ }
+ committer := s.forgeAuthor()
+ env := []string{
+ "GIT_AUTHOR_NAME=" + author.Name,
+ "GIT_AUTHOR_EMAIL=" + author.Email,
+ "GIT_COMMITTER_NAME=" + committer.Name,
+ "GIT_COMMITTER_EMAIL=" + committer.Email,
+ }
+ _, err = git(s.configDir, env, "commit", "--quiet", "-m", subject)
+ return err
+}
+
+// forgeAuthor is the committer, and the author when no account is acting.
+func (s *Store) forgeAuthor() Author {
+ return Author{Name: "git-forge", Email: "git-forge@" + s.cfg.host}
+}
+
+// marshalJSON is encoding/json without HTML escaping: these files are read
+// with cat.
+func marshalJSON(v any) ([]byte, error) {
+ var buf bytes.Buffer
+ enc := json.NewEncoder(&buf)
+ enc.SetEscapeHTML(false)
+ enc.SetIndent("", " ")
+ if err := enc.Encode(v); err != nil { // Encode ends the file with a newline
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
+
+// ensureFile reports whether it wrote, so only a first start commits.
+func ensureFile(path string, data []byte) (bool, error) {
+ _, err := os.Stat(path)
+ if err == nil {
+ return false, nil
+ }
+ if !errors.Is(err, fs.ErrNotExist) {
+ return false, err
+ }
+ return true, writeFile(path, data, 0600)
+}
+
+// writeFile writes beside the target and renames, so nothing sees a partial
+// file.
+func writeFile(path string, data []byte, perm os.FileMode) error {
+ dir := filepath.Dir(path)
+ f, err := os.CreateTemp(dir, filepath.Base(path)+".tmp")
+ if err != nil {
+ return err
+ }
+ tmp := f.Name()
+ defer os.Remove(tmp)
+
+ if _, err := f.Write(data); err != nil {
+ f.Close()
+ return err
+ }
+ if err := f.Chmod(perm); err != nil {
+ f.Close()
+ return err
+ }
+ if err := f.Sync(); err != nil {
+ f.Close()
+ return err
+ }
+ if err := f.Close(); err != nil {
+ return err
+ }
+ if err := os.Rename(tmp, path); err != nil {
+ return err
+ }
+ return syncDir(dir) // the rename itself has to be durable
+}
+
+func syncDir(dir string) error {
+ f, err := os.Open(dir)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ return f.Sync()
+}
+
+// cookieKey signs every session cookie: GIT_FORGE_COOKIE_KEY, else generated
+// into {datadir}/cookie.key, outside the config repo.
+func cookieKey(path string) ([]byte, error) {
+ if v, ok := os.LookupEnv("GIT_FORGE_COOKIE_KEY"); ok {
+ if len(v) < 32 {
+ return nil, errors.New("GIT_FORGE_COOKIE_KEY: at least 32 characters required")
+ }
+ return []byte(v), nil
+ }
+
+ data, err := os.ReadFile(path)
+ if errors.Is(err, fs.ErrNotExist) {
+ key := make([]byte, 32)
+ if _, err := rand.Read(key); err != nil {
+ return nil, err
+ }
+ if err := writeFile(path, []byte(hex.EncodeToString(key)+"\n"), 0600); err != nil {
+ return nil, err
+ }
+ log.Printf("generated %s", path)
+ return key, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ key, err := hex.DecodeString(strings.TrimSpace(string(data)))
+ if err != nil || len(key) < 32 {
+ return nil, fmt.Errorf("%s: expected 64 hex characters; deleting it generates a new key, which signs everybody out", path)
+ }
+ return key, nil
+}
diff --git a/store_test.go b/store_test.go
new file mode 100644
index 0000000..9ae8c58
--- /dev/null
+++ b/store_test.go
@@ -0,0 +1,195 @@
+package main
+
+import (
+ "bytes"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestSplitRepo(t *testing.T) {
+ f := testForge(t)
+ author := f.store.forgeAuthor()
+ for _, path := range []string{"notes", "infra", "infra/dns"} {
+ repo := Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featDisabled, Labels: []string{}, Grants: map[string]string{}}
+ if err := f.store.CreateRepo(path, repo, author); err != nil {
+ t.Fatal(err)
+ }
+ }
+ cases := []struct{ url, repo, kind, rest string }{
+ {"/notes", "notes", "", ""},
+ {"/notes/", "notes", "", ""},
+ {"/notes/settings", "notes", "", "settings"},
+ {"/infra/dns", "infra/dns", "", ""}, // the section wins over the repo
+ {"/infra/dns/log/", "infra/dns", "", "log/"}, // and keeps winning deeper
+ {"/infra/other", "infra", "", "other"},
+ {"/infra/dns.issues/log/", "infra/dns", "issues", "log/"},
+ {"/notes.wiki/tree/", "notes", "wiki", "tree/"},
+ {"/nothing", "", "", ""},
+ }
+ for _, c := range cases {
+ repo, kind, rest, ok := f.store.splitRepo(c.url)
+ if repo != c.repo || kind != c.kind || rest != c.rest || ok != (c.repo != "") {
+ t.Errorf("%s resolved to %q %q %q %v", c.url, repo, kind, rest, ok)
+ }
+ }
+}
+
+// One process per datadir is a correctness rule: two lose updates to
+// users.json with nothing in any log to say so.
+func TestDatadirLockIsExclusive(t *testing.T) {
+ dir := t.TempDir()
+ unlock, err := lockDatadir(dir)
+ if err != nil {
+ t.Fatalf("the first lock was refused: %v", err)
+ }
+ if _, err := lockDatadir(dir); err == nil {
+ t.Error("a second process took a datadir that was already being served")
+ }
+ if _, err := lockDatadir(t.TempDir()); err != nil {
+ t.Errorf("a different datadir was refused: %v", err)
+ }
+ unlock()
+ if unlock, err = lockDatadir(dir); err != nil {
+ t.Errorf("the lock outlived the process that held it: %v", err)
+ }
+ unlock()
+}
+
+// A directory whose name could never be a repository path must not be
+// servable, and must not stop the forge starting either.
+func TestStrayDirectoryIsSkipped(t *testing.T) {
+ dir := t.TempDir()
+ s, err := openStore(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, name := range []string{"Uppercase.git", "with space.git", "settings.git"} {
+ if err := os.MkdirAll(filepath.Join(s.reposDir, name), 0700); err != nil {
+ t.Fatal(err)
+ }
+ }
+ s, err = openStore(dir)
+ if err != nil {
+ t.Fatalf("a stray directory stopped the forge starting: %v", err)
+ }
+ if repos := s.Repos(); len(repos) != 0 {
+ t.Errorf("a directory that is not a usable path is being served: %v", repos)
+ }
+}
+
+// The scan exists to report what arrived by hand, and a report buried in noise
+// is not a report: a repository owns two sibling directories, and reading
+// either as a section warns about every file git keeps inside it.
+func TestScanIsQuietOnAHealthyDatadir(t *testing.T) {
+ f := testForge(t)
+ addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featUsers})
+ addRepo(t, f, "infra/dns", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featUsers})
+
+ var buf bytes.Buffer
+ scan := func() string {
+ t.Helper()
+ buf.Reset()
+ old := log.Writer()
+ log.SetOutput(&buf)
+ err := f.store.scanRepos()
+ log.SetOutput(old)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return strings.TrimSpace(buf.String())
+ }
+
+ if out := scan(); out != "" {
+ t.Errorf("a healthy datadir warned about itself:\n%s", out)
+ }
+ for _, path := range []string{"notes", "infra/dns"} {
+ if _, ok := f.store.Repo(path); !ok {
+ t.Errorf("%s did not survive the scan", path)
+ }
+ }
+ // And the one thing it is for is still said.
+ if err := os.MkdirAll(filepath.Join(f.store.reposDir, "Uppercase.git"), 0700); err != nil {
+ t.Fatal(err)
+ }
+ if out := scan(); !strings.Contains(out, "Uppercase") {
+ t.Errorf("a stray directory was not reported:\n%s", out)
+ }
+}
+
+// The scan is the one place that reports a name nothing has validated, so a
+// directory can be named to look like the next line of the log.
+func TestTheScanCannotBeMadeToForgeALogLine(t *testing.T) {
+ f := testForge(t)
+ // No slash: this has to be one name on disk, not a path.
+ forged := "innocent\n2026-08-22 00:00:00 203.0.113.9 GET x 200 0 1ms"
+ repos := f.store.reposDir
+ // One of each thing the scan reports: a stray file, a directory whose name
+ // is not a usable path, and a stray file inside a section.
+ if err := os.WriteFile(filepath.Join(repos, forged), nil, 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(repos, forged+".git"), 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(repos, "sect"), 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(repos, "sect", forged), nil, 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ var buf bytes.Buffer
+ old := log.Writer()
+ log.SetOutput(&buf)
+ err := f.store.scanRepos()
+ log.SetOutput(old)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ out := strings.TrimRight(buf.String(), "\n")
+ lines := strings.Split(out, "\n")
+ if len(lines) != 3 {
+ t.Fatalf("three strays wrote %d lines:\n%s", len(lines), out)
+ }
+ for _, line := range lines {
+ if !strings.Contains(line, "repos: ") || !strings.Contains(line, `\n`) {
+ t.Errorf("the line break was not escaped: %q", line)
+ }
+ }
+}
+
+// Every single-account edit found the account before the write. Writing an
+// absent key back would put a record in users.json under a name nothing else
+// knows about, with an origin the next start refuses to load.
+func TestEditingAnAccountThatIsGoneWritesNothing(t *testing.T) {
+ dir := t.TempDir()
+ store, err := openStore(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ ran := false
+ err = store.UpdateUser("ghost", "users: sign out every session for ghost", store.forgeAuthor(),
+ func(acct *User) error {
+ ran = true
+ acct.Epoch++
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("editing an account that is gone: %v", err)
+ }
+ if ran {
+ t.Error("the edit ran against an account that is not there")
+ }
+ if _, ok := store.User("ghost"); ok {
+ t.Fatal("the account was recreated")
+ }
+ // The consequence that matters: the datadir still starts.
+ if _, err := openStore(dir); err != nil {
+ t.Errorf("a fresh start refuses the datadir: %v", err)
+ }
+}
diff --git a/templates/admin.html b/templates/admin.html
new file mode 100644
index 0000000..871b061
--- /dev/null
+++ b/templates/admin.html
@@ -0,0 +1,236 @@
+{{define "title"}}Admin - {{.Site}}{{end}}
+{{define "main"}}
+<h2>This instance</h2>
+<form method="post" action="/admin/site" enctype="multipart/form-data">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="site_name">Name, shown in the header and in mail subjects</label>
+<input id="site_name" name="site_name" value="{{.Data.Site.Name}}" required>
+</p>
+<p>
+<label for="site_desc">Subtitle, under the name</label>
+<input id="site_desc" name="site_desc" value="{{.Data.Site.Desc}}">
+</p>
+<fieldset>
+<legend>Logo</legend>
+<p>
+{{if .Data.Site.Logo.Name}}
+Currently <code>{{.Data.Site.Logo.Name}}</code>.
+{{else}}
+Currently cgit's own.
+{{end}}
+</p>
+<p>
+<label for="logo_file">Upload one</label>
+<input id="logo_file" name="logo_file" type="file" accept="image/png,image/jpeg,image/gif,image/webp,image/x-icon">
+<span class="dim">
+Drawn in the header at 96 pixels at most, and left at its own size below that.
+</span>
+</p>
+<p>
+<label class="inline" for="logo-cgit">
+<input id="logo-cgit" name="logo" type="checkbox" value="cgit">
+Use cgit's own logo instead
+</label>
+</p>
+</fieldset>
+
+<fieldset>
+<legend>Favicon</legend>
+<p>
+{{if .Data.Site.Icon.Name}}
+Currently <code>{{.Data.Site.Icon.Name}}</code>.
+{{else}}
+Currently cgit's own.
+{{end}}
+</p>
+<p>
+<label for="icon_file">Upload one</label>
+<input id="icon_file" name="icon_file" type="file" accept="image/png,image/jpeg,image/gif,image/webp,image/x-icon">
+<span class="dim">
+Served at <code>/favicon.ico</code>, whatever type it is.
+</span>
+</p>
+<p>
+<label class="inline" for="icon-cgit">
+<input id="icon-cgit" name="icon" type="checkbox" value="cgit">
+Use cgit's own favicon instead
+</label>
+</p>
+</fieldset>
+<p class="dim">
+Both take a png, jpeg, gif, webp, or an icon, up to 512 KiB, and are kept in
+the config repository, so they are in the backup and in the history with
+everything else. SVG is refused: it is XML that can carry script, and this is
+served back to browsers.
+</p>
+<p><button type="submit">Save</button></p>
+</form>
+<p class="dim">
+Everything else in forge.json decides how the process starts, and is left to
+the file: a listener or a base url typed into a browser is a way to lock
+everybody out.
+</p>
+
+<h2>Repositories</h2>
+<table summary="repositories" class="list nowrap">
+<tr class="nohover">
+<th class="left">Name</th><th class="left">Visibility</th>
+<th class="left">Issues</th><th class="left">Wiki</th><th class="left"></th>
+</tr>
+{{range .Data.Repos}}
+<tr>
+<td><a href="/{{.Path}}/settings">{{.Path}}</a></td>
+<td>{{.Repo.Visibility}}</td>
+<td>{{.Repo.Issues}}</td>
+<td>{{.Repo.Wiki}}</td>
+<td>
+{{if not .Registered}}
+<form method="post" action="/admin/repo/adopt">
+<input type="hidden" name="csrf" value="{{$.CSRF}}">
+<input type="hidden" name="path" value="{{.Path}}">
+<button type="submit">adopt</button>
+</form>
+{{end}}
+</td>
+</tr>
+{{else}}
+<tr><td colspan="5" class="dim">None yet.</td></tr>
+{{end}}
+</table>
+<p class="dim">
+A repository with an adopt button has a directory but no entry in repos.json.
+It is served private with issues and wiki off until it is adopted or deleted.
+</p>
+
+<h3>Create a repository</h3>
+<form method="post" action="/admin/repo/create">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="section">Section, optional</label>
+<input id="section" name="section" autocapitalize="none">
+</p>
+<p>
+<label for="name">Name</label>
+<input id="name" name="name" autocapitalize="none" required>
+</p>
+<p>
+<label for="desc">Description</label>
+<input id="desc" name="desc">
+</p>
+{{template "knobs" .Data.New}}
+<p><button type="submit">Create</button></p>
+</form>
+
+<h2>Accounts</h2>
+{{with .Data.Link}}
+<div class="notice">
+One time link for {{$.Data.For}}, valid for two days. It both sets the password
+and confirms the address, and it dies the moment it is used.
+</div>
+<pre>{{.}}</pre>
+{{end}}
+
+<table summary="accounts" class="list nowrap">
+<tr class="nohover">
+<th class="left">Account</th><th class="left">Origin</th><th class="left">Email</th>
+<th class="left">Admin</th><th class="left">Second factor</th>
+</tr>
+{{range $name, $u := .Data.Users}}
+<tr>
+<td>{{$name}}</td>
+<td>{{$u.Origin}}</td>
+<td>{{with $u.Email}}{{.}}{{if not $u.EmailConfirmed}} <span class="dim">unconfirmed</span>{{end}}{{end}}</td>
+<td>{{if $u.Admin}}yes{{end}}</td>
+<td>{{if $u.TOTP}}yes{{else}}<span class="dim">none</span>{{end}}</td>
+</tr>
+{{end}}
+</table>
+
+<h3>Create an account</h3>
+<p>
+Creating an account issues a one time set-password link rather than a password,
+so an admin never handles one. That link is a way in that outlives this session,
+so this asks for your own authenticator code.
+</p>
+<form method="post" action="/admin/user/create">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="account">Account name</label>
+<input id="account" name="name" autocapitalize="none" required>
+</p>
+<p>
+<label for="email">Email address, optional</label>
+<input id="email" name="email" type="email">
+</p>
+<p>
+<label for="create-code">Your authenticator code</label>
+<input id="create-code" name="code" inputmode="numeric" autocomplete="one-time-code" required>
+</p>
+<p class="dim">
+The admin flag is not set here. Handing it out is its own act, below.
+</p>
+<p><button type="submit">Create</button></p>
+</form>
+
+<div class="danger">
+<h3>Changes that hand out power, or cannot be undone</h3>
+<p>
+Each of these asks for your own authenticator code as well, so that a session
+somebody else is sitting in front of cannot use them.
+</p>
+
+<h4>Admin flag</h4>
+<form method="post" action="/admin/user/admin">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="flag-name">Account</label>
+<input id="flag-name" name="name" autocapitalize="none" required>
+</p>
+<p>
+<label for="flag-admin">
+<input id="flag-admin" name="admin" type="checkbox" value="yes">
+Hold the global admin flag. Leave this unticked to take it away.
+</label>
+</p>
+<p>
+<label for="flag-code">Your authenticator code</label>
+<input id="flag-code" name="code" inputmode="numeric" autocomplete="one-time-code" required>
+</p>
+<p><button type="submit">Set the flag</button></p>
+</form>
+
+<h4>Clear an authenticator</h4>
+<p>
+The way back in for somebody who has lost both their authenticator and their
+recovery codes. It signs every session for that account out.
+</p>
+<form method="post" action="/admin/user/totp">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="totp-name">Account</label>
+<input id="totp-name" name="name" autocapitalize="none" required>
+</p>
+<p>
+<label for="totp-code">Your authenticator code</label>
+<input id="totp-code" name="code" inputmode="numeric" autocomplete="one-time-code" required>
+</p>
+<p><button type="submit">Clear it</button></p>
+</form>
+
+<h4>Delete an account</h4>
+<p>This also drops every grant that pointed at it.</p>
+<form method="post" action="/admin/user/delete">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="delete-name">Account name</label>
+<input id="delete-name" name="name" autocapitalize="none" required>
+</p>
+<p>
+<label for="delete-code">Your authenticator code</label>
+<input id="delete-code" name="code" inputmode="numeric" autocomplete="one-time-code" required>
+</p>
+<p><button type="submit">Delete</button></p>
+</form>
+</div>
+{{end}}
diff --git a/templates/base.html b/templates/base.html
new file mode 100644
index 0000000..0f1751f
--- /dev/null
+++ b/templates/base.html
@@ -0,0 +1,23 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{block "title" .}}{{.Site}}{{end}}</title>
+<link rel="stylesheet" href="/cgit.css">
+<link rel="shortcut icon" href="/favicon.ico">
+</head>
+<body>
+<div id="cgit">
+{{template "header" .}}
+{{template "tabrow" .}}
+<div class="content">
+{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
+{{if .Notice}}<div class="notice">{{.Notice}}</div>{{end}}
+{{block "main" .}}{{end}}
+</div>
+{{/* cgit's footer names cgit, so this one names the forge. */}}
+<div class="footer">generated by {{.Site}} at {{.Now}}</div>
+</div>
+</body>
+</html>
diff --git a/templates/error.html b/templates/error.html
new file mode 100644
index 0000000..9c39077
--- /dev/null
+++ b/templates/error.html
@@ -0,0 +1,5 @@
+{{define "title"}}{{.Data.Status}} {{.Data.Text}} - {{.Site}}{{end}}
+{{define "main"}}
+<h2>{{.Data.Status}} {{.Data.Text}}</h2>
+<p><a href="/">Back to the index</a></p>
+{{end}}
diff --git a/templates/home.html b/templates/home.html
new file mode 100644
index 0000000..a7460f8
--- /dev/null
+++ b/templates/home.html
@@ -0,0 +1,16 @@
+{{define "title"}}{{.Site}}{{end}}
+{{define "main"}}
+<table summary="repository list" class="list nowrap">
+<tr class="nohover"><th class="left">Name</th><th class="left">Description</th></tr>
+{{range .Data.Repos}}
+<tr>
+<td><a href="/{{.Path}}/">{{.Path}}</a>{{if eq .Repo.Visibility "private"}} <span class="dim">private</span>{{end}}</td>
+<td>{{.Repo.Desc}}</td>
+</tr>
+{{else}}
+<tr><td colspan="2" class="dim">
+{{if $.User}}Nothing you can read yet.{{else}}Nothing public here. <a href="/login">Sign in</a> to see the rest.{{end}}
+</td></tr>
+{{end}}
+</table>
+{{end}}
diff --git a/templates/issue-new.html b/templates/issue-new.html
new file mode 100644
index 0000000..ed102aa
--- /dev/null
+++ b/templates/issue-new.html
@@ -0,0 +1,27 @@
+{{define "title"}}New issue, {{.Data.Path}} - {{.Site}}{{end}}
+{{define "main"}}
+<h2>Open an issue</h2>
+<form method="post" action="/{{.Data.Path}}/issues">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="title">Title</label>
+<input id="title" name="title" required maxlength="200">
+</p>
+<p>
+<label for="body">Description. Markdown, and raw HTML is not rendered.</label>
+<textarea id="body" name="body" rows="12"></textarea>
+</p>
+{{with .Data.Labels}}
+<fieldset>
+<legend>Labels</legend>
+{{range .}}
+<label class="inline" for="label-{{.}}">
+<input id="label-{{.}}" type="checkbox" name="label" value="{{.}}">
+{{.}}
+</label>
+{{end}}
+</fieldset>
+{{end}}
+<p><button type="submit">Open</button></p>
+</form>
+{{end}}
diff --git a/templates/issue.html b/templates/issue.html
new file mode 100644
index 0000000..e5135a0
--- /dev/null
+++ b/templates/issue.html
@@ -0,0 +1,62 @@
+{{define "title"}}{{.Data.Issue.Title}} - {{.Data.Path}} - {{.Site}}{{end}}
+{{define "main"}}
+{{$issue := .Data.Issue}}
+<h2>{{$issue.Title}} <span class="dim">#{{$issue.ID}}</span></h2>
+<p>
+<span class="state-{{$issue.State}}">{{$issue.State}}</span>
+<span class="dim">opened by</span> {{template "who" $issue.Author}}
+<span class="dim">on {{$issue.Created.Format "2006-01-02 15:04"}} UTC</span>
+{{range $issue.Labels}}<span class="label">{{.}}</span>{{end}}
+</p>
+
+{{if .Data.Triage}}
+<div class="bar">
+<form method="post" action="/{{.Data.Path}}/issues/{{$issue.ID}}/state">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<button type="submit">{{if eq $issue.State "open"}}close this issue{{else}}reopen this issue{{end}}</button>
+</form>
+{{with .Data.Labels}}
+<form method="post" action="/{{$.Data.Path}}/issues/{{$issue.ID}}/labels">
+<input type="hidden" name="csrf" value="{{$.CSRF}}">
+{{range .}}
+<label class="inline" for="label-{{.Name}}">
+<input id="label-{{.Name}}" type="checkbox" name="label" value="{{.Name}}" {{if .On}}checked{{end}}>
+{{.Name}}
+</label>
+{{end}}
+<button type="submit">set labels</button>
+</form>
+{{end}}
+</div>
+{{end}}
+
+<div class="comment">
+<div class="byline">{{template "who" $issue.Author}} <span class="dim">opened this</span></div>
+<article>{{.Data.Rendered}}</article>
+</div>
+
+{{range .Data.Comments}}
+<div class="comment">
+<div class="byline">
+{{template "who" .Comment.Author}}
+<span class="dim">commented on {{.Comment.Created.Format "2006-01-02 15:04"}} UTC</span>
+</div>
+<article>{{.Rendered}}</article>
+</div>
+{{end}}
+
+{{if .Data.Can}}
+<h3>Comment</h3>
+<form method="post" action="/{{.Data.Path}}/issues/{{$issue.ID}}/comment">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="body">
+Markdown: **bold**, `code`, a list, or four spaces for a code block. Raw HTML
+is not rendered.
+</label>
+<textarea id="body" name="body" rows="8" required></textarea>
+</p>
+<p><button type="submit">Comment</button></p>
+</form>
+{{end}}
+{{end}}
diff --git a/templates/issues.html b/templates/issues.html
new file mode 100644
index 0000000..e0907ce
--- /dev/null
+++ b/templates/issues.html
@@ -0,0 +1,34 @@
+{{define "title"}}Issues, {{.Data.Path}} - {{.Site}}{{end}}
+{{define "main"}}
+<div class="bar">
+<form method="get" action="/{{.Data.Path}}/issues">
+<input id="q" name="q" value="{{.Data.Query}}" placeholder="search the tracker" size="30">
+<input type="hidden" name="state" value="{{.Data.State}}">
+<button type="submit">search</button>
+</form>
+<span>
+{{if eq .Data.State "open"}}<strong>open</strong>{{else}}<a href="/{{.Data.Path}}/issues?state=open&amp;q={{.Data.Query}}">open</a>{{end}}
+{{if eq .Data.State "closed"}}<strong>closed</strong>{{else}}<a href="/{{.Data.Path}}/issues?state=closed&amp;q={{.Data.Query}}">closed</a>{{end}}
+<a href="/{{.Data.Path}}.issues/">history</a>
+{{if .Data.Can}}<a href="/{{.Data.Path}}/issues/new">open an issue</a>{{end}}
+</span>
+</div>
+
+<table summary="issues" class="list nowrap">
+<tr class="nohover">
+<th class="left">#</th><th class="left">Title</th>
+<th class="left">Labels</th><th class="left">Author</th><th class="left">Updated</th>
+</tr>
+{{range .Data.Issues}}
+<tr>
+<td>{{.ID}}</td>
+<td><a href="/{{$.Data.Path}}/issues/{{.ID}}">{{.Title}}</a></td>
+<td class="dim">{{range .Labels}}<span class="label">{{.}}</span> {{end}}</td>
+<td>{{template "who" .Author}}</td>
+<td class="dim">{{.Updated.Format "2006-01-02"}}</td>
+</tr>
+{{else}}
+<tr><td colspan="5" class="dim">Nothing here.</td></tr>
+{{end}}
+</table>
+{{end}}
diff --git a/templates/login.html b/templates/login.html
new file mode 100644
index 0000000..eb9dcd9
--- /dev/null
+++ b/templates/login.html
@@ -0,0 +1,33 @@
+{{define "title"}}Sign in - {{.Site}}{{end}}
+{{define "main"}}
+<h2>Sign in</h2>
+<form method="post" action="/login">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="name">Account</label>
+<input id="name" name="name" autocomplete="username" autocapitalize="none" required>
+</p>
+<p>
+<label for="password">Password</label>
+<input id="password" name="password" type="password" autocomplete="current-password" required>
+</p>
+<p><button type="submit">Sign in</button></p>
+</form>
+{{if .MailOff}}
+<p class="dim">
+Mail is turned off on this instance, so there is no password reset. An admin
+can issue you a one time link instead.
+</p>
+{{else}}
+<p><a href="/reset">Forgotten your password?</a></p>
+{{end}}
+
+{{with .Data.Providers}}
+<h3>Or sign in through</h3>
+<ul>
+{{range .}}
+<li><a href="/oidc/{{.Key}}">{{.Label}}</a></li>
+{{end}}
+</ul>
+{{end}}
+{{end}}
diff --git a/templates/nav.html b/templates/nav.html
new file mode 100644
index 0000000..82f0714
--- /dev/null
+++ b/templates/nav.html
@@ -0,0 +1,112 @@
+{{/*
+ cgit's own header markup, rendered in place of the one noheader=1 turns off,
+ so that both kinds of page carry the same header and it can name the account.
+*/}}
+{{define "header"}}
+<table id="header">
+<tr>
+<td class="logo" rowspan="2"><a href="/"><img src="{{.Logo}}" alt="home"></a></td>
+<td class="main">
+{{if .Repo}}<a href="/">{{.Site}}</a> : <a href="/{{.Repo.Path}}/">{{.Repo.Path}}</a>
+{{else}}<a href="/">{{.Site}}</a>{{end}}
+</td>
+<td class="form">
+{{if .User}}
+{{if or .Admin .MustEnrol}}<a href="/admin">admin</a>{{end}}
+<a href="/settings">{{.User}}</a>
+<form method="post" action="/logout">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<button type="submit" class="link">sign out</button>
+</form>
+{{else}}
+<a href="/login">sign in</a>
+{{end}}
+</td>
+</tr>
+<tr><td class="sub">{{if .Repo}}{{.Repo.Desc}}{{else}}{{.Desc}}{{end}}</td></tr>
+</table>
+{{end}}
+
+{{/* The forge's sections, in cgit's tab markup. */}}
+{{define "tabs"}}{{with .Repo}}{{if .Issues}}<a href="/{{.Path}}/issues"{{if eq $.Repo.Active "issues"}} class="active"{{end}}>issues</a>{{end}}{{if .Wiki}}<a href="/{{.Path}}/wiki"{{if eq $.Repo.Active "wiki"}} class="active"{{end}}>wiki</a>{{end}}{{if .Settings}}<a href="/{{.Path}}/settings"{{if eq $.Repo.Active "settings"}} class="active"{{end}}>settings</a>{{end}}{{end}}{{end}}
+
+{{/*
+ cgit's row copied, for the forge's own pages where cgit renders nothing: the
+ same tabs in the same order, and the same search cell.
+*/}}
+{{define "tabrow"}}{{with .Repo}}
+<table class="tabs"><tr><td>
+<a href="/{{.Path}}/about/">about</a><a href="/{{.Path}}/">summary</a><a href="/{{.Path}}/refs/">refs</a><a href="/{{.Path}}/log/">log</a><a href="/{{.Path}}/tree/">tree</a><a href="/{{.Path}}/commit/">commit</a><a href="/{{.Path}}/diff/">diff</a>{{template "tabs" $}}</td>
+<td class="form"><form class="right" method="get" action="/{{.Path}}/log/">
+<select name="qt">
+<option value="grep">log msg</option>
+<option value="author">author</option>
+<option value="committer">committer</option>
+<option value="range">range</option>
+</select>
+<input class="txt" type="search" size="10" name="q" value="">
+<input type="submit" value="search">
+</form>
+</td></tr></table>
+{{end}}{{end}}
+
+{{/*
+ In the layout because every page is parsed as its own set, and a partial
+ defined in one page is invisible to the next.
+*/}}
+{{define "who"}}{{if .}}<img class="avatar" src="/avatar/{{.}}" alt=""> {{.}}{{else}}<span class="dim">nobody</span>{{end}}{{end}}
+
+{{/*
+ One fragment for both the create form and the settings form, so that the two
+ cannot drift apart on what members and users mean.
+*/}}
+{{define "knobs"}}
+<fieldset>
+<legend>Who may read it</legend>
+<label class="choice" for="v-private">
+<input id="v-private" type="radio" name="visibility" value="private" {{if eq .Visibility "private"}}checked{{end}}>
+<strong>private</strong> <span class="dim">accounts holding a grant on it, and global admins</span>
+</label>
+<label class="choice" for="v-public">
+<input id="v-public" type="radio" name="visibility" value="public" {{if eq .Visibility "public"}}checked{{end}}>
+<strong>public</strong> <span class="dim">anybody, including visitors who are not signed in</span>
+</label>
+</fieldset>
+
+<fieldset>
+<legend>Who may file issues</legend>
+<label class="choice" for="i-disabled">
+<input id="i-disabled" type="radio" name="issues" value="disabled" {{if eq .Issues "disabled"}}checked{{end}}>
+<strong>disabled</strong> <span class="dim">nobody, and the tab is hidden</span>
+</label>
+<label class="choice" for="i-members">
+<input id="i-members" type="radio" name="issues" value="members" {{if eq .Issues "members"}}checked{{end}}>
+<strong>members</strong> <span class="dim">anybody holding a grant here, even a read grant</span>
+</label>
+<label class="choice" for="i-users">
+<input id="i-users" type="radio" name="issues" value="users" {{if eq .Issues "users"}}checked{{end}}>
+<strong>users</strong> <span class="dim">any signed in account. Public repositories only</span>
+</label>
+</fieldset>
+
+<fieldset>
+<legend>Who may edit the wiki</legend>
+<label class="choice" for="w-disabled">
+<input id="w-disabled" type="radio" name="wiki" value="disabled" {{if eq .Wiki "disabled"}}checked{{end}}>
+<strong>disabled</strong> <span class="dim">nobody, and the tab is hidden</span>
+</label>
+<label class="choice" for="w-members">
+<input id="w-members" type="radio" name="wiki" value="members" {{if eq .Wiki "members"}}checked{{end}}>
+<strong>members</strong> <span class="dim">anybody holding a grant here</span>
+</label>
+<label class="choice" for="w-users">
+<input id="w-users" type="radio" name="wiki" value="users" {{if eq .Wiki "users"}}checked{{end}}>
+<strong>users</strong> <span class="dim">any signed in account. Public repositories only</span>
+</label>
+</fieldset>
+<p class="dim">
+Reading is visibility's job alone. The other two say who may write, so a
+repository can take bug reports and documentation fixes from people who will
+never push code. users on a private repository is refused rather than coerced.
+</p>
+{{end}}
diff --git a/templates/repo-settings.html b/templates/repo-settings.html
new file mode 100644
index 0000000..d90f744
--- /dev/null
+++ b/templates/repo-settings.html
@@ -0,0 +1,122 @@
+{{define "title"}}{{.Data.Path}} settings - {{.Site}}{{end}}
+{{define "main"}}
+{{$repo := .Data.Repo}}
+<h2>Settings</h2>
+
+{{if not .Data.Enrolled}}
+<div class="error">
+This account has no authenticator, so it cannot change who may read or write
+this repository, and cannot change grants.
+<a href="/settings/totp">Enrol one</a> and come back.
+</div>
+{{end}}
+
+<form method="post" action="/{{.Data.Path}}/settings">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="desc">Description, shown in the header and in the index</label>
+<input id="desc" name="desc" value="{{$repo.Desc}}">
+</p>
+{{template "knobs" $repo}}
+<p>
+<label for="labels">Labels an issue may carry, one per line</label>
+<textarea id="labels" name="labels" rows="5">{{.Data.Labels}}</textarea>
+</p>
+<p>
+<label for="settings-code">Your authenticator code</label>
+<input id="settings-code" name="code" inputmode="numeric" autocomplete="one-time-code">
+<span class="dim">
+Needed only to move one of the three above: who may read or write outlives this
+session, the way a grant does. A description or a label does not.
+</span>
+</p>
+<p><button type="submit">Save</button></p>
+</form>
+
+<h3>Grants</h3>
+<p class="dim">
+read clones and browses a private repository. write adds pushing to it. admin
+adds this page. A global admin holds admin everywhere without appearing here.
+A push token can be minted for any repository an account can reach, and carries
+the access that account already has there and nothing more.
+</p>
+<p class="dim">
+Access handed out here outlives the session that hands it out, so changing a
+grant asks for your own authenticator code as well.
+</p>
+<table summary="grants" class="list nowrap">
+<tr class="nohover"><th class="left">Account</th><th class="left">Level</th><th class="left"></th></tr>
+{{range $account, $level := $repo.Grants}}
+<tr>
+<td>{{template "who" $account}}</td>
+<td>{{$level}}</td>
+<td>
+{{if $.Data.Enrolled}}
+<form method="post" action="/{{$.Data.Path}}/settings/grant">
+<input type="hidden" name="csrf" value="{{$.CSRF}}">
+<input type="hidden" name="account" value="{{$account}}">
+<input name="code" size="6" inputmode="numeric" autocomplete="one-time-code" aria-label="Your authenticator code" placeholder="code" required>
+<button type="submit">revoke</button>
+</form>
+{{end}}
+</td>
+</tr>
+{{else}}
+<tr><td colspan="3" class="dim">Nobody, so only global admins can reach it.</td></tr>
+{{end}}
+</table>
+
+{{if .Data.Enrolled}}
+<form method="post" action="/{{.Data.Path}}/settings/grant">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="account">Account</label>
+<input id="account" name="account" autocapitalize="none" required>
+</p>
+<fieldset>
+<legend>Level</legend>
+<label class="choice" for="g-read">
+<input id="g-read" type="radio" name="level" value="read" checked>
+<strong>read</strong> <span class="dim">clone and browse it</span>
+</label>
+<label class="choice" for="g-write">
+<input id="g-write" type="radio" name="level" value="write">
+<strong>write</strong> <span class="dim">and push to it</span>
+</label>
+<label class="choice" for="g-admin">
+<input id="g-admin" type="radio" name="level" value="admin">
+<strong>admin</strong> <span class="dim">and change this page</span>
+</label>
+</fieldset>
+<p>
+<label for="grant-code">Your authenticator code</label>
+<input id="grant-code" name="code" inputmode="numeric" autocomplete="one-time-code" required>
+</p>
+<p><button type="submit">Grant</button></p>
+</form>
+{{end}}
+
+{{if .Data.Delete}}
+<div class="danger">
+<h3>Delete this repository</h3>
+<p>
+This removes the repository, its issues, and its wiki together. There is no
+undo and the history goes with it. Type <code>{{.Data.Path}}</code> to confirm,
+and your own authenticator code, so that a session somebody else is sitting in
+front of cannot do it.
+</p>
+<form method="post" action="/{{.Data.Path}}/settings/delete">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="confirm">Repository path</label>
+<input id="confirm" name="confirm" autocapitalize="none" required>
+</p>
+<p>
+<label for="code">Your authenticator code</label>
+<input id="code" name="code" inputmode="numeric" autocomplete="one-time-code" required>
+</p>
+<p><button type="submit">Delete it</button></p>
+</form>
+</div>
+{{end}}
+{{end}}
diff --git a/templates/reset.html b/templates/reset.html
new file mode 100644
index 0000000..9025f2e
--- /dev/null
+++ b/templates/reset.html
@@ -0,0 +1,23 @@
+{{define "title"}}Forgotten password - {{.Site}}{{end}}
+{{define "main"}}
+<h2>Forgotten password</h2>
+{{if .MailOff}}
+<div class="error">
+Mail is turned off on this instance, so no link can be sent. Ask an admin for
+a one time link.
+</div>
+{{end}}
+<p>
+This mails a one time link to the address on file for the account. The answer
+below is the same whether or not the account exists, so that this page cannot
+be used to find out which names are taken.
+</p>
+<form method="post" action="/reset">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="name">Account name</label>
+<input id="name" name="name" autocomplete="username" autocapitalize="none" required>
+</p>
+<p><button type="submit">Send the link</button></p>
+</form>
+{{end}}
diff --git a/templates/set-password.html b/templates/set-password.html
new file mode 100644
index 0000000..6a38413
--- /dev/null
+++ b/templates/set-password.html
@@ -0,0 +1,20 @@
+{{define "title"}}Set a password - {{.Site}}{{end}}
+{{define "main"}}
+<h2>Set a password for {{.Data.Name}}</h2>
+<p>This link works once, and setting the password is what spends it.</p>
+<form method="post" action="/set-password">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<input type="hidden" name="name" value="{{.Data.Name}}">
+<input type="hidden" name="expires" value="{{.Data.Expires}}">
+<input type="hidden" name="t" value="{{.Data.Token}}">
+<p>
+<label for="new">Password</label>
+<input id="new" name="new" type="password" autocomplete="new-password" required>
+</p>
+<p>
+<label for="again">Password again</label>
+<input id="again" name="again" type="password" autocomplete="new-password" required>
+</p>
+<p><button type="submit">Set the password</button></p>
+</form>
+{{end}}
diff --git a/templates/settings.html b/templates/settings.html
new file mode 100644
index 0000000..8b2f0f1
--- /dev/null
+++ b/templates/settings.html
@@ -0,0 +1,126 @@
+{{define "title"}}Settings - {{.Site}}{{end}}
+{{define "main"}}
+<h2>Account</h2>
+<p>
+Signed in as {{template "who" .User}}{{if .Admin}}, a global admin{{end}}.
+</p>
+
+<h3>Email and notifications</h3>
+{{if .MailOff}}
+<p class="dim">
+Mail is turned off on this instance, so nothing is sent. The address below is
+still what commits made through the web are attributed to.
+</p>
+{{else}}
+<p class="dim">
+An address receives nothing until a link sent to it has been followed. Notices
+about your own account ignore the toggle below, because they are security facts
+rather than activity.
+</p>
+{{end}}
+<form method="post" action="/settings/email">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="email">Email address</label>
+<input id="email" name="email" type="email" value="{{.Data.Email}}">
+{{if .Data.Email}}{{if not .Data.EmailConfirmed}}<span class="dim">unconfirmed</span>{{end}}{{end}}
+</p>
+<p>
+<label for="notify">
+<input id="notify" name="notify" type="checkbox" value="yes" {{if .Data.Notify}}checked{{end}}>
+Send me issue and repository notifications
+</label>
+</p>
+<p><button type="submit">Save</button></p>
+</form>
+
+{{if .Data.Local}}
+<h3>Change password</h3>
+<form method="post" action="/settings/password">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="current">Current password</label>
+<input id="current" name="current" type="password" autocomplete="current-password" required>
+</p>
+<p>
+<label for="new">New password</label>
+<input id="new" name="new" type="password" autocomplete="new-password" required>
+</p>
+<p>
+<label for="again">New password again</label>
+<input id="again" name="again" type="password" autocomplete="new-password" required>
+</p>
+<p><button type="submit">Change password</button></p>
+</form>
+{{else}}
+<h3>Password</h3>
+<p>This account signs in through an external provider, so there is no password here.</p>
+{{end}}
+
+<h3>Push tokens</h3>
+<p class="dim">
+Git over HTTP takes a token, never your password. A token is scoped to one
+repository and carries the access you already have there: clone if you can read
+it, push if you can write it, the wiki if you may edit it. It carries no second
+factor, and it can be revoked on its own without touching anything else.
+</p>
+<pre>git clone {{.Base}}/section/repo.git
+Username: {{.User}}
+Password: the token</pre>
+{{with .Data.Secret}}
+<div class="notice">Token for {{$.User}}, shown once:</div>
+<pre id="token">{{.}}</pre>
+{{end}}
+<table summary="push tokens" class="list nowrap">
+<tr class="nohover">
+<th class="left">Id</th><th class="left">Repository</th>
+<th class="left">Made</th><th class="left">Last used</th><th class="left"></th>
+</tr>
+{{range .Data.Tokens}}
+<tr>
+<td>{{.ID}}</td>
+<td><a href="/{{.Repo}}/">{{.Repo}}</a></td>
+<td class="dim">{{if .Created}}{{.Created}}{{else}}unknown{{end}}</td>
+<td class="dim">{{if .Used}}{{.Used}}{{else}}never{{end}}</td>
+<td>
+<form method="post" action="/settings/tokens/revoke">
+<input type="hidden" name="csrf" value="{{$.CSRF}}">
+<input type="hidden" name="id" value="{{.ID}}">
+<button type="submit">revoke</button>
+</form>
+</td>
+</tr>
+{{else}}
+<tr><td colspan="5" class="dim">None. Mint one below to push over HTTP.</td></tr>
+{{end}}
+</table>
+<p class="dim">
+Dates are days, not times: a write on every request git makes would be a write
+on every request git makes. A token that has never been used, or was last used
+a long time ago, is one worth revoking.
+</p>
+<form method="post" action="/settings/tokens">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="repo">Repository you can reach</label>
+<input id="repo" name="repo" placeholder="section/repo" autocapitalize="none" required>
+</p>
+<p><button type="submit">Mint a token</button></p>
+</form>
+
+<h3>Authenticator</h3>
+<p>
+<a href="/settings/totp">Second factor</a>. An account with the global admin
+flag has to have one before it can administer anything.
+</p>
+
+<h3>Sessions</h3>
+<p>
+Signing out everywhere invalidates every session cookie ever issued for this
+account, including this browser's.
+</p>
+<form method="post" action="/settings/sessions">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p><button type="submit">Sign out everywhere</button></p>
+</form>
+{{end}}
diff --git a/templates/setup.html b/templates/setup.html
new file mode 100644
index 0000000..9089bd3
--- /dev/null
+++ b/templates/setup.html
@@ -0,0 +1,33 @@
+{{define "title"}}Set up - {{.Site}}{{end}}
+{{define "main"}}
+<h2>Set up {{.Site}}</h2>
+<p>
+This creates the first account and gives it the global admin flag. The setup
+token was printed to the log when git-forge started, and this page is gone once
+an account exists.
+</p>
+<form method="post" action="/setup">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="token">Setup token</label>
+<input id="token" name="token" required>
+</p>
+<p>
+<label for="name">Account name</label>
+<input id="name" name="name" autocomplete="username" autocapitalize="none" required>
+</p>
+<p>
+<label for="email">Email address, optional</label>
+<input id="email" name="email" type="email" autocomplete="email">
+</p>
+<p>
+<label for="password">Password</label>
+<input id="password" name="password" type="password" autocomplete="new-password" required>
+</p>
+<p>
+<label for="again">Password again</label>
+<input id="again" name="again" type="password" autocomplete="new-password" required>
+</p>
+<p><button type="submit">Create the first admin</button></p>
+</form>
+{{end}}
diff --git a/templates/totp-enroll.html b/templates/totp-enroll.html
new file mode 100644
index 0000000..da3d301
--- /dev/null
+++ b/templates/totp-enroll.html
@@ -0,0 +1,96 @@
+{{define "title"}}Authenticator - {{.Site}}{{end}}
+{{define "main"}}
+<h2>Authenticator</h2>
+
+{{with .Data.Codes}}
+<h3>Recovery codes</h3>
+<p>Each of these works once, in place of a code. They are not shown again.</p>
+<pre>{{range .}}{{.}}
+{{end}}</pre>
+{{end}}
+
+{{if .Data.Enrolled}}
+<p>
+This account has an authenticator, and {{.Data.Left}} recovery codes left.
+</p>
+
+<h3>New recovery codes</h3>
+<p>Making new ones stops the old ones working.</p>
+<form method="post" action="/settings/totp/codes">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+{{if .Data.Local}}
+<p>
+<label for="codes-current">Current password</label>
+<input id="codes-current" name="current" type="password" autocomplete="current-password" required>
+</p>
+{{else}}
+<p>
+<label for="codes-code">Code from the authenticator</label>
+<input id="codes-code" name="code" inputmode="numeric" autocomplete="one-time-code" required>
+</p>
+{{end}}
+<p><button type="submit">Make new recovery codes</button></p>
+</form>
+
+<h3>Remove</h3>
+{{if .Data.Required}}
+<p>
+This account holds the global admin flag, so it must keep an authenticator.
+Another admin has to take the flag away first.
+</p>
+{{else}}
+<p>Removing it signs out every session for this account.</p>
+<form method="post" action="/settings/totp/remove">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+{{if .Data.Local}}
+<p>
+<label for="current">Current password</label>
+<input id="current" name="current" type="password" autocomplete="current-password" required>
+</p>
+{{else}}
+<p>
+<label for="remove-code">Code from the authenticator</label>
+<input id="remove-code" name="code" inputmode="numeric" autocomplete="one-time-code" required>
+</p>
+{{end}}
+<p><button type="submit">Remove the authenticator</button></p>
+</form>
+{{end}}
+
+{{else}}
+<p>
+Type this secret into an authenticator, or give it the URI. There is no QR
+code: it would be useless in a terminal browser.
+</p>
+<pre>{{.Data.Secret}}
+
+{{.Data.URI}}</pre>
+<p>
+Then prove it took, which is what enrols it. Enrolling signs out every other
+session.
+{{if .Data.Local}}
+It takes the password as well: a stolen cookie must not be able to put a second
+factor on this account and lock its owner out.
+{{else}}
+This account signs in through an external provider and has no password here, so
+enrolling rests on this session alone. The address on file is told, and an admin
+can clear an authenticator you did not enrol.
+{{end}}
+</p>
+<form method="post" action="/settings/totp">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<input type="hidden" name="secret" value="{{.Data.Secret}}">
+<p>
+<label for="code">Code from the authenticator</label>
+<input id="code" name="code" inputmode="numeric" autocomplete="one-time-code" required>
+</p>
+{{if .Data.Local}}
+<p>
+<label for="enrol-current">Current password</label>
+<input id="enrol-current" name="current" type="password" autocomplete="current-password" required>
+</p>
+{{end}}
+<p><button type="submit">Enroll</button></p>
+</form>
+{{end}}
+{{end}}
diff --git a/templates/totp.html b/templates/totp.html
new file mode 100644
index 0000000..329d6cc
--- /dev/null
+++ b/templates/totp.html
@@ -0,0 +1,16 @@
+{{define "title"}}Authenticator code - {{.Site}}{{end}}
+{{define "main"}}
+<h2>Authenticator code</h2>
+<p>The password was right. Now the six digit code, or one recovery code.</p>
+<form method="post" action="/login/totp">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<input type="hidden" name="name" value="{{.Data.Name}}">
+<input type="hidden" name="expires" value="{{.Data.Expires}}">
+<input type="hidden" name="t" value="{{.Data.Token}}">
+<p>
+<label for="code">Code</label>
+<input id="code" name="code" inputmode="numeric" autocomplete="one-time-code" autofocus required>
+</p>
+<p><button type="submit">Sign in</button></p>
+</form>
+{{end}}
diff --git a/templates/wiki-edit.html b/templates/wiki-edit.html
new file mode 100644
index 0000000..ed896d2
--- /dev/null
+++ b/templates/wiki-edit.html
@@ -0,0 +1,27 @@
+{{define "title"}}Edit {{.Data.Page}} - {{.Data.Path}} - {{.Site}}{{end}}
+{{define "main"}}
+<h2>Edit wiki{{with .Data.Page}} <span class="dim">{{.}}</span>{{end}}</h2>
+
+{{with .Data.Preview}}
+<h3>Preview</h3>
+<article>{{.}}</article>
+<hr>
+{{end}}
+
+<form method="post" action="/{{.Data.Path}}/wiki/{{.Data.Page}}">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<input type="hidden" name="base" value="{{.Data.Base}}">
+<p>
+<label for="body">{{.Data.File}}, markdown</label>
+<textarea id="body" name="body" rows="24">{{.Data.Body}}</textarea>
+</p>
+<p>
+<button type="submit" name="action" value="save">Save</button>
+<button type="submit" name="action" value="preview">Preview</button>
+</p>
+</form>
+<p class="dim">
+Links between pages are plain relative markdown links, and a subdirectory's
+index.md is served at the subdirectory itself.
+</p>
+{{end}}
diff --git a/templates/wiki.html b/templates/wiki.html
new file mode 100644
index 0000000..36305c6
--- /dev/null
+++ b/templates/wiki.html
@@ -0,0 +1,37 @@
+{{define "title"}}Wiki {{.Data.Page}} - {{.Data.Path}} - {{.Site}}{{end}}
+{{define "main"}}
+<h2>Wiki{{with .Data.Page}} <span class="dim">{{.}}</span>{{end}}</h2>
+
+{{with .Data.Pages}}
+<p>
+Pages:
+{{range .}}<a href="/{{$.Data.Path}}/wiki/{{.}}">{{if .}}{{.}}{{else}}index{{end}}</a>&#32;
+{{end}}
+</p>
+{{end}}
+
+{{if .Data.Can}}
+<form method="get" action="/{{.Data.Path}}/wiki/">
+<p>
+<label for="page">New page, or an existing one to edit</label>
+<input id="page" name="page" placeholder="setup, or runbooks/dns" autocapitalize="none" required>
+<button type="submit">Write it</button>
+</p>
+</form>
+{{end}}
+
+{{if .Data.Empty}}
+<p>This wiki has no pages yet.</p>
+{{if .Data.Can}}<p><a href="/{{.Data.Path}}/wiki?edit">Write the first one</a>.</p>{{end}}
+{{else if .Data.Missing}}
+<p>There is no page here yet.</p>
+{{if .Data.Can}}<p><a href="/{{.Data.Path}}/wiki/{{.Data.Page}}?edit">Write it</a>.</p>{{end}}
+{{else}}
+<article>{{.Data.Rendered}}</article>
+<p>
+{{if .Data.Can}}<a href="/{{.Data.Path}}/wiki/{{.Data.Page}}?edit">Edit</a>{{end}}
+<a href="/{{.Data.Path}}.wiki/log/{{.Data.File}}">History</a>
+</p>
+{{end}}
+
+{{end}}
diff --git a/test b/test
new file mode 100755
index 0000000..bd4c2bc
--- /dev/null
+++ b/test
@@ -0,0 +1,320 @@
+#!/bin/sh
+# smoke: build the image, run it against a temporary volume, and check what
+# only breaks in the real thing.
+#
+# Needs docker, git, curl, lynx, openssl, python3, and timeout here.
+set -eu
+
+IMAGE=${IMAGE:-git-forge:smoke}
+PORT=${PORT:-18080}
+BASE=http://127.0.0.1:$PORT
+NAME=git-forge-smoke
+
+WORK=$(mktemp -d)
+VOLUME=""
+cleanup() {
+ docker rm -f "$NAME" >/dev/null 2>&1 || true
+ if [ -n "$VOLUME" ]; then
+ docker volume rm "$VOLUME" >/dev/null 2>&1 || true
+ fi
+ rm -rf "$WORK"
+}
+trap cleanup EXIT
+
+ok() { printf 'ok %s\n' "$1"; }
+no() { printf 'FAIL %s\n' "$1" >&2; exit 1; }
+
+# want <description> <pattern> <file>
+want() { grep -q -- "$2" "$3" || no "$1"; ok "$1"; }
+# lack <description> <pattern> <file>
+lack() { grep -q -- "$2" "$3" && no "$1"; ok "$1"; }
+# code <description> <expected> <curl args...>
+code() {
+ description=$1 expected=$2
+ shift 2
+ got=$(curl -s -o "$WORK/body" -w '%{http_code}' "$@")
+ [ "$got" = "$expected" ] || no "$description: got $got, wanted $expected"
+ ok "$description"
+}
+# csrf <file> is the form token on a fetched page
+csrf() { grep -o 'name="csrf" value="[^"]*"' "$1" | head -1 | cut -d'"' -f4; }
+# field <file> <name> is a hidden field on a fetched page
+field() { grep -o "name=\"$2\" value=\"[^\"]*\"" "$1" | head -1 | cut -d'"' -f4; }
+
+# The authenticator secret is the base32 of the ASCII "12345678901234567890",
+# so a code can be computed with openssl alone and no base32 decoder. The
+# counter goes in as eight bytes, spelled in octal because POSIX printf has
+# \ooo and not \xhh. A code works once, so a second call waits for the next
+# step; the step is kept in a file because $(totp) runs in a subshell.
+SECRET=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ
+KEYHEX=3132333435363738393031323334353637383930
+totp() {
+ last=$(cat "$WORK/step" 2>/dev/null || echo 0)
+ step=$(( $(date -u +%s) / 30 ))
+ while [ "$step" -le "$last" ]; do
+ sleep 1
+ step=$(( $(date -u +%s) / 30 ))
+ done
+ echo "$step" > "$WORK/step"
+ msg=$(printf '\\%03o' \
+ $(( (step >> 56) & 255 )) $(( (step >> 48) & 255 )) \
+ $(( (step >> 40) & 255 )) $(( (step >> 32) & 255 )) \
+ $(( (step >> 24) & 255 )) $(( (step >> 16) & 255 )) \
+ $(( (step >> 8) & 255 )) $(( step & 255 )))
+ digest=$(printf "$msg" | openssl dgst -sha1 -mac HMAC -macopt hexkey:$KEYHEX | sed 's/.*= *//')
+ offset=$(( 0x$(printf %s "$digest" | cut -c40) * 2 + 1 ))
+ chunk=$(printf %s "$digest" | cut -c${offset}-$((offset + 7)))
+ printf '%06d' $(( (0x$chunk & 0x7fffffff) % 1000000 ))
+}
+
+export GIT_TERMINAL_PROMPT=0 GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null
+export GIT_AUTHOR_NAME=Smoke GIT_AUTHOR_EMAIL=smoke@example.com
+export GIT_COMMITTER_NAME=Smoke GIT_COMMITTER_EMAIL=smoke@example.com
+
+alice="-c $WORK/alice -b $WORK/alice"
+bob="-c $WORK/bob -b $WORK/bob"
+
+echo "== build and start"
+docker build -q -f Containerfile -t "$IMAGE" . >/dev/null
+VOLUME=$(docker volume create)
+docker run -d --name "$NAME" -p "127.0.0.1:$PORT:8080" \
+ -v "$VOLUME:/var/lib/git-forge" -e GIT_FORGE_BASE_URL="$BASE" "$IMAGE" >/dev/null
+
+for _ in 1 2 3 4 5 6 7 8 9 10; do
+ sleep 1
+ curl -sf "$BASE/healthz" >/dev/null 2>&1 && break
+done
+curl -sf "$BASE/healthz" >/dev/null || no "the container never answered /healthz"
+ok "/healthz answers"
+
+echo "== the first admin"
+docker logs "$NAME" > "$WORK/log" 2>&1
+TOKEN=$(grep -o 'token: .*' "$WORK/log" | cut -d' ' -f2)
+[ -n "$TOKEN" ] || no "no setup token in the log"
+curl -s $alice "$BASE/setup" > "$WORK/p"
+want "the setup page is served" "Setup token" "$WORK/p"
+code "the setup form makes the first admin" 303 $alice -o /dev/null \
+ -d "csrf=$(csrf "$WORK/p")&token=$TOKEN&name=alice&password=hunter2hunter2&again=hunter2hunter2" "$BASE/setup"
+code "and then /setup is gone" 404 "$BASE/setup"
+
+echo "== the admin has to hold a second factor"
+code "an admin with no authenticator is sent to enrol" 303 $alice -o /dev/null "$BASE/admin"
+curl -s $alice "$BASE/settings/totp" > "$WORK/p"
+# Enrolling costs the password too.
+curl -s $alice -o /dev/null -w '%{http_code}' \
+ -d "csrf=$(csrf "$WORK/p")&secret=$SECRET&code=$(totp)" "$BASE/settings/totp" > "$WORK/nopass"
+grep -qx 401 "$WORK/nopass" || no "enrolling a second factor did not ask for the password"
+ok "enrolling a second factor asks for the password"
+curl -s $alice -d "csrf=$(csrf "$WORK/p")&secret=$SECRET&code=$(totp)&current=hunter2hunter2" \
+ "$BASE/settings/totp" > "$WORK/enrolled"
+want "enrolling shows recovery codes once" "recovery codes" "$WORK/enrolled"
+code "and now the admin pages open" 200 $alice "$BASE/admin"
+
+echo "== repositories"
+curl -s $alice "$BASE/admin" > "$WORK/p"
+code "a repository in a section" 303 $alice -o /dev/null \
+ -d "csrf=$(csrf "$WORK/p")&section=infra&name=dns&desc=Authoritative DNS zones&visibility=private&issues=members&wiki=members" \
+ "$BASE/admin/repo/create"
+code "a public repository" 303 $alice -o /dev/null \
+ -d "csrf=$(csrf "$WORK/p")&name=notes&desc=Notes&visibility=public&issues=disabled&wiki=disabled" \
+ "$BASE/admin/repo/create"
+
+# Not an exact list: gc changes the shape over a repository's life. What must
+# never appear is a file the forge wrote.
+docker exec "$NAME" ls -A /var/lib/git-forge/repos/infra/dns.git | sort > "$WORK/bare"
+grep -qvE '^(HEAD|ORIG_HEAD|branches|config|description|hooks|info|logs|objects|packed-refs|refs|shallow|worktrees)$' "$WORK/bare" &&
+ no "the repository holds something that is not git's: $(tr '\n' ' ' < "$WORK/bare")"
+ok "the repository holds nothing but git's own files"
+docker exec "$NAME" cat /var/lib/git-forge/repos/infra/dns.git/config > "$WORK/repoconfig"
+lack "and nothing of the forge's in its config" "unpackLimit" "$WORK/repoconfig"
+
+docker exec "$NAME" git -C /var/lib/git-forge/config log --format='%an %s' > "$WORK/audit"
+want "the change is in the audit log, authored by the admin" "alice repos: create infra/dns" "$WORK/audit"
+
+echo "== a second account, through the one time link"
+curl -s $alice "$BASE/admin" > "$WORK/p"
+curl -s $alice -d "csrf=$(csrf "$WORK/p")&name=bob&email=bob@example.com&code=$(totp)" "$BASE/admin/user/create" > "$WORK/made"
+# Off the page as the template wrote it, so the separators are escaped.
+LINK=$(sed -n 's|.*<pre>\('"$BASE"'/set-password?[^<]*\)</pre>.*|\1|p' "$WORK/made" | sed 's/&amp;/\&/g')
+[ -n "$LINK" ] || no "no set-password link on the admin page"
+ok "creating an account shows a set-password link"
+sleep 1
+docker logs "$NAME" > "$WORK/log" 2>&1
+want "mail is off, so the log names the message it dropped" 'dropped "\[git-forge\] set a password for bob"' "$WORK/log"
+lack "and never carries the link" "set-password?" "$WORK/log"
+curl -s $bob "$LINK" > "$WORK/p"
+code "following it sets the password and confirms the address" 303 $bob -o /dev/null \
+ --data-urlencode "csrf=$(csrf "$WORK/p")" \
+ --data-urlencode "name=$(field "$WORK/p" name)" \
+ --data-urlencode "expires=$(field "$WORK/p" expires)" \
+ --data-urlencode "t=$(field "$WORK/p" t)" \
+ --data-urlencode "new=bobbobbob1" --data-urlencode "again=bobbobbob1" "$BASE/set-password"
+code "and the link does not work twice" 404 "$LINK"
+
+echo "== grants and push tokens"
+curl -s $alice "$BASE/infra/dns/settings" > "$WORK/p"
+code "bob is granted write" 303 $alice -o /dev/null \
+ -d "csrf=$(csrf "$WORK/p")&account=bob&level=write&code=$(totp)" "$BASE/infra/dns/settings/grant"
+sleep 1
+docker logs "$NAME" > "$WORK/log" 2>&1
+want "the grant change is a message, dropped by name" "access to infra/dns changed" "$WORK/log"
+
+curl -s $bob "$BASE/settings" > "$WORK/p"
+curl -s $bob -d "csrf=$(csrf "$WORK/p")&repo=infra/dns" "$BASE/settings/tokens" > "$WORK/minted"
+PUSH=$(sed -n 's/.*<pre id="token">\(.*\)<\/pre>.*/\1/p' "$WORK/minted")
+[ -n "$PUSH" ] || no "no push token was minted"
+ok "bob mints a push token"
+
+echo "== git over http"
+cd "$WORK"
+# 2>/dev/null only silences git's note about the repository being empty; a
+# clone that actually fails still stops the script.
+git clone -q "http://bob:$PUSH@127.0.0.1:$PORT/infra/dns.git" dns 2>/dev/null
+printf 'example.com. IN A 192.0.2.1\n' > dns/zone.txt
+printf 'package main\n\n// the zone loader\nfunc main() { n := 42; _ = n }\n' > dns/load.go
+printf '# infra/dns\n\nThe *zones*.\n' > dns/README.md
+# A page that would like to run on this origin must come back as text.
+printf '<html><body><script>alert(document.domain)</script></body></html>\n' > dns/page.html
+git -C dns add -A
+git -C dns commit -qm "dns: add a zone"
+git -C dns push -q origin master
+ok "push over http with the token"
+
+git clone -q "$BASE/notes.git" notes 2>/dev/null
+ok "a public repository clones anonymously"
+git clone -q "$BASE/infra/dns.git" nope 2>"$WORK/refused" && no "a private repository cloned anonymously"
+code "a private repository refuses anonymous access with 401" 401 "$BASE/infra/dns.git/info/refs?service=git-upload-pack"
+
+echo "== issues"
+curl -s $alice "$BASE/infra/dns/settings" > "$WORK/p"
+curl -s $alice -o /dev/null -d "csrf=$(csrf "$WORK/p")&desc=Authoritative DNS zones&visibility=private&issues=members&wiki=members&labels=bug
+docs" "$BASE/infra/dns/settings"
+curl -s $alice "$BASE/infra/dns/issues/new" > "$WORK/p"
+code "alice opens an issue" 303 $alice -o /dev/null \
+ --data-urlencode "csrf=$(csrf "$WORK/p")" \
+ --data-urlencode "title=Zone transfer fails on the secondary" \
+ --data-urlencode "body=The **secondary** does not answer AXFR." \
+ --data-urlencode "label=bug" "$BASE/infra/dns/issues"
+curl -s $alice "$BASE/infra/dns/issues/1" > "$WORK/issue"
+want "the issue renders its markdown" "<strong>secondary</strong>" "$WORK/issue"
+want "and its label" "bug" "$WORK/issue"
+code "alice comments" 303 $alice -o /dev/null \
+ --data-urlencode "csrf=$(csrf "$WORK/issue")" \
+ --data-urlencode "body=Confirmed on ns2 as well." "$BASE/infra/dns/issues/1/comment"
+curl -s $alice "$BASE/infra/dns/issues?q=ns2" > "$WORK/search"
+want "search finds it through the comment" "Zone transfer fails" "$WORK/search"
+
+sleep 1
+docker logs "$NAME" > "$WORK/log" 2>&1
+want "the new issue is a message to the grantee" "infra/dns#1 opened" "$WORK/log"
+
+git clone -q "http://bob:$PUSH@127.0.0.1:$PORT/infra/dns.issues.git" issues
+git -C issues show master:issues/1.json | python3 -m json.tool > /dev/null || no "the tracker's json does not parse"
+ok "the tracker clones and its json parses"
+git -C issues commit -q --allow-empty -m "issues: hand written"
+git -C issues push origin master > "$WORK/pushfail" 2>&1 && no "the tracker accepted a push"
+want "the tracker refuses a push, with a reason" "written by the forge" "$WORK/pushfail"
+
+echo "== wiki"
+curl -s $alice "$BASE/infra/dns/wiki?edit" > "$WORK/p"
+curl -s $alice -o /dev/null --data-urlencode "csrf=$(csrf "$WORK/p")" --data-urlencode "base=" \
+ --data-urlencode "body=# Zones
+
+Written in the browser." --data-urlencode "action=save" "$BASE/infra/dns/wiki/"
+curl -s $alice "$BASE/infra/dns/wiki" > "$WORK/p"
+want "a wiki page written in the browser renders" "Written in the browser." "$WORK/p"
+
+git clone -q "http://bob:$PUSH@127.0.0.1:$PORT/infra/dns.wiki.git" wiki
+printf '# Zones\n\nEdited by push.\n' > wiki/index.md
+git -C wiki add -A
+git -C wiki commit -qm "wiki: edit by push"
+git -C wiki push -q origin master
+curl -s $alice "$BASE/infra/dns/wiki" > "$WORK/p"
+want "and a push to the wiki changes the rendered page" "Edited by push." "$WORK/p"
+
+echo "== browsing"
+curl -s "$BASE/cgit.css" > "$WORK/style"
+curl -s $alice "$BASE/infra/dns/" > "$WORK/summary"
+want "the forge's header is injected into a cgit page" 'id="header"' "$WORK/summary"
+want "with the description from repos.json" "Authoritative DNS zones" "$WORK/summary"
+want "and cgit's own tabs under it" "class='tabs'" "$WORK/summary"
+curl -s $alice "$BASE/infra/dns/issues" > "$WORK/forgepage"
+want "the same header is on the forge's own pages" 'id="header"' "$WORK/forgepage"
+
+# Without cgit's own stylesheet, at the path cgit links to, every page is
+# unstyled.
+code "the one stylesheet is served where cgit looks for it" 200 "$BASE/cgit.css"
+want "and carries the forge's as well as cgit's" "highlight's classes" "$WORK/style"
+want "and cgit's pages ask for it rather than for a second one" "/cgit.css" "$WORK/summary"
+lack "which is the only stylesheet there is" "static/forge.css" "$WORK/summary"
+# cgit has no tab called issues, so finding one means the forge's were injected
+# into cgit's own row rather than sitting in a second menu.
+want "the forge's sections are inside cgit's own tab row" ">issues</a>" "$WORK/summary"
+lack "and there is only one tab row" "<table class=\"tabs\"" "$WORK/summary"
+lack "and one element with cgit's id on the page" "id=\"cgit\"" "$WORK/summary"
+
+# The forge's pages copy cgit's tab list, and a copy that is not checked
+# drifts.
+tablist() {
+ sed -n 's/.*<table class=.tabs.><tr><td>//p' "$1" |
+ grep -o '>[a-z]*</a>' | sed 's/>\(.*\)<\/a>/\1/' | tr '\n' ' '
+}
+if [ "$(tablist "$WORK/summary")" != "$(tablist "$WORK/forgepage")" ]; then
+ printf 'FAIL the forge'"'"'s tab row has drifted from cgit'"'"'s\n cgit: %s\n forge: %s\n' \
+ "$(tablist "$WORK/summary")" "$(tablist "$WORK/forgepage")" >&2
+ exit 1
+fi
+ok "the forge's copy of cgit's tab row still matches cgit's"
+want "with the decorations that make a log line readable" "a.deco" "$WORK/style"
+
+curl -s $alice "$BASE/infra/dns/tree/load.go" > "$WORK/blob"
+want "a source blob comes back with highlight's markup" 'class="hl ' "$WORK/blob"
+curl -s $alice "$BASE/infra/dns/about/" > "$WORK/about"
+want "and the about page through the markdown filter" "<em>zones</em>" "$WORK/about"
+curl -s -D "$WORK/head" -o "$WORK/blobbody" $alice "$BASE/infra/dns/plain/page.html"
+want "a blob comes back as text, never as something a browser runs" "text/plain" "$WORK/head"
+
+curl -s "$BASE/" > "$WORK/index"
+want "the index shows a public repository to anonymous visitors" "notes" "$WORK/index"
+want "with the same header on the index" 'id="header"' "$WORK/index"
+want "and the columns it has" "Description" "$WORK/index"
+lack "and hides a private one" "infra/dns" "$WORK/index"
+
+echo "== no javascript, and readable in lynx"
+ROUTES="/ /login /reset /settings /admin /infra/dns/ /infra/dns/log/ \
+/infra/dns/tree/ /infra/dns/tree/load.go /infra/dns/refs/ /infra/dns/issues \
+/infra/dns/issues/1 /infra/dns/issues/new /infra/dns/wiki /infra/dns/wiki?edit \
+/infra/dns/settings /infra/dns.wiki/ /notes/ /nothing-here"
+for route in $ROUTES; do
+ curl -s $alice "$BASE$route" > "$WORK/page"
+ grep -q '<script' "$WORK/page" && no "a script tag on $route"
+ lynx -dump -stdin -nolist < "$WORK/page" > "$WORK/text" 2>/dev/null || no "lynx could not render $route"
+ [ -s "$WORK/text" ] || no "$route renders empty in lynx"
+done
+ok "every route is script free and renders in lynx"
+
+lynx -dump -stdin -nolist < "$WORK/issue" | grep -q "Zone transfer fails on the secondary" ||
+ no "the issue title is not readable in lynx"
+ok "the issue reads correctly through lynx -dump"
+
+# The header says so to the browser too, on cgit's pages as much as the
+# forge's.
+curl -s -D "$WORK/head" -o /dev/null $alice "$BASE/infra/dns/"
+want "cgit's pages carry the script policy" "script-src 'none'" "$WORK/head"
+curl -s -D "$WORK/head" -o /dev/null $alice "$BASE/infra/dns/issues"
+want "and so do the forge's" "script-src 'none'" "$WORK/head"
+
+echo "== one process to a datadir"
+# timeout, so that a lock that is not taken fails this check rather than
+# hanging it: without the lock the second process serves happily for ever.
+timeout 30 docker run --rm -v "$VOLUME:/var/lib/git-forge" "$IMAGE" > "$WORK/second" 2>&1 &&
+ no "a second process served a datadir that was already being served"
+want "a second process refuses to start, and says why" "already being served" "$WORK/second"
+
+echo "== shutdown"
+docker stop -t 10 "$NAME" >/dev/null
+docker inspect -f '{{.State.ExitCode}}' "$NAME" | grep -qx 0 || no "SIGTERM did not exit zero"
+ok "SIGTERM drains and exits zero"
+
+echo
+echo "smoke: all checks passed"
diff --git a/totp_test.go b/totp_test.go
new file mode 100644
index 0000000..1c5b240
--- /dev/null
+++ b/totp_test.go
@@ -0,0 +1,517 @@
+package main
+
+import (
+ "maps"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+)
+
+// The RFC's own vectors, which is the only way to know an authenticator will
+// agree.
+func TestTOTPAgainstRFC6238(t *testing.T) {
+ // The RFC's SHA-1 key is the ASCII "12345678901234567890".
+ secret := knownSecret
+ cases := map[int64]string{
+ 59: "287082",
+ 1111111109: "081804",
+ 1111111111: "050471",
+ 1234567890: "005924",
+ 2000000000: "279037",
+ }
+ for at, want := range cases {
+ if got := totpCode(secret, time.Unix(at, 0)); got != want {
+ t.Errorf("at %d: %s, wanted %s", at, got, want)
+ }
+ }
+ if totpCode("not base32 !!", time.Now()) != "" {
+ t.Error("an unreadable secret produced a code")
+ }
+}
+
+func TestTOTPSkew(t *testing.T) {
+ secret := totpSecret()
+ now := time.Now()
+ if totpStep(secret, totpCode(secret, now), now) < 0 {
+ t.Error("the current code was refused")
+ }
+ if totpStep(secret, totpCode(secret, now.Add(-totpPeriod)), now) < 0 {
+ t.Error("the previous code was refused")
+ }
+ if totpStep(secret, totpCode(secret, now.Add(-5*totpPeriod)), now) >= 0 {
+ t.Error("a code from two minutes ago was accepted")
+ }
+ if totpStep(secret, "000000 ", now) >= 0 && totpCode(secret, now) != "000000" {
+ t.Error("a wrong code was accepted")
+ }
+ // The step reported is the one the code was made for, which is what stops
+ // the previous code once the current one has been used.
+ if got, want := totpStep(secret, totpCode(secret, now.Add(-totpPeriod)), now), now.Unix()/30-1; got != want {
+ t.Errorf("the previous code reports step %d, wanted %d", got, want)
+ }
+}
+
+func TestTOTPLoginAndRecovery(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ h := f.routes()
+
+ session := signIn(t, f, h, "alice")
+ token := f.csrf(withCookies("/", session))
+
+ // Enrolling needs a code that proves the authenticator has the secret.
+ page := get(h, "/settings/totp", session).Body.String()
+ secret := between(page, `name="secret" value="`, `"`)
+ if secret == "" {
+ t.Fatal("no proposed secret on the enrolment page")
+ }
+ // Enrolling costs the password as well as a code.
+ bare := url.Values{"csrf": {token}, "secret": {secret}, "code": {totpCode(secret, time.Now())}}
+ if w := post(h, "/settings/totp", bare, session); w.Code != http.StatusUnauthorized {
+ t.Errorf("enrolling with no password: status %d", w.Code)
+ }
+ wrong := url.Values{"csrf": {token}, "secret": {secret}, "code": {"000000"}, "current": {"hunter2hunter2"}}
+ retry := post(h, "/settings/totp", wrong, session)
+ if retry.Code != http.StatusBadRequest {
+ t.Errorf("enrolling with a wrong code: status %d", retry.Code)
+ }
+ // The page says to try the next code, which is only possible if the secret
+ // is still the one already typed into the authenticator.
+ if got := between(retry.Body.String(), `name="secret" value="`, `"`); got != secret {
+ t.Errorf("a wrong code rotated the secret to %q", got)
+ }
+ w := post(h, "/settings/totp", url.Values{
+ "csrf": {token}, "secret": {secret}, "code": {totpCode(secret, time.Now())},
+ "current": {"hunter2hunter2"}}, session)
+ if w.Code != http.StatusOK {
+ t.Fatalf("enrolling: status %d", w.Code)
+ }
+ codes := recoveryFrom(w.Body.String())
+ if len(codes) != recoveryCodes {
+ t.Fatalf("expected %d recovery codes, got %v", recoveryCodes, codes)
+ }
+ if u, _ := f.store.User("alice"); u.TOTP == nil || u.TOTP.Secret != secret || u.Epoch == 0 {
+ t.Errorf("enrolment did not stick, or did not bump the epoch: %+v", u.TOTP)
+ }
+
+ // The password alone now stops at the second step.
+ first := post(h, "/login", url.Values{"name": {"alice"}, "password": {"hunter2hunter2"}}, nil)
+ if first.Code != http.StatusOK || !strings.Contains(first.Body.String(), "Authenticator code") {
+ t.Fatalf("password alone: status %d", first.Code)
+ }
+ if len(first.Result().Cookies()) != 0 {
+ t.Error("the password alone issued a session")
+ }
+ pending := url.Values{
+ "csrf": {f.csrf(withCookies("/", nil))},
+ "name": {between(first.Body.String(), `name="name" value="`, `"`)},
+ "expires": {between(first.Body.String(), `name="expires" value="`, `"`)},
+ "t": {between(first.Body.String(), `name="t" value="`, `"`)},
+ }
+
+ badCode := maps.Clone(pending)
+ badCode.Set("code", "000000")
+ if w := post(h, "/login/totp", badCode, nil); w.Code != http.StatusUnauthorized {
+ t.Errorf("a wrong code: status %d", w.Code)
+ }
+ right := maps.Clone(pending)
+ right.Set("code", freshCode(t, f, "alice", secret))
+ if w := post(h, "/login/totp", right, nil); w.Code != http.StatusSeeOther || len(w.Result().Cookies()) == 0 {
+ t.Errorf("the right code: status %d", w.Code)
+ }
+ // A code works once, however long its window has left.
+ if w := post(h, "/login/totp", right, nil); w.Code != http.StatusUnauthorized || len(w.Result().Cookies()) != 0 {
+ t.Errorf("the same code again: status %d", w.Code)
+ }
+
+ // A recovery code works once.
+ recovery := maps.Clone(pending)
+ recovery.Set("code", codes[0])
+ if w := post(h, "/login/totp", recovery, nil); w.Code != http.StatusSeeOther {
+ t.Errorf("a recovery code: status %d", w.Code)
+ }
+ if u, _ := f.store.User("alice"); len(u.TOTP.Recovery) != recoveryCodes-1 {
+ t.Errorf("the recovery code was not spent: %d left", len(u.TOTP.Recovery))
+ }
+ if w := post(h, "/login/totp", recovery, nil); w.Code != http.StatusUnauthorized {
+ t.Errorf("the same recovery code twice: status %d", w.Code)
+ }
+}
+
+// The flag is not enough on its own.
+func TestAdminNeedsTOTP(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ err := f.store.UpdateUsers("users: admin", f.store.forgeAuthor(), func(m map[string]User) error {
+ u := m["alice"]
+ u.Admin = true
+ m["alice"] = u
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ h := f.routes()
+ session := signIn(t, f, h, "alice")
+
+ w := get(h, "/admin", session)
+ if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/settings/totp" {
+ t.Fatalf("an admin with no authenticator reached /admin: %d %s", w.Code, w.Header().Get("Location"))
+ }
+
+ token := f.csrf(withCookies("/", session))
+ secret := between(get(h, "/settings/totp", session).Body.String(), `name="secret" value="`, `"`)
+ enrolled := post(h, "/settings/totp", url.Values{
+ "csrf": {token}, "secret": {secret}, "code": {totpCode(secret, time.Now())},
+ "current": {"hunter2hunter2"}}, session)
+ session = enrolled.Result().Cookies()
+ if get(h, "/admin", session).Code != http.StatusOK {
+ t.Error("an enrolled admin still cannot reach /admin")
+ }
+
+ // And an admin may not take the second factor off again.
+ token = f.csrf(withCookies("/", session))
+ form := url.Values{"csrf": {token}, "current": {"hunter2hunter2"}}
+ if w := post(h, "/settings/totp/remove", form, session); w.Code != http.StatusForbidden {
+ t.Errorf("an admin removed their authenticator: status %d", w.Code)
+ }
+}
+
+// Enrolling and reissuing recovery codes both outlive the session they were
+// done from, so both cost the password.
+func TestSecondFactorNeedsPassword(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "victim", "hunter2hunter2")
+ h := f.routes()
+ stolen := signIn(t, f, h, "victim")
+ token := f.csrf(withCookies("/", stolen))
+ secret := knownSecret
+
+ w := post(h, "/settings/totp", url.Values{
+ "csrf": {token}, "secret": {secret}, "code": {totpCode(secret, time.Now())}}, stolen)
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("a cookie alone enrolled a second factor: status %d", w.Code)
+ }
+ if u, _ := f.store.User("victim"); u.TOTP != nil {
+ t.Fatal("the account was taken with a cookie alone")
+ }
+ // The retry has to offer the same secret, or the authenticator that was
+ // just set up by hand no longer matches.
+ if got := between(w.Body.String(), `name="secret" value="`, `"`); got != secret {
+ t.Errorf("the retry offered a different secret: %q", got)
+ }
+
+ w = post(h, "/settings/totp", url.Values{
+ "csrf": {token}, "secret": {secret}, "code": {totpCode(secret, time.Now())},
+ "current": {"hunter2hunter2"}}, stolen)
+ if w.Code != http.StatusOK {
+ t.Fatalf("enrolling with the password: status %d", w.Code)
+ }
+ session := w.Result().Cookies()
+ token = f.csrf(withCookies("/", session))
+
+ if w := post(h, "/settings/totp/codes", url.Values{"csrf": {token}}, session); w.Code != http.StatusUnauthorized {
+ t.Errorf("recovery codes regenerated without a password: status %d", w.Code)
+ }
+ w = post(h, "/settings/totp/codes", url.Values{"csrf": {token}, "current": {"hunter2hunter2"}}, session)
+ if w.Code != http.StatusOK || len(recoveryFrom(w.Body.String())) != recoveryCodes {
+ t.Errorf("regenerating with the password: status %d", w.Code)
+ }
+}
+
+// An external account has no password, so the enrolled authenticator is what
+// stands between a stolen cookie and losing the second factor.
+func TestExternalSecondFactorNeedsACode(t *testing.T) {
+ f := testForge(t)
+ err := f.store.UpdateUsers("users: add sso:carol", f.store.forgeAuthor(), func(m map[string]User) error {
+ m["sso:carol"] = User{Origin: originOIDC, Iss: "https://idp.example", Sub: "carol", Notify: true}
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ addTOTP(t, f, "sso:carol")
+ h := f.routes()
+
+ // No password and no provider round trip: the cookie is all the thief has.
+ stolen := httptest.NewRecorder()
+ f.setSession(stolen, "sso:carol", 0)
+ session := stolen.Result().Cookies()
+ token := f.csrf(withCookies("/", session))
+
+ for _, path := range []string{"/settings/totp/remove", "/settings/totp/codes"} {
+ if w := post(h, path, url.Values{"csrf": {token}}, session); w.Code != http.StatusForbidden {
+ t.Errorf("%s with the cookie alone: status %d", path, w.Code)
+ }
+ if u, _ := f.store.User("sso:carol"); u.TOTP == nil {
+ t.Fatalf("%s took the second factor off with the cookie alone", path)
+ }
+ }
+
+ code := url.Values{"csrf": {token}, "code": {totpCode(knownSecret, time.Now())}}
+ if w := post(h, "/settings/totp/codes", code, session); w.Code != http.StatusOK {
+ t.Errorf("regenerating with the code: status %d", w.Code)
+ }
+ code.Set("code", freshCode(t, f, "sso:carol", knownSecret))
+ if w := post(h, "/settings/totp/remove", code, session); w.Code != http.StatusSeeOther {
+ t.Errorf("removing with the code: status %d", w.Code)
+ }
+ if u, _ := f.store.User("sso:carol"); u.TOTP != nil {
+ t.Error("the code was right and the authenticator is still there")
+ }
+}
+
+// The way back in when both the authenticator and the recovery codes are gone.
+func TestAdminClearsLostAuthenticator(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addUser(t, f, "bob", "hunter2hunter2")
+ addAdmin(t, f, "alice")
+ addTOTP(t, f, "bob")
+ h := f.routes()
+ session := codeSession(t, f, h, "alice", "hunter2hunter2")
+ token := f.csrf(withCookies("/", session))
+
+ // Clearing somebody's second factor hands out access, so it asks for a code.
+ bare := url.Values{"csrf": {token}, "name": {"bob"}}
+ if w := post(h, "/admin/user/totp", bare, session); w.Code != http.StatusForbidden {
+ t.Errorf("cleared bob's factor with no code of our own: status %d", w.Code)
+ }
+ if u, _ := f.store.User("bob"); u.TOTP == nil {
+ t.Fatal("it was cleared anyway")
+ }
+ withCode := func(name string) url.Values {
+ return url.Values{"csrf": {token}, "name": {name}, "code": {freshCode(t, f, "alice", knownSecret)}}
+ }
+ if w := post(h, "/admin/user/totp", withCode("alice"), session); w.Code != http.StatusForbidden {
+ t.Errorf("an admin cleared their own factor from the admin page: status %d", w.Code)
+ }
+ if w := post(h, "/admin/user/totp", withCode("bob"), session); w.Code != http.StatusSeeOther {
+ t.Fatalf("clearing bob: status %d", w.Code)
+ }
+ if u, _ := f.store.User("bob"); u.TOTP != nil || u.Epoch == 0 {
+ t.Error("bob's factor was not cleared, or his sessions were left alive")
+ }
+ if w := post(h, "/login", url.Values{"name": {"bob"}, "password": {"hunter2hunter2"}}, nil); w.Code != http.StatusSeeOther {
+ t.Errorf("bob cannot get back in: status %d", w.Code)
+ }
+}
+
+// The budget is for guessing, and it is the same one the login form spends. An
+// admin charged for every code that was right would lock themselves out.
+func TestConfirmCodeCountsWrongCodesOnly(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addUser(t, f, "bob", "hunter2hunter2")
+ addAdmin(t, f, "alice")
+ h := f.routes()
+ session := codeSession(t, f, h, "alice", "hunter2hunter2")
+ token := f.csrf(withCookies("/", session))
+ // Signing in spent one of these, which is its own rule. Count from here.
+ f.totpLimit = newLimiter()
+
+ right := url.Values{"csrf": {token}, "name": {"bob"}, "admin": {"yes"}}
+ for i := range 5 {
+ right.Set("code", freshCode(t, f, "alice", knownSecret))
+ if w := post(h, "/admin/user/admin", right, session); w.Code != http.StatusSeeOther {
+ t.Fatalf("admin action %d: status %d", i, w.Code)
+ }
+ }
+ if f.totpLimit.over(accountKey("alice"), 0) {
+ t.Error("codes that were right spent the budget for guessing")
+ }
+
+ wrong := url.Values{"csrf": {token}, "name": {"bob"}, "admin": {"yes"}, "code": {"000000"}}
+ for range 3 {
+ if w := post(h, "/admin/user/admin", wrong, session); w.Code != http.StatusForbidden {
+ t.Fatalf("a wrong code was accepted: status %d", w.Code)
+ }
+ }
+ // The code that was just accepted is refused a second time, and counted.
+ if w := post(h, "/admin/user/admin", right, session); w.Code != http.StatusForbidden {
+ t.Errorf("a code used already was accepted again: status %d", w.Code)
+ }
+ if !f.totpLimit.over(accountKey("alice"), 3) {
+ t.Error("wrong codes were not counted")
+ }
+ if f.totpLimit.over(accountKey("alice"), 4) {
+ t.Error("more was counted than was tried")
+ }
+}
+
+// The secret arrives in a form, so what is enrolled is only as strong as this
+// check makes it.
+func TestEnrolledSecretMustBeTheMintedSize(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ h := f.routes()
+ session := signIn(t, f, h, "alice")
+ for _, secret := range []string{"AA", "", "!!!", knownSecret + "GE"} {
+ w := post(h, "/settings/totp", url.Values{
+ "csrf": {f.csrf(withCookies("/", session))}, "secret": {secret},
+ "code": {totpCode(secret, time.Now())}, "current": {"hunter2hunter2"},
+ }, session)
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("a secret of %d characters: status %d", len(secret), w.Code)
+ }
+ }
+ if u, _ := f.store.User("alice"); u.TOTP != nil {
+ t.Error("a secret this forge would not mint was enrolled")
+ }
+}
+
+// Two logins with one recovery code, from the same read of the account: the
+// write decides, not the read.
+func TestRecoveryCodeIsSpentInTheWrite(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ show, keep := newRecoveryCodes()
+ err := f.store.UpdateUser("alice", "users: enrol", f.store.forgeAuthor(), func(acct *User) error {
+ acct.TOTP = &TOTP{Secret: knownSecret, Recovery: keep}
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ stale, _ := f.store.User("alice")
+ if !f.spendRecoveryCode("alice", stale, show[0]) {
+ t.Fatal("a fresh recovery code was refused")
+ }
+ if f.spendRecoveryCode("alice", stale, show[0]) {
+ t.Error("the same recovery code was accepted twice")
+ }
+ if f.spendRecoveryCode("alice", stale, "not-a-code") {
+ t.Error("a made up recovery code was accepted")
+ }
+ if u, _ := f.store.User("alice"); len(u.TOTP.Recovery) != recoveryCodes-1 {
+ t.Errorf("%d recovery codes left, wanted %d", len(u.TOTP.Recovery), recoveryCodes-1)
+ }
+}
+
+// knownSecret is the base32 of the ASCII "12345678901234567890", the key the
+// RFC's own vectors use.
+const knownSecret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
+
+// addTOTP enrols the known authenticator on an account, so that totpCode can
+// answer for it.
+func addTOTP(t *testing.T, f *Forge, name string) {
+ t.Helper()
+ err := f.store.UpdateUsers("users: enrol "+name, f.store.forgeAuthor(), func(m map[string]User) error {
+ u := m[name]
+ u.TOTP = &TOTP{Secret: knownSecret, Recovery: []string{}}
+ m[name] = u
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+// freshCode clears the step the account last accepted and returns the current
+// code. A code works once, and a test cannot wait thirty seconds for the next.
+func freshCode(t *testing.T, f *Forge, name, secret string) string {
+ t.Helper()
+ err := f.store.touchUser(name, func(acct *User) error {
+ if acct.TOTP != nil {
+ acct.TOTP.Used = 0
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return totpCode(secret, time.Now())
+}
+
+// addAdmin hands out the global admin flag, with the authenticator the flag
+// does nothing without.
+func addAdmin(t *testing.T, f *Forge, name string) {
+ t.Helper()
+ addTOTP(t, f, name)
+ err := f.store.UpdateUsers("users: admin "+name, f.store.forgeAuthor(), func(m map[string]User) error {
+ u := m[name]
+ u.Admin = true
+ m[name] = u
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+// codeSession signs an account holding the known authenticator all the way
+// in, through both steps.
+func codeSession(t *testing.T, f *Forge, h http.Handler, name, password string) []*http.Cookie {
+ t.Helper()
+ first := post(h, "/login", url.Values{"name": {name}, "password": {password}}, nil)
+ if !strings.Contains(first.Body.String(), "Authenticator code") {
+ t.Fatal("expected a second step")
+ }
+ w := post(h, "/login/totp", url.Values{
+ "csrf": {f.csrf(withCookies("/", nil))},
+ "name": {between(first.Body.String(), `name="name" value="`, `"`)},
+ "expires": {between(first.Body.String(), `name="expires" value="`, `"`)},
+ "t": {between(first.Body.String(), `name="t" value="`, `"`)},
+ "code": {totpCode(knownSecret, time.Now())},
+ }, nil)
+ if w.Code != http.StatusSeeOther {
+ t.Fatalf("second step: %d", w.Code)
+ }
+ return w.Result().Cookies()
+}
+
+func between(s, start, end string) string {
+ _, rest, ok := strings.Cut(s, start)
+ if !ok {
+ return ""
+ }
+ out, _, _ := strings.Cut(rest, end)
+ return out
+}
+
+func recoveryFrom(page string) []string {
+ block := between(page, "<pre>", "</pre>")
+ var codes []string
+ for _, line := range strings.Split(block, "\n") {
+ if line = strings.TrimSpace(line); line != "" {
+ codes = append(codes, line)
+ }
+ }
+ return codes
+}
+
+// Removing it is the other half of enrolling: it costs the password too, and
+// it takes every session with it.
+func TestSecondFactorIsRemoved(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "bob", "hunter2hunter2")
+ addTOTP(t, f, "bob")
+ h := f.routes()
+ session := codeSession(t, f, h, "bob", "hunter2hunter2")
+ token := f.csrf(withCookies("/", session))
+
+ if w := post(h, "/settings/totp/remove", url.Values{"csrf": {token}}, session); w.Code != http.StatusUnauthorized {
+ t.Fatalf("removing with no password: status %d", w.Code)
+ }
+ if u, _ := f.store.User("bob"); u.TOTP == nil {
+ t.Fatal("it was removed anyway")
+ }
+ form := url.Values{"csrf": {token}, "current": {"hunter2hunter2"}}
+ if w := post(h, "/settings/totp/remove", form, session); w.Code != http.StatusSeeOther {
+ t.Fatalf("removing with the password: status %d", w.Code)
+ }
+ u, _ := f.store.User("bob")
+ if u.TOTP != nil {
+ t.Error("the authenticator is still enrolled")
+ }
+ if u.Epoch == 0 {
+ t.Error("removing it left every other session alive")
+ }
+ // And the password alone gets back in.
+ if w := post(h, "/login", url.Values{"name": {"bob"}, "password": {"hunter2hunter2"}}, nil); w.Code != http.StatusSeeOther {
+ t.Errorf("signing in after the removal: status %d", w.Code)
+ }
+}
diff --git a/wiki.go b/wiki.go
new file mode 100644
index 0000000..34160ef
--- /dev/null
+++ b/wiki.go
@@ -0,0 +1,303 @@
+package main
+
+import (
+ "errors"
+ "fmt"
+ "html/template"
+ "log"
+ "net/http"
+ "path/filepath"
+ "regexp"
+ "slices"
+ "sort"
+ "strings"
+)
+
+// Wiki pages are markdown in {repo}.wiki.git, paths in the git tree. Rendered
+// here rather than by cgit: clean URLs, an index from ls-tree, and a history
+// link out to cgit.
+
+// A wiki path component, for reading and writing alike.
+var wikiNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
+
+const maxWikiDepth = 8
+
+func (f *Forge) wikiDir(path string) string {
+ return filepath.Join(f.store.reposDir, path+".wiki.git")
+}
+
+// wikiFiles maps a URL to the tree paths it could mean: the page itself, then
+// that name as a directory. index.md is served at the root of the wiki and of
+// any subdirectory.
+func wikiFiles(page string) ([]string, bool) {
+ page = strings.TrimPrefix(page, "/")
+ page = strings.TrimSuffix(page, ".md")
+ if page == "" || strings.HasSuffix(page, "/") {
+ page += "index"
+ if !validWikiPage(page) {
+ return nil, false
+ }
+ return []string{page + ".md"}, true
+ }
+ if !validWikiPage(page) {
+ return nil, false
+ }
+ return []string{page + ".md", page + "/index.md"}, true
+}
+
+func validWikiPage(page string) bool {
+ parts := strings.Split(page, "/")
+ if len(parts) > maxWikiDepth {
+ return false
+ }
+ for _, part := range parts {
+ if !wikiNameRE.MatchString(part) {
+ return false
+ }
+ }
+ return true
+}
+
+// wikiPage is the reverse: a tree path as it appears in a URL. Only a whole
+// index component is dropped: myindex.md is a page called myindex.
+func wikiPage(file string) string {
+ page := strings.TrimSuffix(file, ".md")
+ if page == "index" {
+ return ""
+ }
+ if rest, ok := strings.CutSuffix(page, "/index"); ok {
+ return rest + "/"
+ }
+ return page
+}
+
+type wikiView struct {
+ Path string // the repository
+ Page string // the page, "" for the root
+ File string // the path in the tree
+ Rendered template.HTML
+ Pages []string
+ Missing bool // no such page yet
+ Empty bool // no pages at all yet
+ Can bool // may edit
+}
+
+type wikiEdit struct {
+ Path string
+ Page string
+ File string
+ Body string
+ Base string // the blob this edit started from, for the compare and swap
+ Preview template.HTML
+}
+
+// handleWiki routes everything under {repo}/wiki.
+func (f *Forge) handleWiki(w http.ResponseWriter, r *http.Request, path string, repo Repo, rest string) {
+ if repo.Wiki == featDisabled {
+ f.fail(w, r, http.StatusNotFound, "this repository has its wiki turned off")
+ return
+ }
+ page := strings.TrimPrefix(strings.TrimPrefix(rest, "wiki"), "/")
+ files, ok := wikiFiles(page)
+ if !ok {
+ f.fail(w, r, http.StatusNotFound, "that is not a usable page name")
+ return
+ }
+
+ // Without javascript a form cannot put a name into a path, so it arrives
+ // as a query.
+ if isGet(r) && r.URL.Query().Get("page") != "" {
+ wanted, ok := wikiFiles(r.URL.Query().Get("page"))
+ if !ok {
+ f.fail(w, r, http.StatusBadRequest,
+ "a page name is letters, digits, dot, dash, and underscore, with / for a subdirectory")
+ return
+ }
+ http.Redirect(w, r, "/"+path+"/wiki/"+wikiPage(wanted[0])+"?edit", http.StatusSeeOther)
+ return
+ }
+
+ switch {
+ case isGet(r) && r.URL.Query().Has("edit"):
+ // Editing is always the page itself, never the directory behind it.
+ f.wikiForm(w, r, path, repo, files[0], "", "")
+ case isGet(r):
+ f.wikiShow(w, r, path, repo, files)
+ case r.Method == http.MethodPost:
+ if f.checkPost(w, r) {
+ f.wikiSave(w, r, path, repo, files[0])
+ }
+ default:
+ f.fail(w, r, http.StatusNotFound, "no such page")
+ }
+}
+
+func (f *Forge) wikiShow(w http.ResponseWriter, r *http.Request, path string, repo Repo, files []string) {
+ dir := f.wikiDir(path)
+ view := wikiView{
+ Path: path, Page: wikiPage(files[0]), File: files[0],
+ Can: f.viewer(r).CanWiki(repo),
+ }
+
+ head, err := resolveRef(dir, branch)
+ if err != nil {
+ // An empty wiki and a missing repository must not read the same.
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return
+ }
+ if head == "" {
+ view.Empty, view.Missing = true, true
+ } else {
+ view.Pages = f.wikiPages(dir, head)
+ view.Missing = true
+ for _, file := range files {
+ if body, err := catFile(dir, head+":"+file); err == nil {
+ view.Page, view.File = wikiPage(file), file
+ view.Rendered = renderMarkdown(string(body))
+ view.Missing = false
+ break
+ }
+ }
+ }
+
+ d := f.page(r)
+ d.Repo = f.repoBar(r, path, "", "wiki", repo)
+ d.Data = view
+ // A wiki with no pages at all is a state, not a 404.
+ status := http.StatusOK
+ if view.Missing && !view.Empty {
+ status = http.StatusNotFound
+ }
+ f.render(w, "wiki.html", status, d)
+}
+
+// wikiPages is the index, straight out of the tree.
+func (f *Forge) wikiPages(dir, head string) []string {
+ paths, err := listTreeRecursive(dir, head)
+ if err != nil {
+ log.Printf("wiki: %v", err)
+ return nil
+ }
+ pages := []string{}
+ for _, p := range paths {
+ if !strings.HasSuffix(p, ".md") {
+ continue
+ }
+ // A push can put a name here that reading refuses, or one that reads
+ // back as another file; a link that does not open its file is worse
+ // than none.
+ page := wikiPage(p)
+ files, ok := wikiFiles(page)
+ if !ok || !slices.Contains(files, p) {
+ continue
+ }
+ pages = append(pages, page)
+ }
+ sort.Strings(pages)
+ return pages
+}
+
+func (f *Forge) wikiForm(w http.ResponseWriter, r *http.Request, path string, repo Repo, file, body, message string) {
+ if !f.viewer(r).CanWiki(repo) {
+ f.fail(w, r, http.StatusForbidden, "you may not edit this wiki")
+ return
+ }
+ dir := f.wikiDir(path)
+ base, err := f.wikiBlob(dir, file)
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return
+ }
+ // An editor opened empty over a page would save it away.
+ if body == "" && base != "" {
+ current, err := catFile(dir, base)
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return
+ }
+ body = string(current)
+ }
+
+ d := f.page(r)
+ d.Repo = f.repoBar(r, path, "", "wiki", repo)
+ d.Error = message
+ d.Data = wikiEdit{Path: path, Page: wikiPage(file), File: file, Body: body, Base: base}
+ f.render(w, "wiki-edit.html", http.StatusOK, d)
+}
+
+// wikiBlob is the page's blob id now. The editor carries it back and wikiSave
+// compares it inside the commit: a compare and swap on the page, not the
+// branch.
+func (f *Forge) wikiBlob(dir, file string) (string, error) {
+ head, err := resolveRef(dir, branch)
+ if err != nil {
+ return "", err
+ }
+ return blobIn(dir, head, file)
+}
+
+// blobIn is the id of one path's blob in one commit, or "" when the repository
+// is empty or the commit does not hold that path.
+func blobIn(dir, commit, file string) (string, error) {
+ if commit == "" {
+ return "", nil
+ }
+ return resolveRef(dir, commit+":"+file)
+}
+
+// errStale is the comparison failing inside the commit.
+var errStale = errors.New("the page changed while it was being edited")
+
+func (f *Forge) wikiSave(w http.ResponseWriter, r *http.Request, path string, repo Repo, file string) {
+ if !f.viewer(r).CanWiki(repo) {
+ f.fail(w, r, http.StatusForbidden, "you may not edit this wiki")
+ return
+ }
+ body := strings.ReplaceAll(r.PostFormValue("body"), "\r\n", "\n")
+ base := r.PostFormValue("base")
+
+ if len(body) > maxBody {
+ f.fail(w, r, http.StatusBadRequest, "that page is too big")
+ return
+ }
+ if r.PostFormValue("action") == "preview" {
+ d := f.page(r)
+ d.Repo = f.repoBar(r, path, "", "wiki", repo)
+ d.Data = wikiEdit{
+ Path: path, Page: wikiPage(file), File: file, Body: body, Base: base,
+ Preview: renderMarkdown(body),
+ }
+ f.render(w, "wiki-edit.html", http.StatusOK, d)
+ return
+ }
+
+ dir := f.wikiDir(path)
+ verb := "update"
+ if base == "" {
+ verb = "create"
+ }
+ if !strings.HasSuffix(body, "\n") {
+ body += "\n"
+ }
+ // Compared inside the commit, or both saves of one page would land.
+ err := commitWith(dir, f.actor(r), f.store.forgeAuthor(), func(commit string) ([]change, string, error) {
+ now, err := blobIn(dir, commit, file)
+ if err != nil {
+ return nil, "", err
+ }
+ if now != base {
+ return nil, "", errStale
+ }
+ return []change{{path: file, data: []byte(body)}}, fmt.Sprintf("wiki: %s %s", verb, file), nil
+ })
+ if errors.Is(err, errStale) {
+ f.wikiForm(w, r, path, repo, file, body,
+ "somebody else changed this page while you were editing it. open it again in another window, merge your text in, and save")
+ return
+ }
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return
+ }
+ http.Redirect(w, r, "/"+path+"/wiki/"+wikiPage(file), http.StatusSeeOther)
+}
diff --git a/wiki_test.go b/wiki_test.go
new file mode 100644
index 0000000..eae2c4e
--- /dev/null
+++ b/wiki_test.go
@@ -0,0 +1,175 @@
+package main
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "sync"
+ "testing"
+)
+
+// A page name has to survive the trip out to a URL and back to a path in the
+// tree, or a page is listed under a link that does not open it.
+func TestWikiPageNames(t *testing.T) {
+ for _, file := range []string{
+ "index.md", "docs/index.md", "notes/deep/index.md",
+ "plain.md", "myindex.md", "a/myindex.md", "index.notes.md",
+ } {
+ page := wikiPage(file)
+ files, ok := wikiFiles(page)
+ if !ok {
+ t.Errorf("%s becomes the page %q, which is not a usable name", file, page)
+ continue
+ }
+ if !slices.Contains(files, file) {
+ t.Errorf("%s becomes the page %q, which reads %v instead", file, page, files)
+ }
+ }
+}
+
+// No pages yet is a state, not a failure.
+func TestEmptyWikiIsNotAnError(t *testing.T) {
+ f := testForge(t)
+ addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featUsers})
+ if code := get(f.routes(), "/notes/wiki", nil).Code; code != http.StatusOK {
+ t.Errorf("an empty wiki answered %d", code)
+ }
+ if code := get(f.routes(), "/notes/wiki/nope", nil).Code; code != http.StatusOK {
+ t.Errorf("a missing page in an empty wiki answered %d", code)
+ }
+}
+
+// A push can put a name in the tree that reading refuses, and a link that
+// answers 404 is worse than no link.
+func TestWikiIndexOnlyListsPagesItCanOpen(t *testing.T) {
+ f := testForge(t)
+ addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featUsers})
+ dir := f.wikiDir("notes")
+ author := f.store.forgeAuthor()
+ err := commitWith(dir, author, author, func(base string) ([]change, string, error) {
+ return []change{
+ {path: "fine.md", data: []byte("# fine\n")},
+ {path: "a b.md", data: []byte("# spaces\n")},
+ {path: ".hidden.md", data: []byte("# a dot\n")},
+ // Both of these read as a page that opens index.md instead, so
+ // listing them offers a link that never reaches the file.
+ {path: ".md", data: []byte("# a file called .md\n")},
+ {path: "index.md.md", data: []byte("# doubled\n")},
+ }, "wiki: written by push", nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ head, err := resolveRef(dir, branch)
+ if err != nil {
+ t.Fatal(err)
+ }
+ pages := f.wikiPages(dir, head)
+ if !slices.Contains(pages, "fine") {
+ t.Errorf("a usable page is not listed: %v", pages)
+ }
+ // One entry per file, and every entry opens the file it came from.
+ if len(pages) != 1 {
+ t.Errorf("three files that cannot be opened were listed anyway: %v", pages)
+ }
+ h := f.routes()
+ for _, page := range []string{"a b", ".hidden"} {
+ if slices.Contains(pages, page) {
+ t.Errorf("%q is listed but cannot be opened: %v", page, pages)
+ }
+ if code := get(h, "/notes/wiki/"+url.PathEscape(page), nil).Code; code != http.StatusNotFound {
+ t.Errorf("%q answered %d, so it could have been listed after all", page, code)
+ }
+ }
+}
+
+// Two people saving one page from the same base must not both be told they
+// saved. Comparing before the commit rather than inside it leaves the whole
+// commit between the check and the write, so this has to be concurrent.
+func TestWikiSaveIsACompareAndSwap(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featUsers})
+ h := f.routes()
+ session := signIn(t, f, h, "alice")
+ token := f.csrf(withCookies("/", session))
+ post(h, "/notes/wiki/page", url.Values{
+ "csrf": {token}, "base": {""}, "action": {"save"}, "body": {"original\n"}}, session)
+
+ dir := f.wikiDir("notes")
+ base, err := f.wikiBlob(dir, "page.md")
+ if err != nil || base == "" {
+ t.Fatalf("the first page did not land: %q %v", base, err)
+ }
+
+ const writers = 4
+ codes := make([]int, writers)
+ var wg sync.WaitGroup
+ for i := range writers {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ codes[i] = post(h, "/notes/wiki/page", url.Values{
+ "csrf": {token}, "base": {base}, "action": {"save"},
+ "body": {fmt.Sprintf("rewritten by %d\n", i)}}, session).Code
+ }()
+ }
+ wg.Wait()
+
+ saved := 0
+ for i, code := range codes {
+ switch code {
+ case http.StatusSeeOther:
+ saved++
+ case http.StatusOK: // handed back the editor and told to merge
+ default:
+ t.Errorf("writer %d: status %d", i, code)
+ }
+ }
+ if saved != 1 {
+ t.Errorf("%d of %d writers from one base were told they saved", saved, writers)
+ }
+ // And the one that did save is what the page holds.
+ head, _ := resolveRef(dir, branch)
+ body, err := catFile(dir, head+":page.md")
+ if err != nil || !strings.HasPrefix(string(body), "rewritten by ") {
+ t.Errorf("the page reads %q, %v", body, err)
+ }
+}
+
+// An empty repository and one that is not there must not look the same, or a
+// bad restore renders as a friendly page. Both at the primitive and at the
+// page that shows it.
+func TestMissingRepoIsAnError(t *testing.T) {
+ f := testForge(t)
+ addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featUsers})
+ h := f.routes()
+ dir := filepath.Join(f.store.reposDir, "notes.wiki.git")
+ if head, err := resolveRef(dir, branch); head != "" || err != nil {
+ t.Fatalf("an empty repository should be empty and fine: %q %v", head, err)
+ }
+ if code := get(h, "/notes/wiki", nil).Code; code != http.StatusOK {
+ t.Fatalf("an empty wiki answered %d", code)
+ }
+ if err := os.RemoveAll(dir); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := resolveRef(dir, branch); err == nil {
+ t.Error("a missing repository still looks like an empty one")
+ }
+ if code := get(h, "/notes/wiki", nil).Code; code != http.StatusInternalServerError {
+ t.Errorf("a wiki with no repository behind it answered %d", code)
+ }
+ // And the tracker, which is the same question at the other door.
+ if err := os.RemoveAll(f.issuesDir("notes")); err != nil {
+ t.Fatal(err)
+ }
+ if code := get(h, "/notes/issues", nil).Code; code != http.StatusInternalServerError {
+ t.Errorf("a tracker with no repository behind it answered %d", code)
+ }
+}