码桶

发现社区成员的开源项目

main
NodeNanny/core/panel-server.js
panel-server.js31 KB
'use strict';

const fs = require('fs');
const path = require('path');
const express = require('express');
const store = require('./store');
const auth = require('./auth');
const pool = require('./pool');
const poolEvents = require('./pool-events');
const ai = require('./ai-provider');
const { runCheck } = require('./checker');
const terminal = require('./terminal');
const kbManager = require('./kb-manager');
const kbSync = require('./kb-sync');
const sourceListSync = require('./source-list-sync');
const wikiManager = require('./wiki-manager');
const wikiSync = require('./wiki-sync');
const failureReport = require('./failure-report');

const CONFIG_PATH = path.join(__dirname, '..', 'config', 'config.json');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');

function loadConfig() {
  if (!fs.existsSync(CONFIG_PATH)) {
    console.error(
      `[panel] 找不到配置文件:${CONFIG_PATH}\n` +
      '请先复制 config/config.example.json 为 config/config.json 并填好内容。'
    );
    process.exit(1);
  }
  return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
}

function formatDuration(sinceIso) {
  if (!sinceIso) return null;
  const seconds = Math.max(0, Math.floor((Date.now() - new Date(sinceIso).getTime()) / 1000));
  const days = Math.floor(seconds / 86400);
  const hours = Math.floor((seconds % 86400) / 3600);
  const minutes = Math.floor((seconds % 3600) / 60);
  return { days, hours, minutes, totalSeconds: seconds };
}

