280 lines
9.7 KiB
TypeScript
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' }))
|
|
}
|
|
}
|
|
}
|