Files
cc-web/lib/gitea-webhook.js

494 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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,
};