494 lines
19 KiB
JavaScript
494 lines
19 KiB
JavaScript
'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,
|
||
};
|