Files
gitea-jira-task-bot/index.js

195 lines
6.9 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.
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`);