Skip to content

Commit 35bb6a6

Browse files
forge: personal git forge — push-to-create hosting + GitHub-light UI + JSON API
Tier 1 of the Gogs/Gitea-parity track: persistent per-owner bare repos (push-to-create in your own namespace, anon 401, cross-namespace 403), smart HTTP via the real git-http-backend (gitscratch's CGI pattern, streamed pack bodies), and a zero-dep zero-build web UI in GitHub's light palette — file table, rendered README (hand-rolled escape-first markdown subset, test-proven inert <script>), paginated commits, green/red structured diffs, branches/tags, raw. A JSON API under /forge/api/* exposes the same model (repo meta, tree, blob, commits, structured diff) — the scriptable Gitea-parity surface; one diff parser feeds both HTML and JSON so they can't drift. Findings: stored-XSS neutralized by content-type (never text/html on raw) instead of GitHub's separate raw domain; counter-witness — needed neither reservePath nor mountApp; tier-2 wants api.authorize (collaborators), api.events (webhooks), and a pod namespace for did:nostr agents. 27/27 tests, real git CLI end to end.
1 parent 265ef2d commit 35bb6a6

3 files changed

Lines changed: 2012 additions & 0 deletions

File tree

forge/README.md

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
# forge — a personal git forge (tier 1: hosting + browsing)
2+
3+
The useful slice of Gogs/Gitea as a JSS plugin: push a repo over smart
4+
HTTP, get a GitHub-style (light theme) web UI for it — repo list, file
5+
table, rendered README, tree/blob/raw views, commit log with pagination,
6+
green/red unified diffs, branches and tags — plus a clean JSON API over
7+
the same model. Zero npm dependencies, zero build step, no framework:
8+
every page is server-rendered HTML with inline CSS, all git work is done
9+
by the system `git` binary, and the wire protocol is delegated to the
10+
stock `git-http-backend` CGI (gitscratch's proven plumbing, re-rooted).
11+
12+
```js
13+
plugins: [{ id: 'forge', module: 'forge/plugin.js', prefix: '/forge',
14+
config: {
15+
privateRepos: false, // true: ALL reads become owner-only
16+
gitHttpBackend: '/usr/lib/git-core/git-http-backend', // optional
17+
} }]
18+
```
19+
20+
## Using it
21+
22+
```sh
23+
# push-to-create: your pod username is your namespace
24+
git remote add forge http://localhost:3000/forge/casey/demo.git
25+
git -c http.extraHeader="Authorization: Bearer <token>" push forge main
26+
27+
# then browse http://localhost:3000/forge/casey/demo
28+
```
29+
30+
- **Ownership**: owner = pod username derived from the pusher's WebID
31+
(first path segment, mastodon/'s `podFromWebid` rule). Pushing into
32+
your own namespace materializes the bare repo under
33+
`pluginDir/repos/<owner>/<name>.git` — persistent, no TTL. Pushing into
34+
someone else's namespace is 403; anonymous push is 401 +
35+
`WWW-Authenticate`. Clone/fetch and the UI are public by default;
36+
`privateRepos: true` flips every read (git, HTML, JSON) to owner-only.
37+
- **`api` is a reserved owner name** (the JSON surface lives at
38+
`<prefix>/api`); a pod user literally named `api` cannot have a forge
39+
namespace.
40+
41+
## Routes
42+
43+
| route | page |
44+
|---|---|
45+
| `<prefix>/` | repo list across owners (description, last-push time) |
46+
| `<prefix>/<owner>` | that owner's repos |
47+
| `<prefix>/<owner>/<name>` | repo home: branch selector, file table, latest-commit bar, README card, clone box |
48+
| `.../tree/<ref>/<path>` | directory listing (folders first, per-entry last commit) |
49+
| `.../blob/<ref>/<path>` | file view: line numbers, monospace; binary/too-large fall back to a raw link |
50+
| `.../raw/<ref>/<path>` | raw bytes — `text/plain` or `application/octet-stream`+attachment, **never** `text/html` |
51+
| `.../commits/<ref>?page=N` | log, 30/page: message, short sha, author, relative time, identicon |
52+
| `.../commit/<sha>` | full commit with GitHub-style unified diff (collapsible per-file sections, +N/−M counts) |
53+
| `.../branches`, `.../tags` | ref lists |
54+
| `<prefix>/<owner>/<name>.git/...` | git smart HTTP (`info/refs`, `git-upload-pack`, `git-receive-pack`) |
55+
56+
Refs may contain `/` (`feature/x`): the tree/blob/raw/commits routes
57+
resolve the ref greedily against the real ref list (longest match wins),
58+
so `tree/feature/x/src` is unambiguous.
59+
60+
## JSON API (the Gitea-parity surface)
61+
62+
All under `GET <prefix>/api`, `application/json`, strings raw (JSON is
63+
the escape) — except `readme.html`, which is the server-side markdown
64+
renderer's already-escaped HTML, safe to inject as markup. Shapes:
65+
66+
- `api/repos` and `api/repos/<owner>`
67+
`{ repos: [{ owner, name, description, lastPush, cloneUrl, url }] }`
68+
- `api/repos/<owner>/<name>`
69+
`{ owner, name, description, lastPush, empty, defaultBranch,
70+
branches: [{ name, sha, when, subject }], tags: [...],
71+
cloneUrl, readme: { name, html } | null }`
72+
- `.../tree/<ref>/<path>`
73+
`{ ref, path, entries: [{ mode, type: 'tree'|'blob', sha, size, name,
74+
lastCommit: { sha, short, author, email, at, subject } | null }] }`
75+
- `.../blob/<ref>/<path>`
76+
`{ ref, path, size, binary, tooLarge, content | null }`
77+
- `.../commits/<ref>?page=N`
78+
`{ ref, page, perPage: 30, hasMore, commits: [{ sha, short, author,
79+
email, at, subject }] }`
80+
- `.../commit/<sha>`
81+
`{ sha, short, author, email, at, parents, message,
82+
files: [{ name, binary, adds, dels, hunks: [{ header,
83+
lines: [{ type: 'add'|'del'|'ctx'|'meta', oldLine, newLine, text }] }] }] }`
84+
85+
Errors are `{ error }` with 4xx. `cloneUrl` is absolute: the origin comes
86+
from `api.serverInfo` (#601) at request time, with `config.baseUrl` as
87+
the reverse-proxy override.
88+
89+
## The markdown subset (hand-rolled, bounded)
90+
91+
Escape **first**, then transform — the renderer is structurally
92+
XSS-proof (test-proven: a README containing `<script>alert(1)</script>`
93+
renders it as visible escaped text). Grammar:
94+
95+
- blocks: `#``######` headings, ``` fenced code, `>` blockquotes,
96+
`-`/`*` unordered and `1.` ordered lists (no nesting), blank-line
97+
paragraphs. No tables, no HTML passthrough.
98+
- inline: `` `code` ``, `**bold**`, `*em*`/`_em_`, `[text](href)`,
99+
`![alt](src)`. hrefs are allowlisted to `http(s)://`, `#anchor`, or
100+
relative (no other schemes, no `//`, no `..`); relative images route
101+
through the raw endpoint, relative links through blob view.
102+
103+
READMEs and blobs over 512 KiB are not rendered ("view raw"); binary
104+
detection is a NUL sniff over the first 8 KB.
105+
106+
## Security decisions
107+
108+
- **Raw serving is content-type-neutralized**: never `text/html` (see
109+
Findings 1), always `X-Content-Type-Options: nosniff`.
110+
- **One `esc()` helper, used on every interpolated string** — filenames,
111+
commit messages, author names, diff bodies, ref names are all
112+
attacker-controlled (anyone with a pod can push anything).
113+
- **Validation before any path or git argument is built**: owner/repo
114+
`^[A-Za-z0-9][A-Za-z0-9._-]{0,N}$`, refs
115+
`^[A-Za-z0-9][A-Za-z0-9._/-]{0,200}$` minus `..`, path segments reject
116+
`..`, leading `.`, backslash, control chars and percent-encoded
117+
`./`/`\` forms. All git reads use `execFile` argv arrays (no shell),
118+
NUL-separated `--format`s (never parsed human output), with
119+
`GIT_CONFIG_NOSYSTEM=1` and no `HOME`.
120+
- The only client-side JavaScript is the clone-box copy button, which
121+
degrades to a selectable input.
122+
123+
## Deliberate cuts
124+
125+
- **No syntax highlighting** — that's where the old attempt's 1 MB
126+
bundle came from. A `<pre>` with line numbers covers tier-1 browsing;
127+
highlighting is a candidate for a later wave *if* it can be done
128+
server-side and dependency-free.
129+
- **No search, no issues/PRs/webhooks** — later tiers (see Findings 3).
130+
- **No browser-git** — every byte of git logic is the system binary;
131+
server-side rendering made the old client bundle unnecessary.
132+
133+
## Findings
134+
135+
### 1. Stored-XSS via pushed HTML is neutralized by content-type, not by a second domain
136+
137+
A forge serves attacker-authored bytes from the API origin: a pushed
138+
`evil.html` fetched as `text/html` would run scripts with the origin's
139+
cookies/storage — classic stored XSS. GitHub's fix is architectural (a
140+
separate `raw.githubusercontent.com` domain); a single-origin plugin
141+
can't have that, so the raw endpoint neutralizes instead: text-ish blobs
142+
go out as `text/plain`, everything else as `application/octet-stream` +
143+
`content-disposition: attachment`, never `text/html`, plus
144+
`X-Content-Type-Options: nosniff` so browsers can't second-guess.
145+
Test-proven. The same reasoning puts the README renderer server-side and
146+
escape-first: HTML in a README is data, never markup.
147+
148+
### 2. The counter-witness pattern again: everything under one prefix, no reservePath
149+
150+
Like micropub/, this plugin needs **zero** protocol-fixed paths outside
151+
its prefix — smart HTTP, UI and JSON API all live under `<prefix>`,
152+
which the loader WAC-exempts. `api.reservePath` (#602) exists and is the
153+
right tool for protocol-pinned roots (xrpc, .well-known), but a forge is
154+
evidence the common case needs nothing beyond `api.prefix`. gitscratch's
155+
scoped pass-through content parser was also enough to stream raw pack
156+
bodies through Fastify — `api.mountApp` (#583) was not needed here.
157+
158+
### 3. What a forge needs that the plugin api lacks (the tier-2 shopping list)
159+
160+
- **Per-repo ACL beyond owner-only** — collaborators, org repos, private
161+
repos shared with named agents: that is exactly `api.authorize`'s
162+
(#604) issuer-authority case. Today the model is binary
163+
(public / owner-only via `privateRepos`) because the owner's WAC
164+
cannot be consulted for a *third party's* read.
165+
- **Webhooks / push notifications** — "repo X was pushed" wants
166+
`api.events` (#603). The post-receive hook is where the fact is known;
167+
today it can only fix HEAD, not notify anyone.
168+
- **Repo metadata as pod resources** — description, topics, default
169+
branch override should live in the owner's pod (tier-2 plan) so they
170+
are WAC-governed and portable; today description is derived (git's
171+
`description` file or README first line) because there is no clean
172+
write path from a plugin into a pod except loopback-with-the-user's-
173+
token, and browsing is anonymous.
174+
- **Identity mapping is convention, not contract** — owner = first
175+
WebID path segment (podFromWebid) works for this host's pods but is a
176+
heuristic; did:nostr agents have no pod namespace at all and therefore
177+
cannot push. An `api.podOf(agent)` seam would make ownership honest.
178+
179+
### 4. The JSON API doubles as the Gitea-parity surface
180+
181+
The maintainer wants clients with JSON and JS; the api layer
182+
(`<prefix>/api/...`) is deliberately shaped so a future vanilla-JS
183+
client (or a Gitea-compatible tool, loosely) can drive everything the
184+
HTML shows: repo meta with pre-rendered README html, typed tree entries,
185+
blob flags (`binary`/`tooLarge`), paginated commits, and a structured
186+
diff (`files[] → hunks[] → typed lines` with old/new line numbers) that
187+
is the same parse the HTML diff is rendered from — one parser, two
188+
surfaces, so they cannot drift. HTML stayed server-rendered because it
189+
was finished and correct; the JSON API is the extension point for a
190+
richer client, not a rewrite hook.
191+
192+
### 5. Costs accepted and written down
193+
194+
- The file table's per-entry "last commit touching this path" column is
195+
one `git log -1 -- <path>` per entry (capped at 100) — O(N) process
196+
spawns per tree view. Gitea caches this; a cache is a tier-2 concern.
197+
- The repo index runs a few git calls per repo (capped at 200 repos) to
198+
get last-push time and a description line.
199+
- No `api.events` also means no push-time cache invalidation, so
200+
everything is computed read-time — consistent with the sparql//rss/
201+
finding that read-time walks are the only option today.

0 commit comments

Comments
 (0)