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`);