|
| 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 | + ``. 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