import { env } from "cloudflare:workers"; import { all, first } from "../db"; import { repoDb } from "../scoped-db"; import { enforceRateLimit, requestIp } from "../limits"; import { loadRepo, repoHiddenFrom, repoPermission, repoTakenDown } from "../repos"; import type { RepoRow } from "~/lib/types"; import { publicUrl } from "../events/emit"; import { enqueueDirect, enqueueIntegrationDispatch } from "./queue"; import { verifyLinearSignature, verifySha256Signature, verifySlackSignature } from "./signatures"; import { loadIntegration, openCredentials, parseConfig, recordDelivery } from "./store"; import type { IntegrationRow } from "./store"; import { escapeSlack } from "./format"; import { handleMirrorHook, MIRROR_HOOK_PATH } from "../mirror/hooks"; export const INBOUND_HOOK_PREFIX = "/integrations/hooks/"; const MAX_INBOUND_BYTES = 1024 * 1024; const MAX_LISTED_PULLS = 5; const REPO_ARG = /^([A-Za-z0-9][A-Za-z0-9-]{0,38})\/([A-Za-z0-9._-]{1,100})$/; /** Streams the body and stops reading as soon as the cap is passed, so a chunked request cannot buffer unbounded. */ async function readBody(request: Request): Promise { if (Number(request.headers.get("content-length") ?? "0") > MAX_INBOUND_BYTES) return null; if (!request.body) return ""; const reader = request.body.getReader(); const chunks: Uint8Array[] = []; let size = 0; for (;;) { const { value, done } = await reader.read(); if (done) break; size += value.byteLength; if (size > MAX_INBOUND_BYTES) { await reader.cancel().catch(() => undefined); return null; } chunks.push(value); } const merged = new Uint8Array(size); let offset = 0; for (const chunk of chunks) { merged.set(chunk, offset); offset += chunk.byteLength; } return new TextDecoder().decode(merged); } function slackReply(text: string): Response { return Response.json({ response_type: "ephemeral", text }); } const SLACK_HELP = "Usage: `/gitfor status owner/repo` shows open pull requests and issues. `/gitfor help` shows this."; /** * A repo is visible to a Slack workspace when anonymous visitors can read it, or it has a Slack integration for * that workspace. Moderation-hidden and taken-down repositories are visible nowhere. */ async function slackCanSee(teamId: string, repo: RepoRow): Promise { const { id: repoId, owner_id: ownerId } = repo; if (repoHiddenFrom(repo, null) || repoTakenDown(repo)) return false; if ((await repoPermission(repo, null)) !== "none") return true; const rows = await all( `SELECT * FROM integrations WHERE kind = 'slack' AND active = 1 AND ((scope_type = 'repo' AND scope_id = ?) OR (scope_type = 'org' AND scope_id = ?)) LIMIT 20`, repoId, ownerId, ); return rows.some((row) => parseConfig(row).slackTeam === teamId); } async function slackCommand(request: Request): Promise { const body = await readBody(request); const valid = body !== null && (await verifySlackSignature({ signingSecret: env.SLACK_SIGNING_SECRET ?? "", body, timestamp: request.headers.get("x-slack-request-timestamp"), signature: request.headers.get("x-slack-signature"), })); if (!valid || body === null) return new Response("Invalid signature", { status: 401 }); const form = new URLSearchParams(body); const [command, arg] = (form.get("text") ?? "").trim().split(/\s+/, 2); if (command !== "status" || !arg) return slackReply(SLACK_HELP); const match = REPO_ARG.exec(arg); const repo = match ? await loadRepo(match[1], match[2]) : null; if (!repo || !(await slackCanSee(form.get("team_id") ?? "", repo))) { return slackReply(`No repository named ${escapeSlack(arg)} is visible here.`); } const pulls = await repoDb(repo.id).all<{ number: number; title: string }>( "SELECT number, title FROM issues WHERE repo_id = ? AND is_pull = 1 AND state = 'open' AND hidden_at IS NULL ORDER BY number DESC LIMIT ?", repo.id, MAX_LISTED_PULLS, ); const base = publicUrl(`/${repo.owner_name}/${repo.name}`); const lines = [ `*<${base}|${escapeSlack(`${repo.owner_name}/${repo.name}`)}>* · ${repo.open_pulls_count} open PRs · ${repo.open_issues_count} open issues`, ...pulls.map((pull) => `• <${base}/pull/${pull.number}|#${pull.number}> ${escapeSlack(pull.title)}`), ]; return slackReply(lines.join("\n")); } async function linearHook(request: Request, integrationId: string): Promise { const row = await loadIntegration(integrationId); const body = await readBody(request); if (!row || row.kind !== "linear" || row.active !== 1 || body === null) return new Response("Not found", { status: 404 }); const credentials = await openCredentials(row); const valid = await verifyLinearSignature({ secret: credentials.inboundSecret ?? "", body, signature: request.headers.get("linear-signature"), }); if (!valid) return new Response("Invalid signature", { status: 401 }); const parsed = JSON.parse(body) as { type?: string; action?: string; data?: { identifier?: string } }; if (!(await linearEventIsLinked(row.id, parsed.data?.identifier))) return new Response("ignored"); await recordDelivery({ integrationId: row.id, direction: "inbound", event: `linear.${parsed.type ?? "unknown"}.${parsed.action ?? ""}`, repoId: null, status: "success", statusCode: 200, payload: { type: parsed.type, action: parsed.action, identifier: parsed.data?.identifier ?? null }, }); return new Response("ok"); } /** Linear sends every issue change in the workspace; only issues linked to a pull here are worth a log row. */ async function linearEventIsLinked(integrationId: string, identifier: string | undefined): Promise { if (!identifier) return false; const linked = await first<{ ok: number }>( "SELECT 1 AS ok FROM linear_issue_links WHERE integration_id = ? AND issue_key = ? LIMIT 1", integrationId, identifier, ); return linked !== null; } type GithubHookBody = { installation?: { id?: number }; repository?: { full_name?: string } }; /** * GitHub App webhooks arrive at one app-wide URL signed with the app's webhook secret. They are routed to the * matching repo integrations: pushes tick pull mirrors, and relaying forwards `github.` to chat integrations. */ async function githubHook(request: Request): Promise { const body = await readBody(request); if (body === null) return new Response("Too large", { status: 413 }); const valid = await verifySha256Signature( env.GITHUB_APP_WEBHOOK_SECRET ?? "", body, request.headers.get("x-hub-signature-256"), ); if (!valid) return new Response("Invalid signature", { status: 401 }); const event = (request.headers.get("x-github-event") ?? "").replace(/[^a-z_]/g, ""); const payload = JSON.parse(body) as GithubHookBody & Record; const installationId = payload.installation?.id; const fullName = payload.repository?.full_name; if (!event || !installationId || !fullName) return new Response("ignored"); const rows = await all( `SELECT * FROM integrations WHERE kind = 'github' AND active = 1 AND scope_type = 'repo' AND json_extract(config, '$.githubInstallationId') = ? AND lower(json_extract(config, '$.githubRepo')) = lower(?) LIMIT 10`, installationId, fullName, ); for (const row of rows) { const config = parseConfig(row); await recordDelivery({ integrationId: row.id, direction: "inbound", event: `github.${event}`, repoId: row.scope_id, status: "success", statusCode: 200, payload: { event, action: payload.action ?? null, repository: fullName }, }); if (event === "push" && config.githubMirror === "pull") { await enqueueDirect(row, { repoId: row.scope_id, event: "mirror.pull", payload: {}, summary: null }); } if (config.githubRelayWebhooks) { const relayed = { action: payload.action, repository: payload.repository, sender: payload.sender }; const delivery = request.headers.get("x-github-delivery"); await enqueueIntegrationDispatch( row.scope_id, `github.${event}`, relayed, delivery ? `gh:${delivery}` : undefined, ); } } return new Response("ok"); } /** Inbound third-party webhooks. These are cross-site POSTs authenticated only by their signatures. */ export async function handleInboundHook(request: Request, url: URL): Promise { if (!url.pathname.startsWith(INBOUND_HOOK_PREFIX)) return null; if (request.method !== "POST") return new Response("Method not allowed", { status: 405 }); await enforceRateLimit("GIT_LIMITER", `integration-hook:${requestIp(request)}`); const rest = url.pathname.slice(INBOUND_HOOK_PREFIX.length); if (rest === "slack/commands") return slackCommand(request); if (rest === "github") return githubHook(request); const linear = /^linear\/([a-z0-9]{20})$/.exec(rest); if (linear) return linearHook(request, linear[1]); const mirror = MIRROR_HOOK_PATH.exec(rest); if (mirror) return handleMirrorHook(request, mirror[1]); return new Response("Not found", { status: 404 }); }