clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

Stream no events of private repos

cat committed 70f465d
The events server reads git-daemon-export-ok at each event, not only the
event's private flag, so making a repo private takes effect at once:
/events/ leaves its pushes, comments and builds out, its own stream is a
404 like a missing repo's, and pages already watching it are cut off.
Builds flag the built event of a repo they left out.
commit
70f465d24fa0c76cc3b5f12a474b4066ba130744
parent
84a6f75987515014c357b980a3807b7b8e275f46
author
cat <admin@dev.site>
kind
fix risk score 1.9 of 3, Jev's estimate
3 files changed, +81 -8
Meleventy.config.ts2+
Mlib/events-server.ts42+++++++++++++++++++++++-----
Atest/scenarios/private-events.sh45++++++++++++++++++++++++++++++
M eleventy.config.ts +2 -0
M lib/events-server.ts +34 -8
@@ -5,6 +5,11 @@
55 // Each event goes out twice: an unnamed message with a line of HTML for the66 // page's live status (ssexi swaps it in), and a named event (push, built) the77 // page's script acts on, e.g. refreshing its content when a build finishes.8+//9+// Private repos stream nothing: their events stay off /events/, and their10+// /events/<owner>/<repo>/ is a 404, like a repo that doesn't exist. Whether a11+// repo is private is read from its git-daemon-export-ok at each event, not12+// only from the event's flag, so making a repo private takes effect at once.813 import http from 'node:http';914 import fs from 'node:fs';1015 import path from 'node:path';
@@ -19,11 +24,23 @@ export interface ForgeEvent {
1924 review?: number;2025 ms?: number | null;2126 rendered?: number;27+ // Set by the hooks and builds for a private repo's events.28+ private?: boolean;2229 }2330 2431 interface Client {2532 repo: string | null;2633 res: http.ServerResponse;34+ ping: NodeJS.Timeout;35+}36+37+const NAME = /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/;38+39+// A repo is public while its bare repo has git-daemon-export-ok.40+export function isPublic(root: string, repo: string): boolean {41+ const [owner, name, ...rest] = repo.split('/');42+ if (rest.length || !NAME.test(owner ?? '') || !NAME.test(name ?? '')) return false;43+ return fs.existsSync(path.join(root, owner, `${name}.git`, 'git-daemon-export-ok'));2744 }2845 2946 const ENTITIES: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' };
@@ -47,10 +64,23 @@ function send(res: http.ServerResponse, ev: ForgeEvent): void {
4764 res.write(`event: ${ev.type}\ndata: ${JSON.stringify(ev)}\n\n`);4865 }4966 50-export function serveEvents(state: string, port: number): http.Server {67+// The repos live in the directory above the state directory (FORGE_ROOT/.forge).68+export function serveEvents(state: string, port: number, root = path.dirname(state)): http.Server {5169 const file = path.join(state, 'events.jsonl');5270 const clients = new Set<Client>();71+ const drop = (c: Client): void => {72+ clearInterval(c.ping);73+ clients.delete(c);74+ };5375 const broadcast = (ev: ForgeEvent): void => {76+ if (ev.private || !isPublic(root, ev.repo)) {77+ // Pages of a repo made private since they connected hear no more of it.78+ for (const c of clients) if (c.repo === ev.repo) {79+ drop(c);80+ c.res.end();81+ }82+ return;83+ }5484 for (const c of clients) if (!c.repo || c.repo === ev.repo) send(c.res, ev);5585 };5686
@@ -83,7 +113,7 @@ export function serveEvents(state: string, port: number): http.Server {
83113 84114 return http.createServer((req, res) => {85115 const m = /^\/events\/(?:([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/?)?$/.exec(new URL(req.url ?? '/', 'http://x').pathname);86- if (!m || req.method !== 'GET') {116+ if (!m || req.method !== 'GET' || (m[1] && !isPublic(root, `${m[1]}/${m[2]}`))) {87117 res.writeHead(404).end();88118 return;89119 }
@@ -91,13 +121,9 @@ export function serveEvents(state: string, port: number): http.Server {
91121 res.write('retry: 5000\n');92122 // Connected and idle: nothing to show until someone pushes.93123 res.write('data: \n\n');94- const client: Client = { repo: m[1] ? `${m[1]}/${m[2]}` : null, res };124+ const client: Client = { repo: m[1] ? `${m[1]}/${m[2]}` : null, res, ping: setInterval(() => res.write(': ping\n\n'), 20000) };95125 clients.add(client);96- const ping = setInterval(() => res.write(': ping\n\n'), 20000);97- req.on('close', () => {98- clearInterval(ping);99- clients.delete(client);100- });126+ req.on('close', () => drop(client));101127 }).listen(port, '127.0.0.1', () => console.log(`events on 127.0.0.1:${port}, following ${file}`));102128 }103129
A test/scenarios/private-events.sh +45 -0
@@ -0,0 +1,45 @@
1+# The events server streams nothing of a private repo: /events/ skips its2+# events, flagged or not (it reads git-daemon-export-ok), and3+# /events/<owner>/<repo>/ is the 404 a missing repo gets. A repo made4+# private while a page watches it stops streaming to that page at once.5+. "$(dirname "$0")/../lib.sh"6+user dami7+repo --private dami/secret8+repo dami/open9+port=$(node -e 'const s = require("net").createServer().listen(0, "127.0.0.1", () => { console.log(s.address().port); s.close(); })')10+ev=$FORGE_ROOT/.forge/events.jsonl11+: >"$ev"12+FORGE_STATE=$FORGE_ROOT/.forge FORGE_EVENTS_PORT=$port node "$T_HOME/lib/events-server.ts" >"$T/events.log" 2>&1 &13+T_PIDS="$T_PIDS $!"14+u=http://127.0.0.1:$port/events15+n=016+until curl -s -o /dev/null "$u/x/y/" || [ "$n" -ge 50 ]; do sleep 0.1; n=$((n + 1)); done17+18+code() { curl -s -o /dev/null -w '%{http_code}' --max-time 1 "$1" || true; }19+eq "$(code "$u/dami/secret/")" 40420+eq "$(code "$u/dami/nope/")" 40421+eq "$(code "$u/dami/open/")" 20022+eq "$(code "$u/")" 20023+24+curl -sN --max-time 4 "$u/" >"$T/all" &25+all=$!26+curl -sN --max-time 4 "$u/dami/open/" >"$T/open" &27+one=$!28+T_PIDS="$T_PIDS $all $one"29+sleep 130+push() { printf '{"t":1,"repo":"%s","type":"push","user":"dami","refs":"refs/heads/%s"%s}\n' "$1" "$2" "${3:-}" >>"$ev"; }31+push dami/secret flagged ',"private":true'32+push dami/secret unflagged33+push dami/open before34+sleep 135+rm "$FORGE_ROOT/dami/open.git/git-daemon-export-ok"36+push dami/open after37+# The watcher of dami/open is let go before its 4 seconds are up.38+if wait "$one"; then :; else fail "the stream of dami/open wasn't closed (curl exited $?)"; fi39+wait "$all" || true40+has "$T/all" '"refs":"refs/heads/before"'41+lacks "$T/all" 'dami/secret'42+lacks "$T/all" 'refs/heads/after'43+has "$T/open" '"refs":"refs/heads/before"'44+lacks "$T/open" 'refs/heads/after'45+eq "$(code "$u/dami/open/")" 404