Files
gitea-jira-task-bot/index.js
2026-01-29 15:38:49 +08:00

156 lines
5.4 KiB
JavaScript
Raw 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.
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 editorRoutes = require('./src/routes/editor');
const logger = require('./src/utils/logger');
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);
//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);
//Payload结构验证
if (!body || !body.issue || !body.repository) {
logger.warn('Invalid payload structure', { ip });
return c.json({ error: 'Invalid payload structure' }, 400);
}
if (event === 'issues' || event === 'issue_comment') {
//异步处理不阻塞webhook返回
handleIssueEvent(body).catch(err => logger.error('Async handler error', err.message));
} 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.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`);