195 lines
6.9 KiB
JavaScript
195 lines
6.9 KiB
JavaScript
const { Hono } = require('hono');
|
||
const { serve } = require('@hono/node-server');
|
||
const { serveStatic } = require('@hono/node-server/serve-static');
|
||
const crypto = require('crypto');
|
||
const config = require('./src/config/env');
|
||
const { getConfiguredRepos } = require('./src/config/mappings');
|
||
const { handleIssueEvent } = require('./src/logic/syncManager');
|
||
const { handleJiraHook } = require('./src/logic/jiraSyncManager');
|
||
const { notify, inferEventType } = require('./src/logic/larkNotifier');
|
||
const editorRoutes = require('./src/routes/control');
|
||
const logger = require('./src/utils/logger');
|
||
|
||
const { larkAuthMiddleware, loginHandler, callbackHandler } = require('./src/middleware/larkAuth');
|
||
|
||
const app = new Hono();
|
||
|
||
const requestCounts = new Map();
|
||
const rateLimiter = async (c, next) => {
|
||
const ip = c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || 'unknown';
|
||
const now = Date.now();
|
||
const windowMs = 15 * 60 * 1000;
|
||
const maxRequests = 100;
|
||
|
||
if (!requestCounts.has(ip)) {
|
||
requestCounts.set(ip, []);
|
||
}
|
||
|
||
const requests = requestCounts.get(ip).filter(time => now - time < windowMs);
|
||
requests.push(now);
|
||
requestCounts.set(ip, requests);
|
||
|
||
if (requests.length > maxRequests) {
|
||
return c.text('Too many requests from this IP, please try again later.', 429);
|
||
}
|
||
|
||
await next();
|
||
};
|
||
|
||
//定期清理过期的请求记录
|
||
setInterval(() => {
|
||
const now = Date.now();
|
||
const windowMs = 15 * 60 * 1000;
|
||
for (const [ip, times] of requestCounts.entries()) {
|
||
const validTimes = times.filter(time => now - time < windowMs);
|
||
if (validTimes.length === 0) {
|
||
requestCounts.delete(ip);
|
||
} else {
|
||
requestCounts.set(ip, validTimes);
|
||
}
|
||
}
|
||
}, 5 * 60 * 1000);
|
||
|
||
//鉴权路由
|
||
app.get('/login', loginHandler);
|
||
app.get('/oauth/callback', callbackHandler);
|
||
|
||
// 全局鉴权中间件 (飞书 OAuth)
|
||
app.use('*', larkAuthMiddleware);
|
||
|
||
// 获取当前用户信息
|
||
const { meHandler, adminMiddleware } = require('./src/middleware/larkAuth');
|
||
app.get('/api/me', meHandler);
|
||
|
||
// 敏感接口保护 (仅管理员可访问)
|
||
// 注意:Hono 的中间件匹配顺序很重要,specific routes should be handled or protected explicitly
|
||
app.use('/api/env', adminMiddleware);
|
||
app.use('/api/restart', adminMiddleware);
|
||
app.use('/api/logs', adminMiddleware);
|
||
app.use('/api/logs/clear', adminMiddleware);
|
||
app.use('/api/history', adminMiddleware);
|
||
app.use('/api/status', adminMiddleware);
|
||
|
||
//Gitea webhook处理入口
|
||
app.post('/hooks/gitea', rateLimiter, async (c) => {
|
||
try {
|
||
const signature = c.req.header('x-gitea-signature');
|
||
const event = c.req.header('x-gitea-event');
|
||
const ip = c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || 'unknown';
|
||
|
||
if (!config.gitea.secret) {
|
||
logger.error('GITEA_WEBHOOK_SECRET not configured!');
|
||
return c.text('Server configuration error', 500);
|
||
}
|
||
|
||
if (!signature) {
|
||
logger.security('Request missing signature header', { ip });
|
||
return c.text('Signature required', 401);
|
||
}
|
||
|
||
//获取原始请求体进行签名验证
|
||
const rawBody = await c.req.text();
|
||
const hmac = crypto.createHmac('sha256', config.gitea.secret);
|
||
const digest = hmac.update(rawBody).digest('hex');
|
||
if (digest !== signature) {
|
||
logger.security('Invalid signature detected', { ip });
|
||
return c.text('Invalid Signature', 401);
|
||
}
|
||
|
||
//解析JSON
|
||
const body = JSON.parse(rawBody);
|
||
|
||
//触发飞书通知(所有事件类型)
|
||
const eventType = inferEventType(event, body);
|
||
if (eventType) {
|
||
notify(eventType, body).catch(err =>
|
||
logger.error('[LarkNotifier] Async notify error:', err.message)
|
||
);
|
||
}
|
||
|
||
//Issue 事件处理(原有逻辑)
|
||
if (event === 'issues' || event === 'issue_comment') {
|
||
if (body.issue && body.repository) {
|
||
handleIssueEvent(body).catch(err => logger.error('Async handler error', err.message));
|
||
} else {
|
||
logger.warn('Invalid payload structure for issue event', { ip });
|
||
}
|
||
}
|
||
//PR 事件 - 仅记录日志(未来可扩展)
|
||
else if (event === 'pull_request') {
|
||
logger.info(`[PR Event] ${body.action} - ${body.pull_request?.title || 'Unknown'}`, { ip });
|
||
}
|
||
//Release 事件 - 仅记录日志(未来可扩展)
|
||
else if (event === 'release') {
|
||
logger.info(`[Release Event] ${body.action} - ${body.release?.name || 'Unknown'}`, { ip });
|
||
}
|
||
else {
|
||
logger.info(`Ignored event type: ${event}`, { ip });
|
||
}
|
||
|
||
return c.json({ ok: true });
|
||
} catch (error) {
|
||
logger.error('Webhook Error', error.message);
|
||
return c.json({ error: 'Internal Server Error' }, 500);
|
||
}
|
||
});
|
||
|
||
//Jira webhook处理入口
|
||
app.post('/hooks/jira', rateLimiter, async (c) => {
|
||
try {
|
||
const body = await c.req.json();
|
||
logger.info(`[JIRA HOOK] Received request`, { event: body?.webhookEvent });
|
||
|
||
//Jira Webhook通常没有签名头,依赖IP白名单或URL secret参数,此处仅校验结构
|
||
if (!body || !body.webhookEvent) {
|
||
logger.warn(`[JIRA HOOK] Invalid payload: missing webhookEvent`);
|
||
return c.text('Invalid Jira payload', 400);
|
||
}
|
||
|
||
handleJiraHook(body).catch(err => logger.error('Jira Async handler error', err.message));
|
||
|
||
return c.text('OK');
|
||
} catch (error) {
|
||
logger.error('Jira Webhook Error', error.message);
|
||
return c.text('Internal Server Error', 500);
|
||
}
|
||
});
|
||
|
||
const configuredRepos = getConfiguredRepos();
|
||
|
||
//控制台首页
|
||
app.get('/', (c) => c.redirect('/dashboard'));
|
||
app.get('/dashboard', serveStatic({ path: './public/dashboard.html' }));
|
||
app.get('/error.html', serveStatic({ path: './public/error.html' }));
|
||
|
||
app.route('/api', editorRoutes);
|
||
app.route('/editor/api', editorRoutes);
|
||
|
||
app.use('/editor/*', serveStatic({
|
||
root: './public',
|
||
rewriteRequestPath: (path) => path.replace(/^\/editor/, '')
|
||
}));
|
||
|
||
app.use('/assets/*', serveStatic({
|
||
root: './public',
|
||
rewriteRequestPath: (path) => path.replace(/^\/assets/, '')
|
||
}));
|
||
|
||
logger.info(`Sync Bot running on port ${config.app.port}`);
|
||
logger.info(`Target Jira: ${config.jira.baseUrl}`);
|
||
logger.info(`Target Gitea: ${config.gitea.baseUrl}`);
|
||
|
||
logger.info(`Bot Identity - Gitea ID:${config.gitea.botId || 'N/A'}, Name:${config.gitea.botName || 'N/A'}`);
|
||
logger.info(`Bot Identity - Jira ID:${config.jira.botId || 'N/A'}, Name:${config.jira.botName || 'N/A'}`);
|
||
|
||
logger.info(`Configured repositories: ${configuredRepos.length}`);
|
||
configuredRepos.forEach(repo => logger.info(` - ${repo}`));
|
||
logger.info('Logs directory: logs/');
|
||
logger.info(`Dashboard: http://localhost:${config.app.port}/dashboard`);
|
||
|
||
serve({
|
||
fetch: app.fetch,
|
||
port: config.app.port
|
||
});
|
||
|
||
logger.info(`Server started successfully`); |