/** * Minimal HTTP server that listens for Gitea webhooks and fires a VS Code * `Event` for each accepted payload. Two payload shapes are * supported, discriminated by the `X-Gitea-Event` header: * - `pull_request` → {@link PrWebhookEvent} * - `issues` → {@link IssueWebhookEvent} (used by the new-issue * flow to round-trip a `` marker * back into the panel) * * Routes accepted: * - `POST /webhook/:issueNumber` (legacy) — the issue number is taken from * the path and round-tripped on the event. Kept so per-issue webhooks * that the extension previously created on gitea keep working. * - `POST /webhook` (canonical) — the user configures a single shared * webhook in gitea pointing here; the event leaves `issueNumber` * undefined (PR case) and the coordinator resolves it from the PR body * (`Closes #N` / branch fallback). Issue events carry the number * directly in the payload, so the path doesn't matter for them. * * Other paths/methods get a 404/405. Malformed JSON or missing fields → 400. * Non-matching actions still respond 200 but skip the emit (gitea retries * less aggressively that way). */ import type { IncomingMessage, Server, ServerResponse } from 'node:http' import type { Event } from 'vscode' import type { WebhookEvent } from './eventParser' import { Buffer } from 'node:buffer' import { createHmac, timingSafeEqual } from 'node:crypto' import { createServer } from 'node:http' import { EventEmitter } from 'vscode' import { logger } from '../logging/logger' import { parseEvent } from './eventParser' export type { IssueCommentWebhookEvent, IssueWebhookEvent, PrWebhookEvent, PushWebhookEvent, WebhookEvent, } from './eventParser' export { parseEvent } from './eventParser' export class WebhookServer { private server?: Server private port?: number private secret = '' private readonly emitter = new EventEmitter() readonly onEvent: Event = this.emitter.event /** * 设置(或清空)Gitea webhook secret。非空时对每个请求校验 * `X-Gitea-Signature`(HMAC-SHA256 over 原始 body 字节),不匹配一律 401 * ——端口经 frp 暴露公网,没有签名任何人都能伪造事件触发 codex 执行。 */ setSecret(secret: string): void { this.secret = secret } /** 时序安全的签名比对;头缺失/长度不符/非法 hex 都按不通过处理。 */ private verifySignature(rawBody: Buffer, signatureHeader: string): boolean { const expected = createHmac('sha256', this.secret).update(rawBody).digest() let received: Buffer try { received = Buffer.from(signatureHeader, 'hex') } catch { return false } return received.length === expected.length && timingSafeEqual(received, expected) } /** The port currently bound, or undefined if the server is not listening. */ get currentPort(): number | undefined { return this.port } /** * Start (or move) the server on `port`. If already listening on the same * port this is a no-op. If listening on a different port, the old server * is closed and a new one started. Resolves once the listen succeeds. */ async start(port: number): Promise { if (this.server && this.port === port) return if (this.server) await this.stop() const server = createServer((req, res) => this.handle(req, res)) await new Promise((resolve, reject) => { function onError(err: Error): void { server.off('listening', onListening) logger.add({ level: 'error', source: 'webhook', message: 'bind 失败', details: err.message, }) reject(err) } function onListening(): void { server.off('error', onError) resolve() } server.once('error', onError) server.once('listening', onListening) server.listen(port) }) this.server = server this.port = port logger.add({ level: 'info', source: 'webhook', message: `HTTP server listening on :${port}`, }) } async stop(): Promise { const srv = this.server if (!srv) return this.server = undefined this.port = undefined await new Promise((resolve) => { srv.close(() => resolve()) }) logger.add({ level: 'info', source: 'webhook', message: 'HTTP server 已停止', }) } private handle(req: IncomingMessage, res: ServerResponse): void { try { const url = req.url ?? '' logger.add({ level: 'info', source: 'webhook', message: `收到 ${req.method ?? '?'} ${url}`, }) if (req.method !== 'POST') { res.writeHead(405, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ ok: false, error: 'method_not_allowed' })) return } // Two accepted shapes: // /webhook → issueNumber resolved later from PR body // /webhook/ → legacy per-issue, issueNumber set from path let issueNumber: number | undefined let routeMatched = false const legacyMatch = /^\/webhook\/(\d+)(?:\?.*)?$/.exec(url) if (legacyMatch) { issueNumber = Number(legacyMatch[1]) routeMatched = true } else if (/^\/webhook(?:\?.*)?$/.test(url)) { issueNumber = undefined routeMatched = true } if (!routeMatched) { logger.add({ level: 'warn', source: 'webhook', message: '路径不匹配', details: url, }) res.writeHead(404, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ ok: false, error: 'not_found' })) return } const chunks: Buffer[] = [] req.on('data', (c: Buffer) => chunks.push(c)) req.on('end', () => { try { const rawBody = Buffer.concat(chunks) if (this.secret) { const signatureHeader = (req.headers['x-gitea-signature'] || '').toString() if (!signatureHeader || !this.verifySignature(rawBody, signatureHeader)) { logger.add({ level: 'warn', source: 'webhook', message: `签名校验失败,拒绝请求 (signature=${signatureHeader ? '不匹配' : '<缺失>'})`, }) res.writeHead(401, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ ok: false, error: 'invalid_signature' })) return } } const body = rawBody.toString('utf-8') const eventHeader = (req.headers['x-gitea-event'] || '').toString() || '' logger.add({ level: 'info', source: 'webhook', message: `收到请求 X-Gitea-Event=${eventHeader} bodyLen=${body.length}`, }) let parsed: unknown try { parsed = JSON.parse(body) as unknown } catch { logger.add({ level: 'warn', source: 'webhook', message: '请求体解析失败', details: body.slice(0, 500), }) res.writeHead(400, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ ok: false, error: 'invalid_json' })) return } const event = parseEvent(eventHeader, issueNumber, parsed) if (event) { if (event.kind === 'pr') { const issuePart = typeof event.issueNumber === 'number' ? `issue=#${event.issueNumber}` : 'path=/webhook' logger.add({ level: 'info', source: 'webhook', message: `匹配 action=${event.action} PR #${event.pr} 分支 ${event.branch} ${issuePart} (event=${eventHeader})`, }) } else if (event.kind === 'issue_comment') { logger.add({ level: 'info', source: 'webhook', message: `匹配 issue_comment action=${event.action} issue=#${event.issueNumber} pr=${event.prNumber ? `#${event.prNumber}` : ''} (event=${eventHeader})`, }) } else if (event.kind === 'push') { logger.add({ level: 'info', source: 'webhook', message: `推送分支 ${event.branch} (event=${eventHeader})`, }) } else { logger.add({ level: 'info', source: 'webhook', message: `匹配 issue action=${event.action} issue=#${event.issueNumber} (event=${eventHeader})`, }) } this.emitter.fire(event) } else { logger.add({ level: 'info', source: 'webhook', message: `未处理事件,已忽略 (X-Gitea-Event=${eventHeader})`, }) } res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ ok: true })) } catch (err) { console.error('[superpowers/webhook] handler error:', err) res.writeHead(500, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ ok: false, error: 'internal_error' })) } }) req.on('error', (err) => { console.error('[superpowers/webhook] request stream error:', err) }) } catch (err) { console.error('[superpowers/webhook] top-level error:', err) res.writeHead(500, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ ok: false, error: 'internal_error' })) } } }