diff --git a/index.js b/index.js index e5b0c68..78a0e04 100644 --- a/index.js +++ b/index.js @@ -6,7 +6,7 @@ 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 editorRoutes = require('./src/routes/control'); const logger = require('./src/utils/logger'); const app = new Hono(); @@ -47,6 +47,30 @@ setInterval(() => { } }, 5 * 60 * 1000); +//内网访问控制中间件:保护管理界面,只允许dotenv配置的域名访问 +const internalOnlyMiddleware = async (c, next) => { + const pathname = new URL(c.req.url).pathname; + + if (pathname.startsWith('/hooks/')) { + return await next(); + } + + const host = (c.req.header('host') || '').split(':')[0]; + const allowedHosts = config.app.dashboardAllowedHosts; + + if (!allowedHosts.some(allowed => host === allowed || host.endsWith('.' + allowed))) { + logger.security(`Blocked access from unauthorized host: ${host}`, { + path: pathname, + ip: c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || 'unknown' + }); + return c.text('Forbidden - Access denied from this domain', 403); + } + + await next(); +}; + +app.use('*', internalOnlyMiddleware); + //Gitea webhook处理入口 app.post('/hooks/gitea', rateLimiter, async (c) => { try { diff --git a/public/dashboard-app.js b/public/dashboard-app.js index 6492db5..78e7a3c 100644 --- a/public/dashboard-app.js +++ b/public/dashboard-app.js @@ -5,35 +5,35 @@ function switchTab(tab) { btn.classList.remove('bg-indigo-600', 'text-white'); btn.classList.add('hover:bg-slate-800', 'text-slate-400', 'hover:text-white'); }); - + const activeBtn = document.getElementById(`tab-${tab}`); if (activeBtn) { activeBtn.classList.add('bg-indigo-600', 'text-white'); activeBtn.classList.remove('hover:bg-slate-800', 'text-slate-400', 'hover:text-white'); } - + //切换内容区 document.querySelectorAll('.tab-content').forEach(content => { content.classList.add('hidden'); }); - + const activeContent = document.getElementById(`content-${tab}`); if (activeContent) { activeContent.classList.remove('hidden'); } - + //如果切换到日志页,开始实时加载 if (tab === 'logs') { startLogStreaming(); } else { stopLogStreaming(); } - + //如果切换到设置页,加载 .env 文件 if (tab === 'settings') { loadEnvFile(); } - + //如果切换到使用指南页,加载 README if (tab === 'guide') { loadGuide(); @@ -47,7 +47,7 @@ let lastLogSize = 0; async function startLogStreaming() { //立即加载一次 await loadLogs(); - + //每2秒刷新一次 logInterval = setInterval(loadLogs, 2000); } @@ -63,19 +63,19 @@ async function loadLogs() { try { const res = await fetch('/api/logs'); const data = await res.json(); - + if (data.success) { const logViewer = document.getElementById('log-viewer'); document.getElementById('log-filename').textContent = data.filename || 'sync_service.log'; - + if (data.logs && data.logs.length > 0) { //只在日志有变化时更新 - const newContent = data.logs.map((log, index) => + const newContent = data.logs.map((log, index) => `
${index + 1}${escapeHtml(log)}
` ).join(''); - + if (logViewer.innerHTML !== newContent) { logViewer.innerHTML = newContent; //自动滚动到底部 @@ -102,18 +102,18 @@ async function loadDashboardData() { try { const res = await fetch('/api/status'); const data = await res.json(); - + if (data.success) { //更新统计数据 document.getElementById('today-syncs').textContent = data.todaySyncs || '--'; document.getElementById('repo-count').textContent = data.repoCount || '--'; document.getElementById('error-count').textContent = (data.errorCount + data.fatalCount) || '--'; document.getElementById('uptime').textContent = data.uptime ? `系统运行时间: ${data.uptime}` : '加载中...'; - + //更新服务状态 updateServiceStatus(data.status); } - + //加载历史记录 loadHistory(); } catch (e) { @@ -125,15 +125,15 @@ async function loadHistory() { try { const res = await fetch('/api/history'); const data = await res.json(); - + if (data.success && data.history) { const tbody = document.getElementById('history-table'); - + if (data.history.length === 0) { tbody.innerHTML = '暂无历史记录'; return; } - + tbody.innerHTML = data.history.map(day => ` ${day.date} @@ -153,7 +153,7 @@ async function loadHistory() { function updateServiceStatus(status) { const badge = document.getElementById('status-badge'); const statusText = document.getElementById('service-status'); - + if (status === 'running') { badge.className = 'px-3 py-1 rounded-full text-xs font-medium flex items-center border bg-emerald-50 text-emerald-600 border-emerald-200'; badge.innerHTML = ` @@ -178,17 +178,24 @@ function updateServiceStatus(status) { //控制机器人 async function controlBot(action) { try { - const res = await fetch('/api/control', { + const res = await fetch('/api/restart', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action }) }); - + const data = await res.json(); - + if (data.success) { alert(`操作成功: ${data.message || action}`); - loadDashboardData(); + //服务重启后延迟刷新页面 + if (action === 'restart') { + setTimeout(() => { + location.reload(); + }, 3000); + } else { + loadDashboardData(); + } } else { alert(`操作失败: ${data.error}`); } @@ -202,14 +209,14 @@ async function clearLogs() { if (!confirm('确定要清空日志吗?此操作不可恢复。')) { return; } - + try { const res = await fetch('/api/logs/clear', { method: 'POST' }); - + const data = await res.json(); - + if (data.success) { alert('日志已清空'); if (document.getElementById('content-logs').classList.contains('hidden') === false) { @@ -230,11 +237,11 @@ function refreshStatus() { async function loadEnvFile() { const container = document.getElementById('envEditor'); - + try { const res = await fetch('/api/env'); const data = await res.json(); - + if (data.success) { const envContent = data.content; const envItems = parseEnvContent(envContent); @@ -250,10 +257,10 @@ async function loadEnvFile() { function parseEnvContent(content) { const lines = content.split('\n'); const items = []; - + for (const line of lines) { const trimmed = line.trim(); - + if (trimmed === '' || trimmed.startsWith('#')) { items.push({ type: 'comment', value: line }); } else if (trimmed.includes('=')) { @@ -265,7 +272,7 @@ function parseEnvContent(content) { items.push({ type: 'comment', value: line }); } } - + return items; } @@ -275,10 +282,10 @@ function renderEnvForm(items) { if (item.type === 'comment') { return `
${escapeHtml(item.value)}
`; } else { - const isSecret = item.key.toLowerCase().includes('token') || - item.key.toLowerCase().includes('secret') || - item.key.toLowerCase().includes('password') || - item.key.toLowerCase().includes('pat'); + const isSecret = item.key.toLowerCase().includes('token') || + item.key.toLowerCase().includes('secret') || + item.key.toLowerCase().includes('password') || + item.key.toLowerCase().includes('pat'); return `
@@ -296,25 +303,25 @@ function renderEnvForm(items) { async function saveEnvFile() { const btn = document.getElementById('saveEnvBtn'); - + if (!confirm('确定要保存配置吗?保存后需要重启服务才能生效。')) { return; } - + btn.disabled = true; btn.textContent = '保存中...'; - + try { const content = buildEnvContent(); - + const res = await fetch('/api/env', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }) }); - + const data = await res.json(); - + if (data.success) { alert(`保存成功!\n\n${data.message}`); loadEnvFile(); @@ -332,7 +339,7 @@ async function saveEnvFile() { function buildEnvContent() { const container = document.getElementById('envEditor'); const lines = []; - + container.childNodes.forEach(node => { if (node.classList && node.classList.contains('text-slate-400')) { lines.push(node.textContent); @@ -343,17 +350,17 @@ function buildEnvContent() { lines.push(`${key}=${value}`); } }); - + return lines.join('\n'); } async function loadGuide() { const container = document.getElementById('guide-content'); - + try { const res = await fetch('/api/guide'); const data = await res.json(); - + if (data.success && data.content) { //使用marked.js渲染markdown if (typeof marked !== 'undefined') { @@ -391,10 +398,10 @@ async function loadGuide() { document.addEventListener('DOMContentLoaded', () => { //默认显示dashboard switchTab('dashboard'); - + //加载初始数据 loadDashboardData(); - + //定期刷新仪表盘数据(每30秒) setInterval(loadDashboardData, 30000); }); diff --git a/src/config/env.js b/src/config/env.js index 33e6dab..b7bddaf 100644 --- a/src/config/env.js +++ b/src/config/env.js @@ -7,7 +7,8 @@ const config = { rate: process.env.RATE_LIMIT_WINDOW || 10000, maxRequests: process.env.MAX_REQUESTS_PER_WINDOW || 20, debugMode: process.env.DEBUG_MODE === 'true', - logRetentionDays: process.env.LOG_RETENTION_DAYS || 30 + logRetentionDays: process.env.LOG_RETENTION_DAYS || 30, + dashboardAllowedHosts: (process.env.DASHBOARD_ALLOWED_HOSTS || 'localhost,127.0.0.1').split(',').map(h => h.trim()) }, gitea: { baseUrl: process.env.GITEA_BASE_URL, diff --git a/src/routes/editor.js b/src/routes/control.js similarity index 91% rename from src/routes/editor.js rename to src/routes/control.js index 19761b1..18a9491 100644 --- a/src/routes/editor.js +++ b/src/routes/control.js @@ -1,20 +1,21 @@ /** - * 映射关系编辑器路由模块 - * 提供映射配置的 CRUD 操作和 Jira API 代理 + * 控制面板路由模块 + * 提供映射配置的 CRUD 操作、Jira API 代理和服务控制 */ const { Hono } = require('hono'); +const { exec } = require('child_process'); const axios = require('axios'); const fs = require('fs'); const path = require('path'); const logger = require('../utils/logger'); -const editor = new Hono(); +const control = new Hono(); const MAPPINGS_PATH = path.join(__dirname, '../../mappings.json'); const LOGS_DIR = path.join(__dirname, '../../logs'); const README_PATH = path.join(__dirname, '../../how-to-use.md'); -editor.get('/status', (c) => { +control.get('/status', (c) => { try { let repoCount = 0; if (fs.existsSync(MAPPINGS_PATH)) { @@ -58,7 +59,7 @@ editor.get('/status', (c) => { }); //获取历史统计数据 -editor.get('/history', (c) => { +control.get('/history', (c) => { try { const history = []; @@ -104,7 +105,7 @@ editor.get('/history', (c) => { }); //获取当日日志 -editor.get('/logs', (c) => { +control.get('/logs', (c) => { try { //获取今天的日志文件 const today = new Date().toISOString().split('T')[0]; //YYYY-MM-DD @@ -135,7 +136,7 @@ editor.get('/logs', (c) => { }); //清空当日日志 -editor.post('/logs/clear', (c) => { +control.post('/logs/clear', (c) => { try { const today = new Date().toISOString().split('T')[0]; const logFile = path.join(LOGS_DIR, `sync-${today}.log`); @@ -152,35 +153,38 @@ editor.post('/logs/clear', (c) => { } }); -//控制机器人(重启等) -editor.post('/control', async (c) => { +//控制机器人(支持 PM2 软重启) +control.post('/restart', async (c) => { try { const { action } = await c.req.json(); + logger.info(`[Control] Action received: ${action}`); - logger.info(`[Editor] Control action received: ${action}`); - - //注意:实际的重启需要外部进程管理器(如 PM2) - //这里只是记录日志 if (action === 'restart') { - logger.info('[Editor] Restart requested (requires PM2 or similar)'); - return c.json({ - success: true, - message: '重启请求已记录,请使用 PM2 或其他进程管理器执行重启' - }); + logger.info('[Control] PM2 restart requested via dashboard'); + + //延迟执行,让响应先返回给客户端 + setTimeout(() => { + exec('pm2 restart gitea-jira-sync', (err, stdout, stderr) => { + if (err) { + logger.error('[Control] PM2 restart failed:', err.message); + } else { + logger.info('[Control] PM2 restart executed successfully'); + } + }); + }, 500); + + return c.json({ success: true, message: '正在重启服务,请稍候刷新页面...' }); } - return c.json({ - success: false, - error: '不支持的操作' - }); + return c.json({ success: false, error: '不支持的操作' }); } catch (e) { - logger.error('[Editor] Control error:', e.message); + logger.error('[Control] Restart error:', e.message); return c.json({ success: false, error: e.message }, 500); } }); //读取 .env 文件 -editor.get('/env', (c) => { +control.get('/env', (c) => { try { const envPath = path.join(__dirname, '../../.env'); @@ -200,7 +204,7 @@ editor.get('/env', (c) => { }); //保存 .env 文件 -editor.post('/env', async (c) => { +control.post('/env', async (c) => { try { const { content } = await c.req.json(); const envPath = path.join(__dirname, '../../.env'); @@ -226,7 +230,7 @@ editor.post('/env', async (c) => { } }); -editor.get('/guide', (c) => { +control.get('/guide', (c) => { try { if (!fs.existsSync(README_PATH)) { return c.json({ @@ -245,7 +249,7 @@ editor.get('/guide', (c) => { }); //读取现有的 mappings.json -editor.get('/mappings', (c) => { +control.get('/mappings', (c) => { try { if (!fs.existsSync(MAPPINGS_PATH)) { return c.json({ success: true, data: { repositories: {} } }); @@ -262,7 +266,7 @@ editor.get('/mappings', (c) => { }); //保存/更新 mappings.json -editor.post('/mappings', async (c) => { +control.post('/mappings', async (c) => { try { const { repoName, config } = await c.req.json(); @@ -292,7 +296,7 @@ editor.post('/mappings', async (c) => { }); //删除仓库配置 -editor.delete('/mappings/:repoName', async (c) => { +control.delete('/mappings/:repoName', async (c) => { try { const repoName = decodeURIComponent(c.req.param('repoName')); @@ -324,7 +328,7 @@ editor.delete('/mappings/:repoName', async (c) => { }); //改名仓库配置 -editor.post('/mappings/rename', async (c) => { +control.post('/mappings/rename', async (c) => { try { const { oldName, newName } = await c.req.json(); @@ -368,7 +372,7 @@ editor.post('/mappings/rename', async (c) => { }); //保存配置接口(兼容旧版) -editor.post('/save', async (c) => { +control.post('/save', async (c) => { try { const newConfigObj = await c.req.json(); const repoName = Object.keys(newConfigObj)[0]; @@ -405,7 +409,7 @@ editor.post('/save', async (c) => { }); //扫描 Jira 项目信息 -editor.post('/scan', async (c) => { +control.post('/scan', async (c) => { const { baseUrl, auth, projectKey: rawKey } = await c.req.json(); const inputKey = rawKey ? rawKey.trim() : ''; @@ -482,7 +486,7 @@ editor.post('/scan', async (c) => { }); //扫描 Sprint 信息 -editor.post('/scan-sprint', async (c) => { +control.post('/scan-sprint', async (c) => { const { baseUrl, auth, issueKey } = await c.req.json(); let headers = { 'Accept': 'application/json' }; if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`; @@ -510,7 +514,7 @@ editor.post('/scan-sprint', async (c) => { }); //代理 Jira API 请求 -editor.post('/proxy-jira', async (c) => { +control.post('/proxy-jira', async (c) => { const { url, auth } = await c.req.json(); try { @@ -532,4 +536,4 @@ editor.post('/proxy-jira', async (c) => { } }); -module.exports = editor; +module.exports = control;