Files
superwork/vscode/src/webhook/server.ts
T

280 lines
9.7 KiB
TypeScript

/**
* Minimal HTTP server that listens for Gitea webhooks and fires a VS Code
* `Event<WebhookEvent>` 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 `<!-- spx:nonce=... -->` 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<WebhookEvent>()
readonly onEvent: Event<WebhookEvent> = 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<void> {
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<void>((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<void> {
const srv = this.server
if (!srv)
return
this.server = undefined
this.port = undefined
await new Promise<void>((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/<digits> → 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() || '<missing>'
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}` : '<no>'} (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' }))
}
}
}