import { env } from "cloudflare:workers"; import { all } from "../db"; import { enforceRateLimit, requestIp } from "../limits"; import { loadRepo } from "../repos"; 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"; 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})$/; async function readBody(request: Request): Promise { if (Number(request.headers.get("content-length") ?? "0") > MAX_INBOUND_BYTES) return null; const text = await request.text(); return text.length > MAX_INBOUND_BYTES ? null : text; } 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 it is public or has a Slack integration for that workspace. */ async function slackCanSee(teamId: string, repoId: string, ownerId: string, visibility: string): Promise { if (visibility === "public") 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.id, repo.owner_id, repo.visibility))) { return slackReply(`No repository named ${escapeSlack(arg)} is visible here.`); } const pulls = await all<{ number: number; title: string }>( "SELECT number, title FROM issues WHERE repo_id = ? AND is_pull = 1 AND state = 'open' 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 } }; 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"); } 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 }; await enqueueIntegrationDispatch(row.scope_id, `github.${event}`, relayed); } } 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]); return new Response("Not found", { status: 404 }); }