149 lines
4.2 KiB
JavaScript
149 lines
4.2 KiB
JavaScript
|
|
/**
|
|||
|
|
* services/zhuyuan-bridge/server.js
|
|||
|
|
*
|
|||
|
|
* 铸渊桥接服务 — Webhook Server(可选增强)
|
|||
|
|
*
|
|||
|
|
* 功能:
|
|||
|
|
* - 接收 GitHub App Webhook 事件
|
|||
|
|
* - 健康检查端点
|
|||
|
|
* - 实时响应(不经过 Issue 中转)
|
|||
|
|
*
|
|||
|
|
* 部署:
|
|||
|
|
* pm2 start server.js --name zhuyuan-bridge
|
|||
|
|
*
|
|||
|
|
* 环境变量:
|
|||
|
|
* GHAPP_WEBHOOK_SECRET — GitHub App Webhook Secret
|
|||
|
|
* PORT — 服务端口(默认 3800)
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
'use strict';
|
|||
|
|
|
|||
|
|
const express = require('express');
|
|||
|
|
const crypto = require('crypto');
|
|||
|
|
|
|||
|
|
const app = express();
|
|||
|
|
const PORT = process.env.PORT || 3800;
|
|||
|
|
|
|||
|
|
// ===== Webhook 签名验证 =====
|
|||
|
|
function verifySignature(payload, signature) {
|
|||
|
|
if (!signature || !process.env.GHAPP_WEBHOOK_SECRET) return false;
|
|||
|
|
const hmac = crypto.createHmac('sha256', process.env.GHAPP_WEBHOOK_SECRET);
|
|||
|
|
hmac.update(payload);
|
|||
|
|
const expected = 'sha256=' + hmac.digest('hex');
|
|||
|
|
try {
|
|||
|
|
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
|||
|
|
} catch (_) {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 解析 JSON body,但保留原始 buffer 供签名验证
|
|||
|
|
app.use(function(req, res, next) {
|
|||
|
|
let rawBody = '';
|
|||
|
|
req.on('data', function(chunk) { rawBody += chunk; });
|
|||
|
|
req.on('end', function() {
|
|||
|
|
req.rawBody = rawBody;
|
|||
|
|
try {
|
|||
|
|
req.body = JSON.parse(rawBody);
|
|||
|
|
} catch (_) {
|
|||
|
|
req.body = {};
|
|||
|
|
}
|
|||
|
|
next();
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ===== 简易速率限制 =====
|
|||
|
|
const rateLimitMap = new Map();
|
|||
|
|
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
|
|||
|
|
const RATE_LIMIT_MAX = 30; // max requests per window
|
|||
|
|
|
|||
|
|
function rateLimit(req, res, next) {
|
|||
|
|
const ip = req.ip || req.connection.remoteAddress || 'unknown';
|
|||
|
|
const now = Date.now();
|
|||
|
|
const entry = rateLimitMap.get(ip);
|
|||
|
|
|
|||
|
|
if (!entry || now - entry.start > RATE_LIMIT_WINDOW) {
|
|||
|
|
rateLimitMap.set(ip, { start: now, count: 1 });
|
|||
|
|
return next();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
entry.count++;
|
|||
|
|
if (entry.count > RATE_LIMIT_MAX) {
|
|||
|
|
res.status(429).json({ error: 'Too many requests' });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
next();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ===== 健康检查 =====
|
|||
|
|
app.get('/health', function(req, res) {
|
|||
|
|
res.json({
|
|||
|
|
status: 'alive',
|
|||
|
|
agent: 'zhuyuan-bridge',
|
|||
|
|
version: '1.0.0',
|
|||
|
|
timestamp: new Date().toISOString()
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ===== GitHub App Webhook 入口 =====
|
|||
|
|
app.post('/webhook/github-app', rateLimit, function(req, res) {
|
|||
|
|
const event = req.headers['x-github-event'];
|
|||
|
|
const sig = req.headers['x-hub-signature-256'];
|
|||
|
|
|
|||
|
|
// 签名验证(如果配置了 secret)
|
|||
|
|
if (process.env.GHAPP_WEBHOOK_SECRET) {
|
|||
|
|
if (!verifySignature(req.rawBody, sig)) {
|
|||
|
|
console.warn('[铸渊桥接] ⚠️ Webhook 签名验证失败');
|
|||
|
|
res.status(401).json({ error: 'Signature mismatch' });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log('[铸渊桥接] 收到事件:', event);
|
|||
|
|
|
|||
|
|
// 立即响应 200(避免 GitHub 超时)
|
|||
|
|
res.status(200).json({ received: true, event: event });
|
|||
|
|
|
|||
|
|
// 异步处理事件
|
|||
|
|
handleWebhookEvent(event, req.body).catch(function(err) {
|
|||
|
|
console.error('[铸渊桥接] 事件处理失败:', err.message);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 处理 Webhook 事件
|
|||
|
|
*/
|
|||
|
|
async function handleWebhookEvent(event, payload) {
|
|||
|
|
switch (event) {
|
|||
|
|
case 'issues':
|
|||
|
|
if (payload.action === 'opened' || payload.action === 'labeled') {
|
|||
|
|
const labels = (payload.issue.labels || []).map(function(l) { return l.name; });
|
|||
|
|
if (labels.includes('zhuyuan-exec')) {
|
|||
|
|
console.log('[铸渊桥接] 检测到执行指令 Issue #' + payload.issue.number);
|
|||
|
|
// 在 Webhook 模式下,可以直接启动执行
|
|||
|
|
// 但默认推荐通过 Workflow 执行(更安全、有日志)
|
|||
|
|
console.log('[铸渊桥接] 由 GitHub Actions Workflow 处理执行');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
break;
|
|||
|
|
|
|||
|
|
case 'push':
|
|||
|
|
console.log('[铸渊桥接] Push 事件 → ' + (payload.ref || ''));
|
|||
|
|
break;
|
|||
|
|
|
|||
|
|
case 'pull_request':
|
|||
|
|
console.log('[铸渊桥接] PR 事件 → #' + (payload.pull_request ? payload.pull_request.number : '?'));
|
|||
|
|
break;
|
|||
|
|
|
|||
|
|
default:
|
|||
|
|
console.log('[铸渊桥接] 未处理的事件类型:', event);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ===== 启动服务 =====
|
|||
|
|
app.listen(PORT, function() {
|
|||
|
|
console.log('[铸渊桥接] 🚀 启动在端口 ' + PORT);
|
|||
|
|
console.log('[铸渊桥接] 健康检查: http://localhost:' + PORT + '/health');
|
|||
|
|
console.log('[铸渊桥接] Webhook: http://localhost:' + PORT + '/webhook/github-app');
|
|||
|
|
});
|