'use strict'; const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const { parseWorkflowMarker } = require('./gitea-workflow-codex'); const DEFAULT_MAX_BODY_BYTES = 2 * 1024 * 1024; const DEFAULT_BOT_LOGIN = 'ccweb-bot'; function cloneJson(value) { if (value === undefined) return undefined; return JSON.parse(JSON.stringify(value)); } function safeString(value) { return typeof value === 'string' ? value.trim() : ''; } function escapeRegExp(value) { return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function timingSafeHexEqual(expectedHex, receivedHex) { const expected = Buffer.from(String(expectedHex || '').toLowerCase(), 'hex'); const received = Buffer.from(String(receivedHex || '').toLowerCase(), 'hex'); return expected.length > 0 && expected.length === received.length && crypto.timingSafeEqual(expected, received); } function normalizeSignature(value) { const raw = safeString(value).replace(/^sha256[=:]/i, '').trim(); return /^[0-9a-f]{64}$/i.test(raw) ? raw.toLowerCase() : ''; } function verifyWebhookSignature(rawBody, signature, secret) { const configuredSecret = String(secret || ''); if (!configuredSecret) return false; const expected = crypto.createHmac('sha256', configuredSecret) .update(Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody || ''))) .digest('hex'); return timingSafeHexEqual(expected, normalizeSignature(signature)); } function getHeader(headers, names) { const source = headers || {}; for (const name of names) { const value = source[name] ?? source[name.toLowerCase()]; if (Array.isArray(value)) return safeString(value[0]); if (value !== undefined) return safeString(value); } const lowerNames = new Set(names.map((name) => name.toLowerCase())); for (const [name, value] of Object.entries(source)) { if (!lowerNames.has(name.toLowerCase())) continue; return Array.isArray(value) ? safeString(value[0]) : safeString(value); } return ''; } function atomicWriteJson(filePath, value) { const target = path.resolve(filePath); fs.mkdirSync(path.dirname(target), { recursive: true }); const tempPath = `${target}.${process.pid}.${crypto.randomBytes(5).toString('hex')}.tmp`; fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); try { fs.renameSync(tempPath, target); } finally { try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch {} } } class DeliveryStore { constructor(options = {}) { this.filePath = options.filePath ? path.resolve(options.filePath) : ''; this.maxEntries = Number.isSafeInteger(options.maxEntries) && options.maxEntries > 0 ? options.maxEntries : 10_000; this.entries = new Map(); this.load(); } load() { if (!this.filePath || !fs.existsSync(this.filePath)) return; try { const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf8')); const source = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed.entries || parsed : {}; for (const [key, value] of Object.entries(source)) { if (key && value && typeof value === 'object') this.entries.set(key, value); } } catch { // 损坏的去重文件不应阻止服务启动;新请求会覆盖为可恢复的合法文件。 this.entries.clear(); } } get(key) { const value = this.entries.get(String(key)); return value ? cloneJson(value) : null; } has(key) { return this.entries.has(String(key)); } put(key, value) { const normalizedKey = String(key); this.entries.set(normalizedKey, cloneJson(value)); while (this.entries.size > this.maxEntries) { const oldest = this.entries.keys().next().value; this.entries.delete(oldest); } if (this.filePath) { atomicWriteJson(this.filePath, { schemaVersion: 1, entries: Object.fromEntries(this.entries), }); } } claimDelivery(key, value = {}) { const existing = this.get(key); if (existing) return { duplicate: true, record: existing }; this.put(key, value); return { duplicate: false, record: this.get(key) }; } getDelivery(key) { return this.get(key); } } function repositoryFromPayload(payload) { const repository = payload?.repository || payload?.repo || {}; const owner = repository.owner || repository.organization || {}; const ownerName = typeof owner === 'string' ? safeString(owner) : safeString(owner.login || owner.username || owner.name || repository.owner_name); const repoName = safeString(repository.name || repository.repo_name); const fullName = safeString(repository.full_name || (ownerName && repoName ? `${ownerName}/${repoName}` : '')); const parts = fullName.split('/').filter(Boolean); const normalizedOwner = ownerName || parts.slice(0, -1).join('/'); const normalizedRepo = repoName || parts.at(-1) || ''; if (!normalizedOwner || !normalizedRepo || normalizedOwner.includes('/') || normalizedRepo.includes('/')) { return null; } return { owner: normalizedOwner, name: normalizedRepo, fullName: `${normalizedOwner}/${normalizedRepo}`, cloneUrl: safeString(repository.clone_url || repository.cloneUrl || repository.html_url || ''), defaultBranch: safeString(repository.default_branch || repository.defaultBranch || ''), private: repository.private === true, }; } function resourceFromPayload(payload, eventName) { const issue = payload?.issue || payload?.pull_request || payload?.pullRequest || {}; const pullRequest = payload?.pull_request || payload?.pullRequest || issue?.pull_request; const number = Number(issue.number || payload?.number || pullRequest?.number); if (!Number.isSafeInteger(number) || number <= 0) return null; const isPullRequest = eventName.includes('pull_request') || !!issue.pull_request || !!payload?.pull_request || !!payload?.pullRequest; const kind = isPullRequest ? 'pull_request' : 'issue'; return { kind, number, key: `${kind}:${number}`, title: safeString(issue.title || pullRequest?.title), baseRef: safeString(pullRequest?.base?.ref || pullRequest?.base_ref), headRef: safeString(pullRequest?.head?.ref || pullRequest?.head_ref), headRepo: safeString(pullRequest?.head?.repo?.full_name || pullRequest?.head_repo), }; } function commentFromPayload(payload) { const comment = payload?.comment || payload?.review?.comment || {}; const body = typeof comment.body === 'string' ? comment.body : (typeof payload?.comment_body === 'string' ? payload.comment_body : ''); if (!body) return null; const author = comment.user || comment.author || payload?.sender || payload?.user || {}; return { id: String(comment.id || comment.number || payload?.comment_id || '').trim(), body, author: { id: author.id ?? null, login: safeString(author.login || author.username || author.name), type: safeString(author.type), }, createdAt: safeString(comment.created_at || comment.createdAt || payload?.created_at), updatedAt: safeString(comment.updated_at || comment.updatedAt || payload?.updated_at), }; } function normalizeEventName(headerValue, payload) { const header = safeString(headerValue).toLowerCase().replace(/[-\s]/g, '_'); const action = safeString(payload?.action).toLowerCase(); if (['issue_comment', 'issues_comment', 'pull_request_comment', 'pullrequest_comment'].includes(header)) { return header.includes('pull') ? 'pull_request_comment' : 'issue_comment'; } // 某些 Gitea 版本把普通 Issue 评论复用为 issues + comment 字段; // 只有存在 comment 时才归一化,避免新建 Issue 被误触发。 if (header === 'issues' && (action === 'comment' || payload?.comment)) return 'issue_comment'; if (header === 'pull_request' && (['comment', 'review_comment'].includes(action) || payload?.comment)) return 'pull_request_comment'; return header; } function parseBotMention(body, botLogin = DEFAULT_BOT_LOGIN) { const login = safeString(botLogin) || DEFAULT_BOT_LOGIN; const mention = new RegExp(`(^|[^\\w@])@${escapeRegExp(login)}(?=$|[^\\w-])`, 'i'); const match = mention.exec(String(body || '')); if (!match) return null; const before = String(body).slice(0, match.index); const after = String(body).slice(match.index + match[0].length); const instruction = `${before}${after}` .replace(/^[\s::,,、-]+/, '') .replace(/[\s]+$/, '') .trim(); return { botLogin: login, mention: `@${login}`, instruction, index: match.index + match[1].length, }; } function isBotAuthor(author, botIdentity = {}) { const login = safeString(author?.login).toLowerCase(); const configuredLogin = safeString(botIdentity.login || botIdentity.username || DEFAULT_BOT_LOGIN).toLowerCase(); if (login && configuredLogin && login === configuredLogin) return true; if (botIdentity.id !== undefined && botIdentity.id !== null && author?.id !== null && author?.id !== undefined) { return String(botIdentity.id) === String(author.id); } return false; } function normalizeWebhookEvent({ payload, headers = {}, instanceId = 'default', botIdentity }) { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { return { ignored: true, reason: 'invalid_payload' }; } const eventName = normalizeEventName(getHeader(headers, ['x-gitea-event', 'x-event-key', 'x-github-event']), payload); if (!['issue_comment', 'pull_request_comment'].includes(eventName)) { return { ignored: true, reason: 'unsupported_event', eventName }; } const comment = commentFromPayload(payload); if (!comment) return { ignored: true, reason: 'not_comment', eventName }; // 带完整关联标识的 cc-web 回帖即使 Gitea 版本未携带作者字段,也不得再次触发。 if (parseWorkflowMarker(comment.body)) { return { ignored: true, reason: 'workflow_marker_comment', eventName, commentId: comment.id || null }; } if (isBotAuthor(comment.author, botIdentity)) { return { ignored: true, reason: 'bot_self_comment', eventName, commentId: comment.id || null }; } const mention = parseBotMention(comment.body, botIdentity?.login || DEFAULT_BOT_LOGIN); if (!mention) return { ignored: true, reason: 'missing_mention', eventName, commentId: comment.id || null }; const repository = repositoryFromPayload(payload); if (!repository) return { ignored: true, reason: 'repository_unresolved', eventName, commentId: comment.id || null }; const resource = resourceFromPayload(payload, eventName); if (!resource) return { ignored: true, reason: 'resource_unresolved', eventName, commentId: comment.id || null }; const sender = payload.sender || payload.user || null; return { ignored: false, eventName, eventType: eventName, instanceId: safeString(instanceId) || 'default', deliveryId: getHeader(headers, ['x-gitea-delivery', 'x-delivery-id', 'x-github-delivery']) || safeString(payload.delivery_id), repository, repo: repository, resource, refs: { base: resource.baseRef || null, head: resource.headRef || null }, comment, mention, sender, actor: { login: safeString(sender?.login || sender?.username || sender?.name), id: sender?.id ?? null, isBot: sender?.is_bot === true || String(sender?.type || '').toLowerCase() === 'bot', }, receivedAt: new Date().toISOString(), }; } function createTaskId(deliveryKey) { return `gitea-task-${crypto.createHash('sha256').update(String(deliveryKey)).digest('hex').slice(0, 24)}`; } function readRawBody(req, maxBytes = DEFAULT_MAX_BODY_BYTES) { return new Promise((resolve, reject) => { const chunks = []; let total = 0; let settled = false; const fail = (error) => { if (settled) return; settled = true; reject(error); }; req.on('data', (chunk) => { if (settled) return; const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); total += buffer.length; if (total > maxBytes) { fail(Object.assign(new Error('Webhook 请求体过大。'), { code: 'body_too_large' })); try { req.destroy(); } catch {} return; } chunks.push(buffer); }); req.on('end', () => { if (settled) return; settled = true; resolve(Buffer.concat(chunks)); }); req.on('error', fail); }); } function readDeliveryRecord(store, key) { if (typeof store?.get === 'function') return store.get(key); if (typeof store?.getDelivery === 'function') return store.getDelivery(key); return null; } function claimDeliveryRecord(store, key, record) { if (typeof store?.claimDelivery === 'function') { return store.claimDelivery(key, record); } if (typeof store?.put === 'function') { store.put(key, record); return { duplicate: false, record }; } throw new TypeError('deliveryStore 必须提供 get/put 或 getDelivery/claimDelivery。'); } function createGiteaWebhookReceiver(options = {}) { let secret = options.secret ?? process.env.CC_WEB_GITEA_WEBHOOK_SECRET ?? ''; let botToken = options.botToken ?? ''; const instanceId = safeString(options.instanceId || process.env.CC_WEB_GITEA_INSTANCE_ID || process.env.GITEA_INSTANCE_ID) || 'default'; const botIdentity = { login: safeString(options.botLogin || process.env.CC_WEB_GITEA_BOT_LOGIN || process.env.GITEA_BOT_LOGIN) || DEFAULT_BOT_LOGIN, id: options.botId ?? process.env.CC_WEB_GITEA_BOT_ID ?? process.env.GITEA_BOT_ID ?? null, }; const maxBodyBytes = Number.isSafeInteger(options.maxBodyBytes) && options.maxBodyBytes > 0 ? options.maxBodyBytes : DEFAULT_MAX_BODY_BYTES; const deliveryStore = options.deliveryStore || new DeliveryStore({ filePath: options.deliveryFile || process.env.CC_WEB_GITEA_DELIVERY_FILE, }); const onTask = typeof options.onTask === 'function' ? options.onTask : async () => {}; const inflight = new Set(); async function processWebhook({ headers = {}, rawBody }) { const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody || '')); if (options.requireBotToken && !String(botToken || '').trim()) { return { statusCode: 503, payload: { ok: false, error: 'gitea_workflow_not_configured' } }; } const signature = getHeader(headers, ['x-gitea-signature', 'x-hub-signature-256', 'x-signature']); if (secret && !verifyWebhookSignature(body, signature, secret)) { return { statusCode: 401, payload: { ok: false, error: 'invalid_signature' } }; } let payload; try { payload = JSON.parse(body.toString('utf8')); } catch { return { statusCode: 400, payload: { ok: false, error: 'invalid_json' } }; } const deliveryId = getHeader(headers, ['x-gitea-delivery', 'x-delivery-id', 'x-github-delivery']) || safeString(payload.delivery_id); if (!deliveryId) return { statusCode: 400, payload: { ok: false, error: 'missing_delivery_id' } }; const deliveryKey = `${instanceId}:${deliveryId}`; const existing = readDeliveryRecord(deliveryStore, deliveryKey); if (existing) { return { statusCode: 200, payload: { ok: true, duplicate: true, status: 'duplicate', deliveryId, taskId: existing.taskId || null, reason: existing.reason || null }, record: existing, }; } const event = normalizeWebhookEvent({ payload, headers, instanceId, botIdentity }); event.deliveryId = deliveryId; const record = { deliveryKey, deliveryId, instanceId, taskId: event.ignored ? null : createTaskId(deliveryKey), status: event.ignored ? 'ignored' : 'accepted', reason: event.reason || null, eventName: event.eventName || null, receivedAt: event.receivedAt || new Date().toISOString(), }; const claimed = claimDeliveryRecord(deliveryStore, deliveryKey, record); if (claimed?.duplicate) { const duplicateRecord = claimed.record || readDeliveryRecord(deliveryStore, deliveryKey) || record; return { statusCode: 200, payload: { ok: true, duplicate: true, status: 'duplicate', deliveryId, taskId: duplicateRecord.taskId || null }, record: duplicateRecord, }; } if (event.ignored) { return { statusCode: 200, payload: { ok: true, ignored: true, status: 'ignored', reason: event.reason }, record, event }; } const task = { taskId: record.taskId, deliveryKey, deliveryId, instanceId, event, receivedAt: record.receivedAt, }; // Webhook 先快速确认,工作区 clone/fetch 等副作用由上层异步处理。 const taskPromise = Promise.resolve().then(() => onTask(cloneJson(task))).catch((error) => { if (typeof options.onTaskError === 'function') { try { options.onTaskError(error, task); } catch {} } }).finally(() => inflight.delete(taskPromise)); inflight.add(taskPromise); return { statusCode: 202, payload: { ok: true, accepted: true, state: 'queued', status: 'accepted', deliveryId, taskId: record.taskId }, record, event, task, }; } async function handle(req, res) { let result; try { const rawBody = await readRawBody(req, maxBodyBytes); result = await processWebhook({ headers: req.headers || {}, rawBody }); } catch (error) { const statusCode = error?.code === 'body_too_large' ? 413 : 400; result = { statusCode, payload: { ok: false, error: error?.code || 'invalid_request' } }; } if (!res.headersSent) { res.writeHead(result.statusCode, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-cache', }); res.end(JSON.stringify(result.payload)); } return result; } async function drain() { await Promise.all(Array.from(inflight)); } function updateConfig(next = {}) { if (Object.prototype.hasOwnProperty.call(next, 'secret')) secret = String(next.secret || ''); if (Object.prototype.hasOwnProperty.call(next, 'botToken')) botToken = String(next.botToken || ''); if (Object.prototype.hasOwnProperty.call(next, 'botLogin')) { botIdentity.login = safeString(next.botLogin) || DEFAULT_BOT_LOGIN; } if (Object.prototype.hasOwnProperty.call(next, 'botId')) botIdentity.id = next.botId ?? null; return { webhookSecretConfigured: Boolean(secret), botTokenConfigured: Boolean(botToken), botLogin: botIdentity.login, botId: botIdentity.id, }; } return { handle, processWebhook, drain, updateConfig, deliveryStore, instanceId, botIdentity }; } function createGiteaWebhookRoute(receiver, options = {}) { if (!receiver || typeof receiver.handle !== 'function') { throw new TypeError('receiver.handle 必须是函数。'); } const endpoint = String(options.path || '/api/gitea/webhook'); return function handleGiteaWebhookRoute(req, res, urlLike) { let pathname = typeof urlLike === 'string' ? urlLike : urlLike?.pathname; if (!pathname) { try { pathname = new URL(req.url || '/', 'http://localhost').pathname; } catch { pathname = ''; } } if (req.method !== 'POST' || pathname !== endpoint) return false; receiver.handle(req, res); return true; }; } module.exports = { DEFAULT_MAX_BODY_BYTES, DeliveryStore, createDeliveryStore: (options) => new DeliveryStore(options), createGiteaWebhookRoute, createGiteaWebhookReceiver, createTaskId, normalizeWebhookEvent, parseBotMention, verifyWebhookSignature, };