mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-01 08:10:25 +00:00
* feat: 自动登录失败后回退密码登录并补充独立配置 改动文件: - packages/napcat-webui-backend/src/helper/config.ts - packages/napcat-webui-backend/src/utils/auto_login.ts - packages/napcat-webui-backend/src/utils/auto_login_config.ts - packages/napcat-webui-backend/index.ts - packages/napcat-webui-backend/src/api/QQLogin.ts - packages/napcat-webui-backend/src/router/QQLogin.ts - packages/napcat-webui-frontend/src/controllers/qq_manager.ts - packages/napcat-webui-frontend/src/pages/dashboard/config/login.tsx - packages/napcat-test/autoPasswordFallback.test.ts 目的: - 在启动阶段将自动登录流程从“仅快速登录”扩展为“快速登录失败后自动回退密码登录”,并保持二维码兜底。 - 在 WebUI 登录配置页新增独立的自动回退账号/密码配置,密码仅提交与存储 MD5,不回显明文。 效果: - 后端配置新增 autoPasswordLoginAccount 与 autoPasswordLoginPasswordMd5 字段,并提供读取、更新(空密码不覆盖)和清空能力。 - 新增 QQLogin API:GetAutoPasswordLoginConfig / SetAutoPasswordLoginConfig / ClearAutoPasswordLoginConfig。 - WebUI 登录配置页新增自动回退密码登录区块,支持保存、刷新、清空及“留空不修改密码”交互。 - 新增自动登录回退逻辑单测与配置补丁构造单测,覆盖快速成功、回退成功、回退失败、无密码兜底等场景。 * feat: 精简为环境变量驱动的快速登录失败密码回退 改动目的: - 按维护者建议将方案收敛为后端环境变量驱动,不新增 WebUI 配置与路由 - 保留“快速登录失败 -> 密码回退 -> 二维码兜底”核心能力 - 兼容快速启动参数场景,降低评审复杂度 主要改动文件: - packages/napcat-webui-backend/index.ts - packages/napcat-shell/base.ts - packages/napcat-webui-backend/src/api/QQLogin.ts - packages/napcat-webui-backend/src/helper/config.ts - packages/napcat-webui-backend/src/router/QQLogin.ts - packages/napcat-webui-frontend/src/controllers/qq_manager.ts - packages/napcat-webui-frontend/src/pages/dashboard/config/login.tsx - 删除:packages/napcat-webui-backend/src/utils/auto_login.ts - 删除:packages/napcat-webui-backend/src/utils/auto_login_config.ts - 删除:packages/napcat-test/autoPasswordFallback.test.ts 实现细节: 1. WebUI 启动自动登录链路 - 保留 NAPCAT_QUICK_ACCOUNT 优先逻辑 - 快速登录失败后触发密码回退 - 回退密码来源优先级: a) NAPCAT_QUICK_PASSWORD_MD5(32 位 MD5) b) NAPCAT_QUICK_PASSWORD(运行时自动计算 MD5) - 未配置回退密码时保持二维码兜底,并输出带 QQ 号的引导日志 2. Shell 快速登录链路 - quickLoginWithUin 失败判定统一基于 result 码 + errMsg - 覆盖历史账号不存在、凭证失效、快速登录异常等场景 - 失败后统一进入同一密码回退逻辑,再兜底二维码 3. 文案与可运维性 - 日志明确推荐优先使用 ACCOUNT + NAPCAT_QUICK_PASSWORD - NAPCAT_QUICK_PASSWORD_MD5 作为备用方式 效果: - 满足自动回退登录需求,且改动面显著缩小 - 不修改 napcat-docker 仓库代码,直接兼容现有容器启动参数 - 便于上游快速审阅与合并 * fix: 修复 napcat-framework 未使用变量导致的 CI typecheck 失败 改动文件: - packages/napcat-framework/napcat.ts 问题背景: - 上游代码中声明了变量 bypassEnabled,但后续未使用 - 在 CI 的全量 TypeScript 检查中触发 TS6133(声明但未读取) - 导致 PR Build 机器人评论显示构建失败(Type check failed) 具体修复: - 将以下语句从“赋值后未使用”改为“直接调用” - 原:const bypassEnabled = napi2nativeLoader.nativeExports.enableAllBypasses?.(bypassOptions); - 现:napi2nativeLoader.nativeExports.enableAllBypasses?.(bypassOptions); 影响与效果: - 不改变运行时行为(仍会执行 enableAllBypasses) - 消除 TS6133 报错,恢复 typecheck 可通过 本地验证: - pnpm run typecheck:通过 - pnpm run build:framework:通过 - pnpm run build:shell:通过 --------- Co-authored-by: 手瓜一十雪 <nanaeonn@outlook.com>
585 lines
23 KiB
TypeScript
585 lines
23 KiB
TypeScript
/**
|
||
* @file WebUI服务入口文件
|
||
*/
|
||
|
||
import express from 'express';
|
||
import type { WebUiConfigType } from './src/types';
|
||
import { createServer } from 'http';
|
||
import { createHash, randomUUID } from 'node:crypto';
|
||
import { createServer as createHttpsServer } from 'https';
|
||
import { NapCatPathWrapper } from 'napcat-common/src/path';
|
||
import { WebUiConfigWrapper } from '@/napcat-webui-backend/src/helper/config';
|
||
import { ALLRouter } from '@/napcat-webui-backend/src/router';
|
||
import { cors } from '@/napcat-webui-backend/src/middleware/cors';
|
||
import { createUrl, getRandomToken } from '@/napcat-webui-backend/src/utils/url';
|
||
import { sendError } from '@/napcat-webui-backend/src/utils/response';
|
||
import { join } from 'node:path';
|
||
import { terminalManager } from '@/napcat-webui-backend/src/terminal/terminal_manager';
|
||
import multer from 'multer';
|
||
import * as net from 'node:net';
|
||
import { WebUiDataRuntime } from './src/helper/Data';
|
||
import { existsSync, readFileSync } from 'node:fs'; // 引入multer用于错误捕获
|
||
import { ILogWrapper } from 'napcat-common/src/log-interface';
|
||
import { ISubscription } from 'napcat-common/src/subscription-interface';
|
||
import { IStatusHelperSubscription } from '@/napcat-common/src/status-interface';
|
||
import { handleDebugWebSocket } from '@/napcat-webui-backend/src/api/Debug';
|
||
import compression from 'compression';
|
||
import { napCatVersion } from 'napcat-common/src/version';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { dirname, resolve } from 'node:path';
|
||
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
|
||
import { OB11PluginMangerAdapter } from '@/napcat-onebot/network/plugin-manger';
|
||
|
||
const __filename = fileURLToPath(import.meta.url);
|
||
const __dirname = dirname(__filename);
|
||
// 实例化Express
|
||
const app = express();
|
||
/**
|
||
* 初始化并启动WebUI服务。
|
||
* 该函数配置了Express服务器以支持JSON解析和静态文件服务,并监听6099端口。
|
||
* 无需参数。
|
||
* @returns {Promise<void>} 无返回值。
|
||
*/
|
||
export let WebUiConfig: WebUiConfigWrapper;
|
||
export let webUiPathWrapper: NapCatPathWrapper;
|
||
export let logSubscription: ISubscription;
|
||
export let statusHelperSubscription: IStatusHelperSubscription;
|
||
export let webUiLogger: ILogWrapper | null = null;
|
||
const MAX_PORT_TRY = 100;
|
||
|
||
export let webUiRuntimePort = 6099;
|
||
// 全局变量:存储需要在QQ登录成功后发送的新token
|
||
export let pendingTokenToSend: string | null = null;
|
||
|
||
/**
|
||
* 存储WebUI启动时的初始token,用于鉴权
|
||
* - 无论是否在运行时修改密码,都应该使用此token进行鉴权
|
||
* - 运行时手动修改的密码将会在下次napcat重启后生效
|
||
* - 如果需要在运行时修改密码并立即生效,则需要在前端调用路由进行修改
|
||
*/
|
||
let initialWebUiToken: string = '';
|
||
|
||
export function setInitialWebUiToken (token: string) {
|
||
initialWebUiToken = token;
|
||
}
|
||
|
||
export function getInitialWebUiToken (): string {
|
||
return initialWebUiToken;
|
||
}
|
||
|
||
export function setPendingTokenToSend (token: string | null) {
|
||
pendingTokenToSend = token;
|
||
}
|
||
|
||
export async function InitPort (parsedConfig: WebUiConfigType): Promise<[string, number, string]> {
|
||
try {
|
||
await tryUseHost(parsedConfig.host);
|
||
const preferredPort = parseInt(process.env['NAPCAT_WEBUI_PREFERRED_PORT'] || '', 10);
|
||
|
||
let port: number;
|
||
if (preferredPort > 0) {
|
||
try {
|
||
port = await tryUsePort(preferredPort, parsedConfig.host, 0, true);
|
||
} catch {
|
||
port = await tryUsePort(parsedConfig.port, parsedConfig.host);
|
||
}
|
||
} else {
|
||
port = await tryUsePort(parsedConfig.port, parsedConfig.host);
|
||
}
|
||
|
||
return [parsedConfig.host, port, parsedConfig.token];
|
||
} catch (error) {
|
||
console.log('host或port不可用', error);
|
||
return ['', 0, randomUUID()];
|
||
}
|
||
}
|
||
|
||
async function checkCertificates (logger: ILogWrapper): Promise<{ key: string, cert: string; } | null> {
|
||
try {
|
||
const certPath = join(webUiPathWrapper.configPath, 'cert.pem');
|
||
const keyPath = join(webUiPathWrapper.configPath, 'key.pem');
|
||
|
||
if (existsSync(certPath) && existsSync(keyPath)) {
|
||
const cert = readFileSync(certPath, 'utf8');
|
||
const key = readFileSync(keyPath, 'utf8');
|
||
logger.log('[NapCat] [WebUi] 找到SSL证书,将启用HTTPS模式');
|
||
return { cert, key };
|
||
}
|
||
return null;
|
||
} catch (error) {
|
||
logger.log('[NapCat] [WebUi] 检查SSL证书时出错: ' + error);
|
||
return null;
|
||
}
|
||
}
|
||
export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWrapper, Subscription: ISubscription, statusSubscription: IStatusHelperSubscription) {
|
||
webUiPathWrapper = pathWrapper;
|
||
logSubscription = Subscription;
|
||
statusHelperSubscription = statusSubscription;
|
||
webUiLogger = logger;
|
||
WebUiConfig = new WebUiConfigWrapper();
|
||
let config = await WebUiConfig.GetWebUIConfig();
|
||
|
||
// 检查是否禁用WebUI(若禁用则不进行密码检测)
|
||
if (config.disableWebUI) {
|
||
logger.log('[NapCat] [WebUi] WebUI is disabled by configuration.');
|
||
return;
|
||
}
|
||
|
||
// 优先使用环境变量覆盖 Token
|
||
if (process.env['NAPCAT_WEBUI_SECRET_KEY'] && config.token !== process.env['NAPCAT_WEBUI_SECRET_KEY']) {
|
||
await WebUiConfig.UpdateWebUIConfig({ token: process.env['NAPCAT_WEBUI_SECRET_KEY'] });
|
||
logger.log(`[NapCat] [WebUi] 检测到环境变量配置,已更新 WebUI Token 为 ${process.env['NAPCAT_WEBUI_SECRET_KEY']}`);
|
||
config = await WebUiConfig.GetWebUIConfig();
|
||
} else if (config.token === 'napcat' || !config.token) {
|
||
// 只有没设置环境变量,且是默认密码时,才生成随机密码
|
||
const randomToken = getRandomToken(8);
|
||
await WebUiConfig.UpdateWebUIConfig({ token: randomToken });
|
||
logger.log('[NapCat] [WebUi] 检测到默认密码,已自动更新为安全密码');
|
||
|
||
// 存储token到全局变量,等待QQ登录成功后发送
|
||
setPendingTokenToSend(randomToken);
|
||
logger.log('[NapCat] [WebUi] 新密码将在QQ登录成功后发送给用户');
|
||
|
||
// 重新获取更新后的配置
|
||
config = await WebUiConfig.GetWebUIConfig();
|
||
}
|
||
|
||
// 存储启动时的初始token用于鉴权
|
||
setInitialWebUiToken(config.token);
|
||
|
||
const [host, port, token] = await InitPort(config);
|
||
webUiRuntimePort = port;
|
||
if (port === 0) {
|
||
logger.log('[NapCat] [WebUi] Current WebUi is not run.');
|
||
return;
|
||
}
|
||
WebUiDataRuntime.setWebUiConfigQuickFunction(
|
||
async () => {
|
||
const autoLoginAccount = process.env['NAPCAT_QUICK_ACCOUNT'] || WebUiConfig.getAutoLoginAccount();
|
||
const resolveQuickPasswordMd5 = (): string | undefined => {
|
||
const quickPasswordMd5FromEnv = process.env['NAPCAT_QUICK_PASSWORD_MD5']?.trim();
|
||
if (quickPasswordMd5FromEnv) {
|
||
if (/^[a-fA-F0-9]{32}$/.test(quickPasswordMd5FromEnv)) {
|
||
return quickPasswordMd5FromEnv.toLowerCase();
|
||
}
|
||
console.log('[NapCat] [WebUi] NAPCAT_QUICK_PASSWORD_MD5 格式无效(需为 32 位 MD5)');
|
||
}
|
||
|
||
const quickPassword = process.env['NAPCAT_QUICK_PASSWORD'];
|
||
if (typeof quickPassword === 'string' && quickPassword.length > 0) {
|
||
console.log('[NapCat] [WebUi] 检测到 NAPCAT_QUICK_PASSWORD,已在内存中计算 MD5 用于回退登录');
|
||
return createHash('md5').update(quickPassword, 'utf8').digest('hex');
|
||
}
|
||
return undefined;
|
||
};
|
||
if (!autoLoginAccount) {
|
||
return;
|
||
}
|
||
const quickPasswordMd5 = resolveQuickPasswordMd5();
|
||
|
||
try {
|
||
const { result, message } = await WebUiDataRuntime.requestQuickLogin(autoLoginAccount);
|
||
if (result) {
|
||
console.log(`[NapCat] [WebUi] 自动快速登录成功: ${autoLoginAccount}`);
|
||
return;
|
||
}
|
||
console.log(`[NapCat] [WebUi] 自动快速登录失败: ${message || '未知错误'}`);
|
||
} catch (error) {
|
||
console.log('[NapCat] [WebUi] 自动快速登录异常:' + error);
|
||
}
|
||
|
||
if (!quickPasswordMd5) {
|
||
console.log(`[NapCat] [WebUi] QQ ${autoLoginAccount} 未配置回退密码环境变量,建议优先使用 ACCOUNT + NAPCAT_QUICK_PASSWORD(NAPCAT_QUICK_PASSWORD_MD5 作为备用),保持二维码登录兜底`);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const { result, message, needCaptcha, needNewDevice } = await WebUiDataRuntime.requestPasswordLogin(autoLoginAccount, quickPasswordMd5);
|
||
if (result) {
|
||
console.log(`[NapCat] [WebUi] 自动密码回退登录成功: ${autoLoginAccount}`);
|
||
return;
|
||
}
|
||
if (needCaptcha) {
|
||
console.log(`[NapCat] [WebUi] 自动密码回退登录需要验证码,请在登录页面继续完成: ${autoLoginAccount}`);
|
||
return;
|
||
}
|
||
if (needNewDevice) {
|
||
console.log(`[NapCat] [WebUi] 自动密码回退登录需要新设备验证,请在登录页面继续完成: ${autoLoginAccount}`);
|
||
return;
|
||
}
|
||
console.log(`[NapCat] [WebUi] 自动密码回退登录失败: ${message || '未知错误'}`);
|
||
} catch (error) {
|
||
console.log('[NapCat] [WebUi] 自动密码回退登录异常:' + error);
|
||
}
|
||
});
|
||
// ------------注册中间件------------
|
||
// 使用express的json中间件
|
||
app.use(express.json());
|
||
// 启用gzip压缩(对所有响应启用,阈值1KB)
|
||
app.use(compression({
|
||
level: 6, // 压缩级别 1-9,6 是性能和压缩率的平衡点
|
||
threshold: 1024, // 只压缩大于 1KB 的响应
|
||
filter: (req, res) => {
|
||
// 不压缩 SSE 和 WebSocket 升级请求
|
||
if (req.headers['accept'] === 'text/event-stream') {
|
||
return false;
|
||
}
|
||
// 使用默认过滤器
|
||
return compression.filter(req, res);
|
||
},
|
||
}));
|
||
|
||
// CORS中间件
|
||
// TODO:
|
||
app.use(cors);
|
||
|
||
// 自定义字体文件路由 - 返回用户上传的字体文件
|
||
app.use('/webui/fonts/CustomFont.woff', async (_req, res) => {
|
||
const fontPath = await WebUiConfig.GetWebUIFontPath();
|
||
if (fontPath) {
|
||
res.sendFile(fontPath);
|
||
} else {
|
||
res.status(404).send('Custom font not found');
|
||
}
|
||
});
|
||
|
||
// 如果是自定义色彩,构建一个css文件
|
||
app.use('/files/theme.css', async (_req, res) => {
|
||
const theme = await WebUiConfig.GetTheme();
|
||
const fontMode = theme.fontMode || 'system';
|
||
|
||
let css = '';
|
||
|
||
// 生成字体 @font-face
|
||
if (fontMode === 'aacute') {
|
||
css += `
|
||
@font-face {
|
||
font-family: 'Aa偷吃可爱长大的';
|
||
src: url('/webui/fonts/AaCute.woff') format('woff');
|
||
font-display: swap;
|
||
}
|
||
`;
|
||
} else if (fontMode === 'custom') {
|
||
css += `
|
||
@font-face {
|
||
font-family: 'CustomFont';
|
||
src: url('/webui/fonts/CustomFont.woff') format('woff');
|
||
font-display: swap;
|
||
}
|
||
`;
|
||
}
|
||
|
||
// 生成颜色主题和字体变量
|
||
css += ':root, .light, [data-theme="light"] {';
|
||
for (const key in theme.light) {
|
||
css += `${key}: ${theme.light[key]};`;
|
||
}
|
||
// 添加字体变量
|
||
if (fontMode === 'aacute') {
|
||
css += "--font-family-base: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
|
||
css += "--font-family-mono: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
|
||
} else if (fontMode === 'custom') {
|
||
css += "--font-family-base: 'CustomFont', var(--font-family-fallbacks) !important;";
|
||
css += "--font-family-mono: 'CustomFont', var(--font-family-fallbacks) !important;";
|
||
} else {
|
||
css += '--font-family-base: var(--font-family-fallbacks) !important;';
|
||
css += '--font-family-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;';
|
||
}
|
||
css += '}';
|
||
|
||
css += '.dark, [data-theme="dark"] {';
|
||
for (const key in theme.dark) {
|
||
css += `${key}: ${theme.dark[key]};`;
|
||
}
|
||
// 添加字体变量
|
||
if (fontMode === 'aacute') {
|
||
css += "--font-family-base: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
|
||
css += "--font-family-mono: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
|
||
} else if (fontMode === 'custom') {
|
||
css += "--font-family-base: 'CustomFont', var(--font-family-fallbacks) !important;";
|
||
css += "--font-family-mono: 'CustomFont', var(--font-family-fallbacks) !important;";
|
||
} else {
|
||
css += '--font-family-base: var(--font-family-fallbacks) !important;';
|
||
css += '--font-family-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;';
|
||
}
|
||
css += '}';
|
||
|
||
res.send(css);
|
||
});
|
||
|
||
// 动态生成 sw.js
|
||
app.get('/webui/sw.js', async (_req, res) => {
|
||
try {
|
||
// 读取模板文件
|
||
let templatePath = resolve(__dirname, 'static', 'sw_template.js');
|
||
if (!existsSync(templatePath)) {
|
||
templatePath = resolve(__dirname, 'src', 'assets', 'sw_template.js');
|
||
}
|
||
|
||
let swContent = readFileSync(templatePath, 'utf-8');
|
||
|
||
// 替换版本号
|
||
// 使用 napCatVersion,如果为 alpha 则尝试加上时间戳或其他标识以避免缓存冲突,或者直接使用
|
||
// 用户要求控制 sw.js 版本,napCatVersion 是核心控制点
|
||
swContent = swContent.replace('{{VERSION}}', napCatVersion);
|
||
|
||
res.header('Content-Type', 'application/javascript');
|
||
res.header('Service-Worker-Allowed', '/webui/');
|
||
res.header('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||
res.send(swContent);
|
||
} catch (error) {
|
||
console.error('[NapCat] [WebUi] Error generating sw.js', error);
|
||
res.status(500).send('Error generating service worker');
|
||
}
|
||
});
|
||
|
||
// ------------中间件结束------------
|
||
|
||
// ------------挂载路由------------
|
||
// 挂载静态路由(前端),路径为 /webui
|
||
app.use('/webui', express.static(pathWrapper.staticPath, {
|
||
maxAge: '1d',
|
||
}));
|
||
|
||
// 插件内存静态资源路由(不需要鉴权)
|
||
// 路径格式: /plugin/:pluginId/mem/:urlPath/*
|
||
app.use('/plugin/:pluginId/mem', async (req, res) => {
|
||
const { pluginId } = req.params;
|
||
if (!pluginId) return res.status(400).json({ code: -1, message: 'Plugin ID is required' });
|
||
|
||
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter | null;
|
||
if (!ob11) return res.status(503).json({ code: -1, message: 'OneBot context not available' });
|
||
|
||
const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter | undefined;
|
||
if (!pluginManager) return res.status(503).json({ code: -1, message: 'Plugin manager not available' });
|
||
|
||
const routerRegistry = pluginManager.getPluginRouter(pluginId);
|
||
const memoryRoutes = routerRegistry?.getMemoryStaticRoutes() || [];
|
||
|
||
for (const { urlPath, files } of memoryRoutes) {
|
||
const prefix = urlPath.startsWith('/') ? urlPath : '/' + urlPath;
|
||
if (req.path.startsWith(prefix)) {
|
||
const filePath = '/' + (req.path.substring(prefix.length).replace(/^\//, '') || '');
|
||
const memFile = files.find(f => ('/' + f.path.replace(/^\//, '')) === filePath);
|
||
if (memFile) {
|
||
try {
|
||
const content = typeof memFile.content === 'function' ? await memFile.content() : memFile.content;
|
||
res.setHeader('Content-Type', memFile.contentType || 'application/octet-stream');
|
||
return res.send(content);
|
||
} catch (err) {
|
||
console.error(`[Plugin: ${pluginId}] Error serving memory file:`, err);
|
||
return res.status(500).json({ code: -1, message: 'Error serving memory file' });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return res.status(404).json({ code: -1, message: 'Memory file not found' });
|
||
});
|
||
|
||
// 插件无认证 API 路由(不需要鉴权)
|
||
// 路径格式: /plugin/:pluginId/api/*
|
||
app.use('/plugin/:pluginId/api', (req, res, next) => {
|
||
const { pluginId } = req.params;
|
||
if (!pluginId) return res.status(400).json({ code: -1, message: 'Plugin ID is required' });
|
||
|
||
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter | null;
|
||
if (!ob11) return res.status(503).json({ code: -1, message: 'OneBot context not available' });
|
||
|
||
const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter | undefined;
|
||
if (!pluginManager) return res.status(503).json({ code: -1, message: 'Plugin manager not available' });
|
||
|
||
const routerRegistry = pluginManager.getPluginRouter(pluginId);
|
||
if (!routerRegistry || !routerRegistry.hasApiNoAuthRoutes()) {
|
||
return res.status(404).json({ code: -1, message: `Plugin '${pluginId}' has no registered no-auth API routes` });
|
||
}
|
||
|
||
// 构建并执行插件无认证 API 路由
|
||
const pluginRouter = routerRegistry.buildApiNoAuthRouter();
|
||
return pluginRouter(req, res, next);
|
||
});
|
||
|
||
// 插件页面路由(不需要鉴权)
|
||
// 路径格式: /plugin/:pluginId/page/:pagePath
|
||
app.get('/plugin/:pluginId/page/:pagePath', (req, res) => {
|
||
const { pluginId, pagePath } = req.params;
|
||
if (!pluginId) return res.status(400).json({ code: -1, message: 'Plugin ID is required' });
|
||
|
||
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter | null;
|
||
if (!ob11) return res.status(503).json({ code: -1, message: 'OneBot context not available' });
|
||
|
||
const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter | undefined;
|
||
if (!pluginManager) return res.status(503).json({ code: -1, message: 'Plugin manager not available' });
|
||
|
||
const routerRegistry = pluginManager.getPluginRouter(pluginId);
|
||
if (!routerRegistry || !routerRegistry.hasPages()) {
|
||
return res.status(404).json({ code: -1, message: `Plugin '${pluginId}' has no registered pages` });
|
||
}
|
||
|
||
const pages = routerRegistry.getPages();
|
||
const page = pages.find(p => p.path === '/' + pagePath || p.path === pagePath);
|
||
if (!page) {
|
||
return res.status(404).json({ code: -1, message: `Page '${pagePath}' not found in plugin '${pluginId}'` });
|
||
}
|
||
|
||
const pluginPath = routerRegistry.getPluginPath();
|
||
if (!pluginPath) {
|
||
return res.status(500).json({ code: -1, message: 'Plugin path not available' });
|
||
}
|
||
|
||
const htmlFilePath = join(pluginPath, page.htmlFile);
|
||
if (!existsSync(htmlFilePath)) {
|
||
return res.status(404).json({ code: -1, message: `HTML file not found: ${page.htmlFile}` });
|
||
}
|
||
|
||
return res.sendFile(htmlFilePath);
|
||
});
|
||
|
||
// 插件文件系统静态资源路由(不需要鉴权)
|
||
// 路径格式: /plugin/:pluginId/files/*
|
||
app.use('/plugin/:pluginId/files', (req, res, next) => {
|
||
const { pluginId } = req.params;
|
||
if (!pluginId) return res.status(400).json({ code: -1, message: 'Plugin ID is required' });
|
||
|
||
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter | null;
|
||
if (!ob11) return res.status(503).json({ code: -1, message: 'OneBot context not available' });
|
||
|
||
const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter | undefined;
|
||
if (!pluginManager) return res.status(503).json({ code: -1, message: 'Plugin manager not available' });
|
||
|
||
const routerRegistry = pluginManager.getPluginRouter(pluginId);
|
||
const staticRoutes = routerRegistry?.getStaticRoutes() || [];
|
||
|
||
for (const { urlPath, localPath } of staticRoutes) {
|
||
const prefix = urlPath.startsWith('/') ? urlPath : '/' + urlPath;
|
||
if (req.path.startsWith(prefix) || req.path === prefix.slice(0, -1)) {
|
||
const staticMiddleware = express.static(localPath, { maxAge: '1d' });
|
||
const originalUrl = req.url;
|
||
req.url = '/' + (req.path.substring(prefix.length).replace(/^\//, '') || '');
|
||
return staticMiddleware(req, res, (err) => {
|
||
req.url = originalUrl;
|
||
err ? next(err) : next();
|
||
});
|
||
}
|
||
}
|
||
res.status(404).json({ code: -1, message: 'Static resource not found' });
|
||
});
|
||
|
||
// 初始化WebSocket服务器
|
||
const sslCerts = await checkCertificates(logger);
|
||
const isHttps = !!sslCerts;
|
||
const server = isHttps && sslCerts ? createHttpsServer(sslCerts, app) : createServer(app);
|
||
server.on('upgrade', (request, socket, head) => {
|
||
const url = new URL(request.url || '', `http://${request.headers.host}`);
|
||
|
||
// 检查是否是调试 WebSocket 连接
|
||
if (url.pathname.startsWith('/api/Debug/ws')) {
|
||
handleDebugWebSocket(request, socket, head);
|
||
} else {
|
||
// 默认为终端 WebSocket
|
||
terminalManager.initialize(request, socket, head, logger);
|
||
}
|
||
});
|
||
// 挂载API接口
|
||
app.use('/api', ALLRouter);
|
||
// 所有剩下的请求都转到静态页面
|
||
const indexFile = join(pathWrapper.staticPath, 'index.html');
|
||
|
||
app.all(/\/webui\/(.*)/, (_req, res) => {
|
||
res.sendFile(indexFile);
|
||
});
|
||
|
||
// 初始服务(先放个首页)
|
||
app.all('/', (_req, res) => {
|
||
res.status(301).header('Location', '/webui').send();
|
||
});
|
||
|
||
// 错误处理中间件,捕获multer的错误
|
||
app.use((err: Error, _: express.Request, res: express.Response, next: express.NextFunction) => {
|
||
if (err instanceof multer.MulterError) {
|
||
return sendError(res, err.message, true);
|
||
}
|
||
next(err);
|
||
});
|
||
|
||
// 全局错误处理中间件(非multer错误)
|
||
app.use((_: Error, __: express.Request, res: express.Response, ___: express.NextFunction) => {
|
||
sendError(res, 'An unknown error occurred.', true);
|
||
});
|
||
|
||
// ------------启动服务------------
|
||
server.listen(port, host, async () => {
|
||
const searchParams = { token };
|
||
logger.log(`[NapCat] [WebUi] WebUi Token: ${token}`);
|
||
logger.log(
|
||
`[NapCat] [WebUi] WebUi User Panel Url: ${createUrl('127.0.0.1', port.toString(), '/webui', searchParams)}`
|
||
);
|
||
if (host !== '') {
|
||
logger.log(
|
||
`[NapCat] [WebUi] WebUi User Panel Url: ${createUrl(host, port.toString(), '/webui', searchParams)}`
|
||
);
|
||
}
|
||
});
|
||
|
||
// ------------Over!------------
|
||
}
|
||
|
||
async function tryUseHost (host: string): Promise<string> {
|
||
return new Promise((resolve, reject) => {
|
||
try {
|
||
const server = net.createServer();
|
||
server.on('listening', () => {
|
||
server.close();
|
||
resolve(host);
|
||
});
|
||
|
||
server.on('error', (err: any) => {
|
||
if (err.code === 'EADDRNOTAVAIL') {
|
||
reject(new Error('主机地址验证失败,可能为非本机地址'));
|
||
} else {
|
||
reject(new Error(`遇到错误: ${err.code}`));
|
||
}
|
||
});
|
||
|
||
// 尝试监听 让系统随机分配一个端口
|
||
server.listen(0, host);
|
||
} catch (error) {
|
||
// 这里捕获到的错误应该是启动服务器时的同步错误
|
||
reject(new Error(`服务器启动时发生错误: ${error}`));
|
||
}
|
||
});
|
||
}
|
||
|
||
async function tryUsePort (port: number, host: string, tryCount: number = 0, singleTry: boolean = false): Promise<number> {
|
||
return new Promise((resolve, reject) => {
|
||
try {
|
||
const server = net.createServer();
|
||
server.on('listening', () => {
|
||
server.close();
|
||
resolve(port);
|
||
});
|
||
|
||
server.on('error', (err: any) => {
|
||
if (err.code === 'EADDRINUSE') {
|
||
if (singleTry) {
|
||
// 只尝试一次,端口被占用则直接失败
|
||
reject(new Error(`端口 ${port} 已被占用`));
|
||
} else if (tryCount < MAX_PORT_TRY) {
|
||
// 递归尝试下一个端口
|
||
resolve(tryUsePort(port + 1, host, tryCount + 1, false));
|
||
} else {
|
||
reject(new Error(`端口尝试失败,达到最大尝试次数: ${MAX_PORT_TRY}`));
|
||
}
|
||
} else {
|
||
reject(new Error(`遇到错误: ${err.code}`));
|
||
}
|
||
});
|
||
|
||
// 尝试监听端口
|
||
server.listen(port, host);
|
||
} catch (error) {
|
||
// 这里捕获到的错误应该是启动服务器时的同步错误
|
||
reject(new Error(`服务器启动时发生错误: ${error}`));
|
||
}
|
||
});
|
||
}
|