533def44dc136b5bd9166f42028bea31bd70b618 diff --git a/scripts/p620/tdd-md.container b/scripts/p620/tdd-md.container index e586614a7c39c350481d4984a012016cc9721bbb..068d7ec9b32539d557ffdcc18e28db19a812b63e 100644 --- a/scripts/p620/tdd-md.container +++ b/scripts/p620/tdd-md.container @@ -31,6 +31,7 @@ Environment=GITHUB_CLIENT_ID=Ov23li9O1wWWJDjlm6dX Secret=tdd_github_client_secret,type=env,target=GITHUB_CLIENT_SECRET Secret=tdd_forgejo_admin_token,type=env,target=FORGEJO_ADMIN_TOKEN Secret=tdd_session_secret,type=env,target=SESSION_SECRET +Secret=tdd_webhook_secret,type=env,target=WEBHOOK_SECRET # Geen PublishPort — pod publisht al :44390 → :3000. diff --git a/src/forgejo.ts b/src/forgejo.ts index ec981804e128581ff6cd7d484a0416bf54181bd3..4f94d79f76d29bea8c75ad8f7968dea8e1f6cbc7 100644 --- a/src/forgejo.ts +++ b/src/forgejo.ts @@ -69,6 +69,43 @@ export const repoExists = async (owner: string, repo: string): Promise return res.status === 200; }; +// Creates a per-repo webhook that fires on push events. The webhook +// posts to /api/forgejo/webhook on tdd.md, signed with WEBHOOK_SECRET so +// our endpoint can verify it. Idempotent — checks for an existing hook +// with the same URL before creating. +export const ensureRepoWebhook = async (params: { + owner: string; + repo: string; + webhookUrl: string; + secret: string; +}): Promise => { + const base = `${FORGEJO_URL}/api/v1/repos/${encodeURIComponent(params.owner)}/${encodeURIComponent(params.repo)}/hooks`; + const listRes = await fetch(base, { headers: adminAuth() }); + if (listRes.ok) { + const hooks = (await listRes.json()) as { id: number; config: { url?: string } }[]; + const exists = hooks.some((h) => h.config?.url === params.webhookUrl); + if (exists) return; + } + const res = await fetch(base, { + method: "POST", + headers: { ...adminAuth(), "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "forgejo", + active: true, + events: ["push"], + config: { + url: params.webhookUrl, + content_type: "json", + secret: params.secret, + }, + }), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`forgejo ensureRepoWebhook ${res.status}: ${text}`); + } +}; + export const createRepoForUser = async (params: { username: string; name: string; @@ -194,6 +231,21 @@ export const registerAgent = async (params: { } const baseUrl = process.env.BASE_URL ?? "https://tdd.md"; + const webhookSecret = process.env.WEBHOOK_SECRET; + if (webhookSecret) { + try { + await ensureRepoWebhook({ + owner: params.username, + repo: kata, + webhookUrl: `${baseUrl}/api/forgejo/webhook`, + secret: webhookSecret, + }); + } catch (err) { + // Webhook is convenience; registration must still succeed without it. + console.error(`webhook setup failed for ${params.username}/${kata}:`, err); + } + } + return { username: params.username, pushToken, diff --git a/src/server.ts b/src/server.ts index e166d7c8e543047e460543f8ac634501db564ece..10920c56246a0196963a01ac837111ab616eb669 100644 --- a/src/server.ts +++ b/src/server.ts @@ -138,6 +138,20 @@ const timingSafeEqual = (a: string, b: string): boolean => { return r === 0; }; +const hmacSha256Hex = async (secret: string, body: string): Promise => { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body)); + return Array.from(new Uint8Array(sig)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +}; + // Forward git protocol + Forgejo API/asset requests to Forgejo via the host // network. Lets us serve everything under tdd.md (GitHub-style) without // exposing git.tdd.md externally. @@ -443,6 +457,37 @@ const server = Bun.serve({ } }, + "/api/forgejo/webhook": async (req) => { + if (req.method !== "POST") return new Response("POST only", { status: 405 }); + const secret = process.env.WEBHOOK_SECRET; + if (!secret) return new Response("webhook not configured", { status: 503 }); + + const body = await req.text(); + const provided = + req.headers.get("x-forgejo-signature") ?? req.headers.get("x-gitea-signature") ?? ""; + const expected = await hmacSha256Hex(secret, body); + if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) { + return new Response("invalid signature", { status: 401 }); + } + + let payload: { repository?: { owner?: { login?: string }; name?: string }; ref?: string }; + try { + payload = JSON.parse(body); + } catch { + return new Response("invalid json", { status: 400 }); + } + const owner = payload.repository?.owner?.login; + const repo = payload.repository?.name; + if (!owner || !repo) return new Response("missing owner/repo", { status: 400 }); + + // Fire the judge in the background; ack immediately so Forgejo + // doesn't time out while we're checking out commits. + void judge(owner, repo).catch((err) => { + console.error(`judge failed for ${owner}/${repo}:`, err); + }); + return Response.json({ accepted: true, owner, repo }); + }, + "/auth/github/start": (_req) => { if (!github.isConfigured() || !forgejo.isConfigured()) { return errorPage("registration is not configured on this server", 503);