function main() {
  const config = loadConfig();
  const app = express();
  const sessionSecret = store.getOrCreateSessionSecret();
  const terminalSecret = store.getOrCreateTerminalSecret();
  const panelPassword = config.panel.password || '';
  const bindHost = config.panel.bindHost || '127.0.0.1';

  // 面板打算通过 Nginx 反代对外开放时,一定要设置密码;
  // 只在本机/SSH 隧道场景下访问才允许留空跳过登录(向下兼容旧配置)。
  if (!panelPassword && bindHost !== '127.0.0.1') {
    console.error(
      '[panel] 拒绝启动:config.panel.bindHost 不是 127.0.0.1(即打算被外部访问),' +
      '但 config.panel.password 为空。\n' +
      '[panel] 请先在 config.json 里设置 panel.password,否则任何人都能直接打开你的面板。'
    );
    process.exit(1);
  }

  // 如果面板部署在 Nginx 反代后面,信任 X-Forwarded-* 头,
  // 这样才能正确识别 HTTPS(用于登录 Cookie 的 Secure 属性)和真实客户端 IP(用于登录限速)。
  app.set('trust proxy', 1);
  app.use(express.json());

  function clientIp(req) {
    return req.ip || req.socket.remoteAddress || 'unknown';
  }

  function setSessionCookie(req, res) {
    const { name, token, maxAgeMs } = auth.createSessionCookie(sessionSecret);
    const secureFlag = req.secure ? '; Secure' : '';
    res.setHeader(
      'Set-Cookie',
      `${name}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(maxAgeMs / 1000)}${secureFlag}`
    );
  }

  function clearSessionCookie(res) {
    res.setHeader('Set-Cookie', `${auth.COOKIE_NAME}=; Path=/; HttpOnly; Max-Age=0`);
  }

  // ---------- 登录相关:不需要鉴权即可访问 ----------
  app.get('/login.html', (req, res) => {
    res.sendFile(path.join(PUBLIC_DIR, 'login.html'));
  });

  // ---------- 订阅接口:也不走 Cookie 登录(代理客户端没法登录),靠 URL 里的随机令牌保护 ----------
  // 发现16 修复(批次四第三轮真机测试):这里以前对自建节点分支做的是 res.redirect(302, subscriptionUrl),
  // 但 subscriptionUrl 是一条 vless:// 裸链接,不是 http(s) 地址——302 跳转只能跳到 http(s),
  // 客户端"添加订阅"发起的是标准 HTTP 请求、期待直接拿到订阅内容本体(base64),跳到一个非法
  // scheme 只会失败或拿到空内容。这就是"原始订阅地址能用、智能订阅用不了"的真实原因,而且跟
  // 流量池分支(下面 buildPoolSubscription 那条)处理方式本来就不一致。现在统一改成:不管走的是
  // 自建节点还是流量池,都直接把订阅内容按标准订阅格式 base64 编码后返回,不再对任何分支做 redirect。
  app.get('/sub/:token', (req, res) => {
    const subToken = store.getOrCreateSubToken();
    if (req.params.token !== subToken) {
      return res.status(404).send('not found');
    }
    const state = store.getState();
    const subscriptionUrl = (config.node && config.node.subscriptionUrl) || '';

    if (state.activeSource === 'pool') {
      const poolData = pool.getPool();
      if (poolData.nodes && poolData.nodes.length > 0) {
        res.set('Content-Type', 'text/plain; charset=utf-8');
        return res.send(pool.buildPoolSubscription(config.language));
      }
      // 池子空了,没有更好的选择,只能退回真实订阅(如果有的话)
    }

    if (subscriptionUrl) {
      // 自建节点场景:跟流量池分支保持一致,直接把这条节点链接本身按标准订阅格式
      // (单条链接、base64 整体编码)返回,而不是 302 跳转到一个非 http(s) 的 scheme。
      // 批次五·第一批新增(交接文档40.7/40.8节):不再原样返回subscriptionUrl本身,
      // 而是先用buildMainNodeSubscription()给节点名称加一个"来自NodeNanny"的品牌后缀
      // (跟随config.language走五语言),让用户在客户端App里也能一眼认出这是哪个项目
      // 装的节点,不是裸链接原样转发。
      res.set('Content-Type', 'text/plain; charset=utf-8');
      return res.send(pool.buildMainNodeSubscription(subscriptionUrl, config.language));
    }
    res.status(404).send(
      '还没有配置 node.subscriptionUrl(你的一键装节点脚本生成的那个真实订阅地址),\n' +
      '请在 config.json 里补上,或者暂时直接用装节点脚本给你的原始订阅地址。'
    );
  });

  app.post('/api/login', (req, res) => {
    const ip = clientIp(req);
    if (auth.isLocked(ip)) {
      return res.status(429).json({ ok: false, error: 'locked' });
    }
    if (!panelPassword) {
      // 没设密码(仅限本机访问场景),登录接口直接放行,方便本地调试
      setSessionCookie(req, res);
      return res.json({ ok: true });
    }
    const submitted = (req.body && req.body.password) || '';
    if (auth.timingSafeEqualStr(submitted, panelPassword)) {
      auth.registerSuccess(ip);
      setSessionCookie(req, res);
      return res.json({ ok: true });
    }
    auth.registerFailure(ip);
    return res.status(401).json({ ok: false, error: 'wrong_password' });
  });

  app.post('/api/logout', (req, res) => {
    clearSessionCookie(res);
    res.json({ ok: true });
  });

  // ---------- 以下路由都需要先登录 ----------
  app.use((req, res, next) => {
    // v21修复(真实bug,不是浏览器缓存问题):这条鉴权中间件此前挡在 express.static
    // 之前,拦截的是"所有非/api/开头的请求"——包括 /assets/favicon.png、css、js
    // 这些静态资源本身。后果:在login.html这个本来就不需要登录就能看到的页面上,
    // 浏览器发起的favicon请求(GET /assets/favicon.png)同样会命中这条中间件,
    // 因为还没登录、又不是/api/开头,直接被redirect到/login.html——浏览器拿到的
    // 是一段HTML而不是图片字节,自然无法渲染成图标,不管换成无痕模式、清多少次
    // 缓存都没用,因为请求根本没到达真正提供图片的express.static那一步。
    // 修复:静态资源(/assets/下的文件)本身不含敏感信息(图标/样式/脚本),直接放行,
    // 不受登录状态影响;真正的数据仍然全部走 /api/ 接口,鉴权逻辑不变。
    if (req.path.startsWith('/assets/')) return next();
    if (!panelPassword) return next(); // 未设密码时不强制登录,保持旧行为
    if (auth.verifySessionCookie(req.headers.cookie, sessionSecret)) return next();
    if (req.path.startsWith('/api/')) {
      return res.status(401).json({ ok: false, error: 'unauthorized' });
    }
    res.redirect('/login.html');
  });

  app.use(express.static(PUBLIC_DIR));

  // 批次二新增:面板首页展示"订阅信息"区块要用到的接口,返回两个订阅地址:
  // - nodeSubscriptionUrl:233boy 装机脚本自带的真实订阅地址(一次性、静态,来自 config.node.subscriptionUrl)
  // - nannySubUrl:NodeNanny 自己的智能订阅入口(/sub/:token)。发现16修复后,这个接口不再对
  //   任何分支做 302 跳转,而是统一直接返回订阅内容本体(base64):自建节点正常时返回真实节点
  //   本身,自建节点异常且流量池有节点时返回应急节点内容,客户端只要订阅这一个地址就够了。
  // 引导用户用 nannySubUrl 而不是 nodeSubscriptionUrl,具体文案放在前端页面里说明。
  app.get('/api/subscription-info', (req, res) => {
    const nodeSubscriptionUrl = (config.node && config.node.subscriptionUrl) || '';
    const subToken = store.getOrCreateSubToken();
    // 发现23 修复:nodomain 模式下面板本体走自签证书 HTTPS(人用浏览器点一次"继续访问"就过去了),
    // 但 /sub/:token 这个订阅接口是给 Shadowrocket 这类 App 直接发 HTTP 请求拉取内容的,这类 App
    // 不会像浏览器一样弹出"信任例外"的选项,遇到自签证书直接判定失败——装系统级证书信任又对非
    // 技术用户太麻烦,权衡后订阅接口改走明文 HTTP(见 setup-reverse-proxy.sh 里新增的单独 HTTP
    // 端口,只开放 /sub/ 这一个路径,其它路径一律拒绝)。这个专用地址存在 config.access.subUrlBase
    // 里(由 setup-reverse-proxy.sh 写入),存在就优先用它拼订阅地址;不存在(比如本机直连、或者
    // domain 模式本来就有正规证书不需要这套)就退回旧逻辑,从当前请求的协议+host 反推。
    const origin = (config.access && config.access.subUrlBase)
      || `${req.protocol}://${req.get('host')}`;
    const nannySubUrl = `${origin}/sub/${subToken}`;

    // 发现5修复:告诉前端"智能订阅现在到底能不能真的用",而不是让前端无条件显示乐观文案。
    // 判断逻辑跟 /sub/:token 的实际行为保持一致:activeSource 是 pool 时看池子里有没有节点,
    // 否则看 node.subscriptionUrl 是否为空——为空的话 /sub/:token 会直接返回 404,谈不上"正常使用"。
    const state = store.getState();
    let nannySubUsable = false;
    if (state.activeSource === 'pool') {
      const poolData = pool.getPool();
      nannySubUsable = !!(poolData.nodes && poolData.nodes.length > 0);
    } else {
      nannySubUsable = !!nodeSubscriptionUrl;
    }

    res.json({ nodeSubscriptionUrl, nannySubUrl, nannySubUsable });
  });

  app.get('/api/status', (req, res) => {
    const state = store.getState();
    const events = store.getEvents(20);
    const poolEnabled = !!(config.pool && config.pool.enabled);
    const poolData = poolEnabled ? pool.getPool() : null;
    const aiEnabled = !!(config.ai && config.ai.enabled);
    res.json({
      nodeName: config.node.name,
      status: state.status,
      since: state.since,
      duration: formatDuration(state.since),
      lastCheck: state.lastCheck,
      restartsToday: state.restartsToday,
      restartsSuccessToday: state.restartsSuccessToday || 0,
      checkIntervalMinutes: config.monitor.checkIntervalMinutes,
      events,
      pool: {
        enabled: poolEnabled,
        activeSource: state.activeSource || 'self',
        manualOverride: !!state.poolManualOverride,
        nodeCount: poolData ? poolData.nodes.length : 0,
        updatedAt: poolData ? poolData.updatedAt : null,
        // 修复记录:此前refreshPool()内部其实算出了很详细的per-source摘要
        // (哪个来源trial/trusted/blacklisted、通过率),但一直被直接丢弃,
        // 用户想看只能手动登服务器翻data/sources.json——自愈生态的核心卖点
        // 就是"可测量的信任",这里补上,让面板每次刷新都能看到当前各来源
        // 的实时状态,不需要手动触发一次刷新才能看到、也不用登服务器。
        sources: poolEnabled ? pool.getSourceTrustSummary(config) : [],
        // 批次五·第一批新增(交接文档40.6/40.7节):只在"当前订阅内容确实来自流量池"
        // (activeSource==='pool')时才有意义——不是流量池启用就展示,启用之后大多数
        // 时间主节点其实是正常的,不该误导用户以为正在用陌生节点。数据本身直接复用
        // pool.getActiveNodesSummary(),不重新设计一套来源统计逻辑。
        activeNodes: (poolEnabled && state.activeSource === 'pool') ? pool.getActiveNodesSummary() : []
      },
      ai: {
        enabled: aiEnabled,
        diagnosis: state.lastDiagnosis || null
      }
    });
  });

  // 星图第三步·后端接口(交接文档三十六.6/36.8节):把pool.getStarmapData()
  // 算好的坐标+元数据原样吐给前端,前端不需要自己调用layoutStars(),避免
  // demo(pool-starchart-v2.html)和正式面板各跑一份布局算法、两边参数漂移。
  // 流量池没启用时返回一个空壳(stars:[]),不报错,方便前端统一处理"暂无数据"
  // 这种状态,不用单独判断这个接口存不存在。
  app.get('/api/pool/starmap', (req, res) => {
    const poolEnabled = !!(config.pool && config.pool.enabled);
    if (!poolEnabled) {
      res.json({ enabled: false, canvasWidth: 420, canvasHeight: 320, stars: [], overflowCount: 0, blacklistOverflowCount: 0, updatedAt: null });
      return;
    }
    const width = parseInt(req.query.w, 10) || 420;
    const height = parseInt(req.query.h, 10) || 320;
    try {
      const data = pool.getStarmapData(width, height);
      res.json(Object.assign({ enabled: true }, data));
    } catch (err) {
      res.status(500).json({ enabled: true, error: err.message, stars: [] });
    }
  });

  // 流量池检测明细日志——跟 /api/pool/starmap 同样的"流量池未开启就返回空壳"原则,
  // 不报错,前端统一处理"暂无数据"这个状态。数据来自独立的 pool-events.json,
  // 不占用自建节点那份 store.js events 的名额。
  app.get('/api/pool/events', (req, res) => {
    const poolEnabled = !!(config.pool && config.pool.enabled);
    if (!poolEnabled) {
      res.json({ enabled: false, rounds: [] });
      return;
    }
    try {
      const limit = req.query.limit ? parseInt(req.query.limit, 10) : undefined;
      const rounds = poolEvents.getRecentRounds(limit);
      res.json({ enabled: true, rounds });
    } catch (err) {
      res.status(500).json({ enabled: true, error: err.message, rounds: [] });
    }
  });

  app.post('/api/check', async (req, res) => {
    try {
      const result = await runCheck(config);
      res.json({ ok: true, status: result.status });
    } catch (err) {
      res.status(500).json({ ok: false, error: err.message });
    }
  });

  // 手动触发一次流量池刷新,方便部署完立刻验证抓取有没有跑通,不用干等定时任务
  app.post('/api/pool/refresh', async (req, res) => {
    if (!config.pool || !config.pool.enabled) {
      return res.status(400).json({ ok: false, error: 'pool_disabled' });
    }
    try {
      const result = await pool.refreshPool(config);
      if (result.ok) {
        store.addEvent('pool_refreshed', { count: result.count });
      } else if (!result.skipped) {
        store.addEvent('pool_refresh_failed', { error: result.error });
      }
      res.json(result);
    } catch (err) {
      res.status(500).json({ ok: false, error: err.message });
    }
  });

  // 手动切换备用池开关(founder拍板,跟checker.js的自动切换是两条独立的线):用户不想等
  // 自建节点真的挂了才用上流量池,可以在面板上主动点一下强制切过去;再点一次切回来。
  // 用一个开关按钮而不是两个独立按钮——当前状态由 state.poolManualOverride 决定下一步动作。
  app.post('/api/pool/manual-toggle', (req, res) => {
    if (!config.pool || !config.pool.enabled) {
      return res.status(400).json({ ok: false, error: 'pool_disabled' });
    }
    const state = store.getState();
    if (state.poolManualOverride) {
      // 当前是手动pool模式,切回自建节点,恢复自动挡
      store.updateState({ activeSource: 'self', poolManualOverride: false });
      store.addEvent('pool_manual_restored', {});
      return res.json({ ok: true, activeSource: 'self', manualOverride: false });
    }
    // 当前是self(或理论上checker.js自动切的pool,但那种情况下poolManualOverride本来就是false,
    // 走到这里说明用户是在自动挡pool状态下又手动点了一次,效果是"接管"成手动挡,同样需要校验节点数)
    const poolData = pool.getPool();
    if (!poolData.nodes || poolData.nodes.length === 0) {
      return res.status(400).json({ ok: false, error: 'pool_empty' });
    }
    store.updateState({ activeSource: 'pool', poolManualOverride: true });
    store.addEvent('pool_manual_activated', { count: poolData.nodes.length });
    return res.json({ ok: true, activeSource: 'pool', manualOverride: true });
  });

  // 手动触发一次AI诊断,方便测试或者用户想主动问一次,不用等连续失败3次自动触发。
  // 手动触发不发送诊断邮件(避免用户测试功能也被塞邮箱),只更新面板上显示的结果。
  // 用一个进程内标记防止手快多点几下并发打好几次 AI API(消耗的是用户自己的 Key 额度,
  // 不是安全问题,但没必要浪费);不用分布式锁,单进程内够用。
  let aiDiagnoseInFlight = false;
  app.post('/api/ai/diagnose', async (req, res) => {
    if (!config.ai || !config.ai.enabled) {
      return res.status(400).json({ ok: false, error: 'ai_disabled' });
    }
    if (aiDiagnoseInFlight) {
      return res.status(429).json({ ok: false, error: 'ai_diagnose_in_progress' });
    }
    aiDiagnoseInFlight = true;
    try {
      const diagnosisText = await ai.diagnose({
        providerConfig: config.ai,
        nodeInfo: config.node,
        events: store.getEvents(20)
      });
      const diagnosis = { at: new Date().toISOString(), text: diagnosisText, error: null };
      store.setLastDiagnosis(diagnosis);
      store.addEvent('ai_diagnosis', { node: config.node.name });
      res.json({ ok: true, diagnosis });
    } catch (err) {
      const diagnosis = { at: new Date().toISOString(), text: null, error: err.message };
      store.setLastDiagnosis(diagnosis);
      store.addEvent('ai_diagnosis_failed', { node: config.node.name, error: err.message.slice(0, 200) });
      res.status(500).json({ ok: false, error: err.message });
    } finally {
      aiDiagnoseInFlight = false;
    }
  });

  // ---------- 终端二次解锁(交接文档v4:终端权限应高于普通面板登录) ----------
  // 普通面板登录之后,打开终端前还要单独再输入一次 config.terminal.password。
  // 走独立的cookie/密钥/失败限速表,不跟面板登录混用。
  app.post('/api/terminal/unlock', (req, res) => {
    const ip = clientIp(req);
    if (!config.terminal || !config.terminal.enabled) {
      return res.status(400).json({ ok: false, error: 'terminal_disabled' });
    }
    if (auth.isTerminalLocked(ip)) {
      return res.status(429).json({ ok: false, error: 'too_many_attempts' });
    }
    const { password } = req.body || {};
    const terminalPassword = config.terminal.password || '';
    if (!terminalPassword || !auth.timingSafeEqualStr(password || '', terminalPassword)) {
      auth.registerTerminalFailure(ip);
      return res.status(401).json({ ok: false, error: 'wrong_password' });
    }
    auth.registerTerminalSuccess(ip);
    const { name, token, maxAgeMs } = auth.createTerminalSessionCookie(terminalSecret);
    const secureFlag = req.secure ? '; Secure' : '';
    res.setHeader(
      'Set-Cookie',
      `${name}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(maxAgeMs / 1000)}${secureFlag}`
    );
    res.json({ ok: true, expiresInMinutes: auth.TERMINAL_SESSION_MINUTES });
  });

  // ---------- 知识库:匹配/列表/同步 ----------
  // 给面板日志展示区调用:把一行日志丢进来,返回命中的知识库条目(已含fixCommands/riskLevel)。
  // code重构第二批新增:请求体可以传 code(比如事件里存的 USABILITY_BAD_STATUS_CODE)
  // 做精确匹配,不用再传line去猜正则——如果同时传了两者,code优先(更快、更准,
  // 不用进沙箱),line命中的结果追加在后面、按entry.id去重,两条路径的命中都不丢。
  app.post('/api/kb/match', async (req, res) => {
    try {
      const { line, code, contextKey } = req.body || {};
      const hits = [];
      const seenIds = new Set();
      if (code) {
        for (const entry of await kbManager.matchCode(code, { contextKey })) {
          if (!seenIds.has(entry.id)) {
            seenIds.add(entry.id);
            hits.push(entry);
          }
        }
      }
      if (line) {
        for (const entry of await kbManager.matchLine(line, { contextKey })) {
          if (!seenIds.has(entry.id)) {
            seenIds.add(entry.id);
            hits.push(entry);
          }
        }
      }
      res.json({ ok: true, hits });
    } catch (err) {
      res.status(500).json({ ok: false, error: err.message });
    }
  });

  app.get('/api/kb/list', (req, res) => {
    const kb = kbManager.loadKnowledgeBase();
    res.json({ ok: true, entries: kb.entries });
  });

  // 手动触发一次远程知识库同步检查;只落地staging,不自动合并(合并要走下面的apply路由,
  // 由人工在面板上看过diff之后再点确认)。
  app.post('/api/kb/sync/check', async (req, res) => {
    if (!config.kbSync || !config.kbSync.rawUrl) {
      return res.status(400).json({ ok: false, error: 'kb_sync_not_configured' });
    }
    try {
      const result = await kbSync.checkForUpdate(config.kbSync);
      res.json(result);
    } catch (err) {
      res.status(500).json({ ok: false, error: err.message });
    }
  });

  app.get('/api/kb/sync/diff', (req, res) => {
    res.json(kbSync.diffStagingAgainstCurrent());
  });

  // 人工确认合并staging——这是唯一会真正改动生效版knowledge-base.json的入口,
  // 前端务必在调用前把diff完整展示给用户看过。
  app.post('/api/kb/sync/apply', (req, res) => {
    try {
      const { acceptedIds } = req.body || {};
      const result = kbSync.applyStaging({ acceptedIds });
      if (result.ok) store.addEvent('kb_sync_applied', { count: result.appliedCount });
      res.json(result);
    } catch (err) {
      res.status(500).json({ ok: false, error: err.message });
    }
  });

  // ---------- 官方节点来源列表远程同步(v39新增) ----------
  // 跟kb-sync走完全一样的三步流程:check(只读、返回diff)→前端展示diff→人工点确认才apply。
  // 合并对象是真实的config.json的pool.manualSources,不是config.example.json,
  // 具体的字段清洗/去fixed逻辑都在source-list-sync.js里,这里只负责接路由。
  app.post('/api/sources/sync/check', async (req, res) => {
    if (!config.sourceListSync || !config.sourceListSync.rawUrl) {
      return res.status(400).json({ ok: false, error: 'source_list_sync_not_configured' });
    }
    try {
      const result = await sourceListSync.checkForUpdate(config.sourceListSync);
      res.json(result);
    } catch (err) {
      res.status(500).json({ ok: false, error: err.message });
    }
  });

  app.get('/api/sources/sync/diff', (req, res) => {
    res.json(sourceListSync.diffStagingAgainstCurrent());
  });

  // 人工确认合并——唯一会真正改动config.json的入口,前端务必先把diff完整展示给用户看过。
  app.post('/api/sources/sync/apply', (req, res) => {
    try {
      const { acceptedIds } = req.body || {};
      const result = sourceListSync.applyStaging({ acceptedIds });
      if (result.ok) store.addEvent('source_list_sync_applied', { count: result.appliedCount });
      res.json(result);
    } catch (err) {
      res.status(500).json({ ok: false, error: err.message });
    }
  });

  // ---------- Wiki百科:分类树/单页/搜索 ----------
  // 跟上面的knowledge-base是两条独立数据线(见wiki-manager.js文件头注释):
  // 这里返回的都是原始markdown文本,服务端不做任何HTML渲染,渲染+清洗放在
  // 前端wiki.html里用marked.js + DOMPurify做,避免第三方教程原文里混入的
  // 不可信内容在服务端就被当成HTML处理。
  // lang 查询参数(?lang=en 等):非法或缺省一律由 wikiManager.normalizeLang 兜底成'zh',
  // 不在这一层做校验/报错——多语言是"缺了就回退中文"的柔性设计,不是硬性要求。
  app.get('/api/wiki/tree', (req, res) => {
    try {
      res.json({ ok: true, categories: wikiManager.buildTree(req.query.lang) });
    } catch (err) {
      res.status(500).json({ ok: false, error: err.message });
    }
  });

  app.get('/api/wiki/page/:categoryId/:slug', (req, res) => {
    const page = wikiManager.getPage(req.params.categoryId, req.params.slug, req.query.lang);
    if (!page) return res.status(404).json({ ok: false, error: 'not_found' });
    res.json({ ok: true, page });
  });

  app.get('/api/wiki/search', (req, res) => {
    try {
      const results = wikiManager.search(req.query.q, req.query.lang);
      res.json({ ok: true, results });
    } catch (err) {
      res.status(500).json({ ok: false, error: err.message });
    }
  });

  // ---------- Wiki内容远程同步(v29交接文档第三节"第一件事",本次会话新增) ----------
  // 创始人本次明确确认的方案:直接覆盖式镜像同步,不保留本地手改(跟kb-sync的
  // "staging+逐条确认"重流程不同)。流程固定两步:先check(只读、返回diff),
  // 前端把diff完整展示给用户看过,用户点"确认覆盖"之后才调用apply(真正落盘)。
  app.get('/api/wiki-sync/status', (req, res) => {
    res.json({ ok: true, status: wikiSync.getStatus() });
  });

  app.post('/api/wiki-sync/check', async (req, res) => {
    if (!config.wikiSync || !config.wikiSync.owner || !config.wikiSync.repo) {
      return res.status(400).json({ ok: false, error: 'wiki_sync_not_configured' });
    }
    try {
      const result = await wikiSync.checkForUpdate(config.wikiSync);
      res.json(result);
    } catch (err) {
      res.status(500).json({ ok: false, error: err.message });
    }
  });

  // 唯一会真正改动 data/wiki/ 目录内容的入口——前端必须在调用前把check()返回的
  // diff(added/changed/removed)完整展示给用户确认过,这里不重复展示逻辑。
  app.post('/api/wiki-sync/apply', async (req, res) => {
    if (!config.wikiSync || !config.wikiSync.owner || !config.wikiSync.repo) {
      return res.status(400).json({ ok: false, error: 'wiki_sync_not_configured' });
    }
    try {
      const result = await wikiSync.applyUpdate(config.wikiSync);
      if (result.ok) {
        store.addEvent('wiki_sync_applied', {
          downloaded: result.downloadedCount,
          removed: result.removedCount
        });
      }
      res.json(result);
    } catch (err) {
      res.status(500).json({ ok: false, error: err.message });
    }
  });

  // ---------- 失败报告生成器(任务清单第4项) ----------
  // 纯读取,不改动任何数据;组装逻辑全在failure-report.js里,这里只负责接参数、
  // 兜底language取值(跟其它面板文案一样:优先明确传的lang query,没有就退回
  // config.language,本身还有非法值兜底成zh,见failure-report.js的normalizeLang)。
  app.get('/api/failure-report', (req, res) => {
    try {
      const lang = failureReport.normalizeLang(req.query.lang || config.language);
      const text = failureReport.buildFailureReport({ config, lang });
      res.json({ ok: true, text });
    } catch (err) {
      res.status(500).json({ ok: false, error: err.message });
    }
  });

  // ---------- 终端命令写入/确认 ----------
  // 供知识库修复卡片"一键执行"按钮调用:真正的风险分级判断在terminalApi.writeCommand里做,
  // 这里只负责把请求转发过去,不在路由层重复判断逻辑。
  app.post('/api/terminal/write-command', (req, res) => {
    if (!terminalApi || !terminalApi.enabled) {
      return res.status(400).json({ ok: false, error: 'terminal_disabled' });
    }
    const { sessionId, command, riskLevel, verified } = req.body || {};
    if (!sessionId || !command) {
      return res.status(400).json({ ok: false, error: 'missing_sessionId_or_command' });
    }
    // verified 显式要求传 true 才解锁一键执行;不传/传其它值一律按false处理,
    // 这是v4文档要求的"权限层面强制",不是只在前端UI上提示一下就算了。
    const result = terminalApi.writeCommand(sessionId, command, riskLevel, verified === true);
    res.json(result);
  });

  app.post('/api/terminal/confirm-command', (req, res) => {
    if (!terminalApi || !terminalApi.enabled) {
      return res.status(400).json({ ok: false, error: 'terminal_disabled' });
    }
    const { sessionId, confirmToken } = req.body || {};
    if (!sessionId || !confirmToken) {
      return res.status(400).json({ ok: false, error: 'missing_sessionId_or_token' });
    }
    const result = terminalApi.confirmCommand(sessionId, confirmToken);
    res.json(result);
  });

  const port = config.panel.port || 8787;
  let terminalApi = { enabled: false };
  const httpServer = app.listen(port, bindHost, () => {
    console.log(`[panel] 面板已启动:http://${bindHost}:${port}`);
    if (!panelPassword) {
      console.log('[panel] 警告:未设置 panel.password,任何能连上这个地址的人都不用登录直接看到面板。');
    }
    if (bindHost === '127.0.0.1') {
      console.log('[panel] 当前只绑定本机地址。外部访问建议走 Nginx 反向代理(见 README「面板访问」一节),');
      console.log('[panel] 或者用 SSH 端口转发临时看一眼:');
      console.log(`[panel]   ssh -L ${port}:127.0.0.1:${port} 你的用户名@服务器IP`);
    }
    if (config.pool && config.pool.enabled) {
      const subToken = store.getOrCreateSubToken();
      console.log('[panel] 流量池功能已启用,订阅地址(填进客户端订阅栏,不要外传):');
      console.log(`[panel]   http(s)://你的面板访问地址/sub/${subToken}`);
    }
  });

  // 终端功能挂载在同一个http server上(走 /ws/terminal 这个WebSocket升级路径),
  // 依赖没装好或者config.terminal.enabled为false时,attachTerminal内部会打印原因并
  // 返回 {enabled:false},不影响面板其余功能正常运行。
  terminalApi = terminal.attachTerminal(httpServer, { config, sessionSecret, terminalSecret });
  if (config.terminal && config.terminal.enabled && !terminalApi.enabled) {
    console.log('[panel] 提示:config.terminal.enabled=true,但终端未能成功挂载,看上面几行日志确认原因。');
  }
}

main();