NapCatQQ source snapshot
This commit is contained in:
commit
f954252a34
1111 files changed
+139867
No files matched your search
@@ -0,0 +1,3 @@
|
||||
# The Path of NapCatQQ
|
||||
|
||||
Tiny WebUi Backend for NapCatQQ
|
||||
@@ -0,0 +1,583 @@
|
||||
/**
|
||||
* @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, dirname, resolve } 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 { 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}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "napcat-webui-backend",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.ts"
|
||||
},
|
||||
"./*": {
|
||||
"import": "./*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@sinclair/typebox": "^0.34.38",
|
||||
"compressing": "^1.10.3",
|
||||
"compression": "^1.8.1",
|
||||
"express": "^5.0.0",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"json5": "^2.2.3",
|
||||
"multer": "^2.0.1",
|
||||
"napcat-common": "workspace:*",
|
||||
"napcat-dpapi": "workspace:*",
|
||||
"napcat-pty": "workspace:*",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.0.1",
|
||||
"@types/ws": "^8.5.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import { AuthHelper } from '@/napcat-webui-backend/src/helper/SignToken';
|
||||
import { PasskeyHelper } from '@/napcat-webui-backend/src/helper/PasskeyHelper';
|
||||
import { TotpHelper } from '@/napcat-webui-backend/src/helper/TotpHelper';
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { sendSuccess, sendError } from '@/napcat-webui-backend/src/utils/response';
|
||||
import { isEmpty } from '@/napcat-webui-backend/src/utils/check';
|
||||
import { WebUiConfig, getInitialWebUiToken, setInitialWebUiToken } from '@/napcat-webui-backend/index';
|
||||
|
||||
// 登录 - 支持2FA验证
|
||||
export const LoginHandler: RequestHandler = async (req, res) => {
|
||||
// 获取WebUI配置
|
||||
const WebUiConfigData = await WebUiConfig.GetWebUIConfig();
|
||||
// 获取请求体中的hash和totpCode
|
||||
const { hash, totpCode } = req.body;
|
||||
// 获取客户端IP
|
||||
const clientIP = req.ip || req.socket.remoteAddress || '';
|
||||
|
||||
// 如果token为空,返回错误信息
|
||||
if (isEmpty(hash)) {
|
||||
return sendError(res, 'token is empty');
|
||||
}
|
||||
// 检查登录频率
|
||||
if (!WebUiDataRuntime.checkLoginRate(clientIP, WebUiConfigData.loginRate)) {
|
||||
return sendError(res, 'login rate limit');
|
||||
}
|
||||
// 使用启动时缓存的token进行验证,而不是动态读取配置文件
|
||||
const initialToken = getInitialWebUiToken();
|
||||
if (!initialToken) {
|
||||
return sendError(res, 'Server token not initialized');
|
||||
}
|
||||
// 验证初始token hash是否等于提交的token hash
|
||||
if (!AuthHelper.comparePasswordHash(initialToken, hash)) {
|
||||
return sendError(res, 'token is invalid');
|
||||
}
|
||||
|
||||
// 检查是否启用了2FA
|
||||
if (WebUiConfigData.enable2FA && WebUiConfigData.totpSecret) {
|
||||
// 如果没有提供验证码,返回要求验证码
|
||||
if (!totpCode) {
|
||||
return sendSuccess(res, {
|
||||
require2FA: true,
|
||||
message: 'Please enter your authenticator code',
|
||||
});
|
||||
}
|
||||
|
||||
// 验证TOTP验证码
|
||||
if (!TotpHelper.verifyTotp(WebUiConfigData.totpSecret, totpCode)) {
|
||||
return sendError(res, 'Invalid or expired code');
|
||||
}
|
||||
|
||||
// 2FA验证成功,签发凭证
|
||||
const signCredential = Buffer.from(JSON.stringify(AuthHelper.signCredential(hash))).toString('base64');
|
||||
return sendSuccess(res, {
|
||||
Credential: signCredential,
|
||||
require2FA: false,
|
||||
});
|
||||
}
|
||||
|
||||
// 未启用2FA,直接签发凭证
|
||||
const signCredential = Buffer.from(JSON.stringify(AuthHelper.signCredential(hash))).toString('base64');
|
||||
return sendSuccess(res, {
|
||||
Credential: signCredential,
|
||||
});
|
||||
};
|
||||
|
||||
// 退出登录
|
||||
export const LogoutHandler: RequestHandler = async (req, res) => {
|
||||
const authorization = req.headers.authorization;
|
||||
try {
|
||||
const CredentialBase64: string = authorization?.split(' ')[1] as string;
|
||||
const Credential = JSON.parse(Buffer.from(CredentialBase64, 'base64').toString());
|
||||
AuthHelper.revokeCredential(Credential);
|
||||
return sendSuccess(res, 'Logged out successfully');
|
||||
} catch (_e) {
|
||||
return sendError(res, 'Logout failed');
|
||||
}
|
||||
};
|
||||
|
||||
// 检查登录状态
|
||||
export const checkHandler: RequestHandler = async (req, res) => {
|
||||
// 获取请求头中的Authorization
|
||||
const authorization = req.headers.authorization;
|
||||
// 检查凭证
|
||||
try {
|
||||
// 从Authorization中获取凭证
|
||||
const CredentialBase64: string = authorization?.split(' ')[1] as string;
|
||||
// 解析凭证
|
||||
const Credential = JSON.parse(Buffer.from(CredentialBase64, 'base64').toString());
|
||||
|
||||
// 检查凭证是否已被注销
|
||||
if (AuthHelper.isCredentialRevoked(Credential)) {
|
||||
return sendError(res, 'Token has been revoked');
|
||||
}
|
||||
|
||||
// 使用启动时缓存的token进行验证
|
||||
const initialToken = getInitialWebUiToken();
|
||||
if (!initialToken) {
|
||||
return sendError(res, 'Server token not initialized');
|
||||
}
|
||||
// 验证凭证是否在一小时内有效
|
||||
const valid = AuthHelper.validateCredentialWithinOneHour(initialToken, Credential);
|
||||
// 返回成功信息
|
||||
if (valid) return sendSuccess(res, null);
|
||||
// 返回错误信息
|
||||
return sendError(res, 'Authorization Failed');
|
||||
} catch (_e) {
|
||||
// 返回错误信息
|
||||
return sendError(res, 'Authorization Failed');
|
||||
}
|
||||
};
|
||||
|
||||
// 修改密码(token)
|
||||
export const UpdateTokenHandler: RequestHandler = async (req, res) => {
|
||||
const { oldToken, newToken } = req.body;
|
||||
const authorization = req.headers.authorization;
|
||||
|
||||
if (isEmpty(newToken)) {
|
||||
return sendError(res, 'newToken is empty');
|
||||
}
|
||||
|
||||
// 强制要求旧密码
|
||||
if (isEmpty(oldToken)) {
|
||||
return sendError(res, 'oldToken is required');
|
||||
}
|
||||
|
||||
// 检查新旧密码是否相同
|
||||
if (oldToken === newToken) {
|
||||
return sendError(res, '新密码不能与旧密码相同');
|
||||
}
|
||||
|
||||
// 检查新密码强度
|
||||
if (newToken.length < 6) {
|
||||
return sendError(res, '新密码至少需要6个字符');
|
||||
}
|
||||
|
||||
// 检查是否包含字母
|
||||
if (!/[a-zA-Z]/.test(newToken)) {
|
||||
return sendError(res, '新密码必须包含字母');
|
||||
}
|
||||
|
||||
// 检查是否包含数字
|
||||
if (!/[0-9]/.test(newToken)) {
|
||||
return sendError(res, '新密码必须包含数字');
|
||||
}
|
||||
|
||||
try {
|
||||
// 注销当前的Token
|
||||
if (authorization) {
|
||||
const CredentialBase64: string = authorization.split(' ')[1] as string;
|
||||
const Credential = JSON.parse(Buffer.from(CredentialBase64, 'base64').toString());
|
||||
AuthHelper.revokeCredential(Credential);
|
||||
}
|
||||
|
||||
// 使用启动时缓存的token进行验证
|
||||
const initialToken = getInitialWebUiToken();
|
||||
if (!initialToken) {
|
||||
return sendError(res, 'Server token not initialized');
|
||||
}
|
||||
if (initialToken !== oldToken) {
|
||||
return sendError(res, '旧 token 不匹配');
|
||||
}
|
||||
// 直接更新配置文件中的token,不需要通过WebUiConfig.UpdateToken方法
|
||||
await WebUiConfig.UpdateWebUIConfig({ token: newToken });
|
||||
// 更新内存中的缓存token,使新密码立即生效
|
||||
setInitialWebUiToken(newToken);
|
||||
|
||||
return sendSuccess(res, 'Token updated successfully');
|
||||
} catch (e: any) {
|
||||
return sendError(res, `Failed to update token: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 生成Passkey注册选项
|
||||
export const GeneratePasskeyRegistrationOptionsHandler: RequestHandler = async (_req, res) => {
|
||||
try {
|
||||
// 使用固定用户ID,因为WebUI只有一个用户
|
||||
const userId = 'napcat-user';
|
||||
const userName = 'NapCat User';
|
||||
|
||||
// 从请求头获取host来确定RP_ID
|
||||
const host = _req.get('host') || 'localhost';
|
||||
const hostname = host.split(':')[0] || 'localhost'; // 移除端口
|
||||
// 对于本地开发,使用localhost而不是IP地址
|
||||
const rpId = (hostname === '127.0.0.1' || hostname === 'localhost') ? 'localhost' : hostname;
|
||||
|
||||
const options = await PasskeyHelper.generateRegistrationOptions(userId, userName, rpId);
|
||||
return sendSuccess(res, options);
|
||||
} catch (error) {
|
||||
return sendError(res, `Failed to generate registration options: ${(error as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 验证Passkey注册
|
||||
export const VerifyPasskeyRegistrationHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { response } = req.body;
|
||||
if (!response) {
|
||||
return sendError(res, 'Response is required');
|
||||
}
|
||||
|
||||
const origin = req.get('origin') || req.protocol + '://' + req.get('host');
|
||||
const host = req.get('host') || 'localhost';
|
||||
const hostname = host.split(':')[0] || 'localhost'; // 移除端口
|
||||
// 对于本地开发,使用localhost而不是IP地址
|
||||
const rpId = (hostname === '127.0.0.1' || hostname === 'localhost') ? 'localhost' : hostname;
|
||||
const userId = 'napcat-user';
|
||||
const verification = await PasskeyHelper.verifyRegistration(userId, response, origin, rpId);
|
||||
|
||||
if (verification.verified) {
|
||||
return sendSuccess(res, { verified: true });
|
||||
} else {
|
||||
return sendError(res, 'Registration failed');
|
||||
}
|
||||
} catch (error) {
|
||||
return sendError(res, `Registration verification failed: ${(error as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 生成Passkey认证选项
|
||||
export const GeneratePasskeyAuthenticationOptionsHandler: RequestHandler = async (_req, res) => {
|
||||
try {
|
||||
const userId = 'napcat-user';
|
||||
|
||||
if (!(await PasskeyHelper.hasPasskeys(userId))) {
|
||||
return sendError(res, 'No passkeys registered');
|
||||
}
|
||||
|
||||
// 从请求头获取host来确定RP_ID
|
||||
const host = _req.get('host') || 'localhost';
|
||||
const hostname = host.split(':')[0] || 'localhost'; // 移除端口
|
||||
// 对于本地开发,使用localhost而不是IP地址
|
||||
const rpId = (hostname === '127.0.0.1' || hostname === 'localhost') ? 'localhost' : hostname;
|
||||
|
||||
const options = await PasskeyHelper.generateAuthenticationOptions(userId, rpId);
|
||||
return sendSuccess(res, options);
|
||||
} catch (error) {
|
||||
return sendError(res, `Failed to generate authentication options: ${(error as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 验证Passkey认证
|
||||
export const VerifyPasskeyAuthenticationHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { response } = req.body;
|
||||
if (!response) {
|
||||
return sendError(res, 'Response is required');
|
||||
}
|
||||
|
||||
// 获取WebUI配置用于限速检查
|
||||
const WebUiConfigData = await WebUiConfig.GetWebUIConfig();
|
||||
// 获取客户端IP
|
||||
const clientIP = req.ip || req.socket.remoteAddress || '';
|
||||
|
||||
// 检查登录频率
|
||||
if (!WebUiDataRuntime.checkLoginRate(clientIP, WebUiConfigData.loginRate)) {
|
||||
return sendError(res, 'login rate limit');
|
||||
}
|
||||
|
||||
const origin = req.get('origin') || req.protocol + '://' + req.get('host');
|
||||
const host = req.get('host') || 'localhost';
|
||||
const hostname = host.split(':')[0] || 'localhost'; // 移除端口
|
||||
// 对于本地开发,使用localhost而不是IP地址
|
||||
const rpId = (hostname === '127.0.0.1' || hostname === 'localhost') ? 'localhost' : hostname;
|
||||
const userId = 'napcat-user';
|
||||
const verification = await PasskeyHelper.verifyAuthentication(userId, response, origin, rpId);
|
||||
|
||||
if (verification.verified) {
|
||||
// 使用与普通登录相同的凭证签发
|
||||
const initialToken = getInitialWebUiToken();
|
||||
if (!initialToken) {
|
||||
return sendError(res, 'Server token not initialized');
|
||||
}
|
||||
const signCredential = Buffer.from(JSON.stringify(AuthHelper.signCredential(AuthHelper.generatePasswordHash(initialToken)))).toString('base64');
|
||||
return sendSuccess(res, {
|
||||
Credential: signCredential,
|
||||
});
|
||||
} else {
|
||||
return sendError(res, 'Authentication failed');
|
||||
}
|
||||
} catch (error) {
|
||||
return sendError(res, `Authentication verification failed: ${(error as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取2FA状态
|
||||
export const Get2FAStatusHandler: RequestHandler = async (_req, res) => {
|
||||
try {
|
||||
const WebUiConfigData = await WebUiConfig.GetWebUIConfig();
|
||||
return sendSuccess(res, {
|
||||
enable2FA: WebUiConfigData.enable2FA || false,
|
||||
hasSecret: !!WebUiConfigData.totpSecret,
|
||||
});
|
||||
} catch (error) {
|
||||
return sendError(res, `获取2FA状态失败: ${(error as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 生成2FA密钥
|
||||
export const Generate2FASecretHandler: RequestHandler = async (_req, res) => {
|
||||
try {
|
||||
const secret = TotpHelper.generateSecret();
|
||||
const qrCodeUrl = TotpHelper.generateQrCodeUrl(secret, 'NapCat WebUI', 'NapCat');
|
||||
|
||||
return sendSuccess(res, {
|
||||
secret,
|
||||
qrCodeUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
return sendError(res, `Failed to generate 2FA secret: ${(error as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 启用2FA
|
||||
export const Enable2FAHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { secret, totpCode } = req.body;
|
||||
|
||||
if (!secret || !totpCode) {
|
||||
return sendError(res, 'secret and totpCode are required');
|
||||
}
|
||||
|
||||
if (!TotpHelper.verifyTotp(secret, totpCode)) {
|
||||
return sendError(res, 'Invalid code');
|
||||
}
|
||||
|
||||
await WebUiConfig.UpdateWebUIConfig({
|
||||
enable2FA: true,
|
||||
totpSecret: secret,
|
||||
});
|
||||
|
||||
return sendSuccess(res, { message: '2FA enabled' });
|
||||
} catch (error) {
|
||||
return sendError(res, `Failed to enable 2FA: ${(error as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 禁用2FA - 需要验证当前TOTP代码
|
||||
export const Disable2FAHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { totpCode } = req.body;
|
||||
|
||||
if (!totpCode) {
|
||||
return sendError(res, 'totpCode is required to disable 2FA');
|
||||
}
|
||||
|
||||
const WebUiConfigData = await WebUiConfig.GetWebUIConfig();
|
||||
if (!WebUiConfigData.totpSecret) {
|
||||
return sendError(res, '2FA is not enabled');
|
||||
}
|
||||
|
||||
// 验证当前TOTP代码
|
||||
if (!TotpHelper.verifyTotp(WebUiConfigData.totpSecret, totpCode)) {
|
||||
return sendError(res, 'Invalid code');
|
||||
}
|
||||
|
||||
await WebUiConfig.UpdateWebUIConfig({
|
||||
enable2FA: false,
|
||||
totpSecret: '',
|
||||
});
|
||||
|
||||
return sendSuccess(res, { message: '2FA disabled' });
|
||||
} catch (error) {
|
||||
return sendError(res, `Failed to disable 2FA: ${(error as Error).message}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import { existsSync, readdirSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import { join, normalize } from 'node:path';
|
||||
import { webUiPathWrapper } from '@/napcat-webui-backend/index';
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||
import compressing from 'compressing';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
// 使用compressing库进行流式压缩导出
|
||||
export const BackupExportConfigHandler: RequestHandler = async (_req, res) => {
|
||||
const isLogin = WebUiDataRuntime.getQQLoginStatus();
|
||||
if (!isLogin) {
|
||||
return sendError(res, 'Not Login');
|
||||
}
|
||||
|
||||
try {
|
||||
const configPath = webUiPathWrapper.configPath;
|
||||
|
||||
if (!existsSync(configPath)) {
|
||||
return sendError(res, '配置目录不存在');
|
||||
}
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return date.toISOString().replace(/[:.]/g, '-');
|
||||
};
|
||||
const zipFileName = `config_backup_${formatDate(new Date())}.zip`;
|
||||
|
||||
// 设置响应头
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${zipFileName}"`);
|
||||
|
||||
// 使用compressing的Stream API进行流式压缩
|
||||
const stream = new compressing.zip.Stream();
|
||||
|
||||
// 添加目录下的所有文件到压缩流(单层平坦结构)
|
||||
const entries = readdirSync(configPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile()) {
|
||||
const entryPath = join(configPath, entry.name);
|
||||
stream.addEntry(entryPath, { relativePath: entry.name });
|
||||
}
|
||||
}
|
||||
|
||||
// 管道传输到响应
|
||||
stream.pipe(res);
|
||||
|
||||
// 处理流错误
|
||||
stream.on('error', (err) => {
|
||||
console.error('压缩流错误:', err);
|
||||
if (!res.headersSent) {
|
||||
sendError(res, '流式压缩失败');
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
console.error('导出配置失败:', error);
|
||||
if (!res.headersSent) {
|
||||
return sendError(res, `导出配置失败: ${msg}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 从内存Buffer流式解压,返回文件名和内容的映射
|
||||
async function extractZipToMemory (buffer: Buffer): Promise<Map<string, Buffer>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const files = new Map<string, Buffer>();
|
||||
const bufferStream = Readable.from(buffer);
|
||||
const uncompressStream = new compressing.zip.UncompressStream();
|
||||
|
||||
uncompressStream.on('entry', (header, stream, next) => {
|
||||
// 只处理文件,忽略目录
|
||||
if (header.type === 'file') {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
stream.on('end', () => {
|
||||
// 取文件名(忽略路径中的目录部分)
|
||||
const fileName = header.name.split('/').pop() || header.name;
|
||||
files.set(fileName, Buffer.concat(chunks));
|
||||
next();
|
||||
});
|
||||
stream.on('error', (err) => {
|
||||
console.error(`读取文件失败: ${header.name}`, err);
|
||||
next();
|
||||
});
|
||||
} else {
|
||||
stream.resume();
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
uncompressStream.on('finish', () => resolve(files));
|
||||
uncompressStream.on('error', reject);
|
||||
|
||||
bufferStream.pipe(uncompressStream);
|
||||
});
|
||||
}
|
||||
|
||||
// 导入配置 - 流式处理,完全在内存中解压
|
||||
export const BackupImportConfigHandler: RequestHandler = async (req, res) => {
|
||||
// 检查是否有文件上传(multer memoryStorage 模式下文件在 req.file.buffer)
|
||||
if (!req.file || !req.file.buffer) {
|
||||
return sendError(res, '请选择要导入的配置文件');
|
||||
}
|
||||
|
||||
try {
|
||||
const configPath = webUiPathWrapper.configPath;
|
||||
|
||||
// 从内存中解压zip
|
||||
const extractedFiles = await extractZipToMemory(req.file.buffer);
|
||||
|
||||
if (extractedFiles.size === 0) {
|
||||
return sendError(res, '配置文件为空或格式不正确');
|
||||
}
|
||||
|
||||
// 备份当前配置到内存
|
||||
const backupFiles = new Map<string, Buffer>();
|
||||
if (existsSync(configPath)) {
|
||||
const currentFiles = readdirSync(configPath, { withFileTypes: true });
|
||||
for (const entry of currentFiles) {
|
||||
if (entry.isFile()) {
|
||||
const filePath = join(configPath, entry.name);
|
||||
backupFiles.set(entry.name, readFileSync(filePath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 写入新的配置文件
|
||||
for (const [fileName, content] of extractedFiles) {
|
||||
// 防止路径穿越攻击
|
||||
const destPath = join(configPath, fileName);
|
||||
const normalizedPath = normalize(destPath);
|
||||
if (!normalizedPath.startsWith(normalize(configPath))) {
|
||||
continue;
|
||||
}
|
||||
writeFileSync(destPath, content);
|
||||
}
|
||||
|
||||
return sendSuccess(res, {
|
||||
message: '配置导入成功,重启后生效~',
|
||||
filesImported: extractedFiles.size,
|
||||
filesBackedUp: backupFiles.size,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('导入配置失败:', error);
|
||||
const msg = (error as Error).message;
|
||||
return sendError(res, `导入配置失败: ${msg}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
|
||||
import { sendSuccess, sendError } from '@/napcat-webui-backend/src/utils/response';
|
||||
import { WebUiConfig, webUiPathWrapper } from '@/napcat-webui-backend/index';
|
||||
import { getLatestTag, getAllTags, compareSemVer } from 'napcat-common/src/helper';
|
||||
import { getLatestActionArtifacts, getMirrorConfig } from '@/napcat-common/src/mirror';
|
||||
import { NapCatCoreWorkingEnv } from '@/napcat-webui-backend/src/types';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export const GetNapCatVersion: RequestHandler = (_, res) => {
|
||||
const data = WebUiDataRuntime.GetNapCatVersion();
|
||||
sendSuccess(res, { version: data });
|
||||
};
|
||||
|
||||
export const getLatestTagHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const latestTag = await getLatestTag();
|
||||
sendSuccess(res, latestTag);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch latest tag', details: (error as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 版本信息接口
|
||||
*/
|
||||
export interface VersionInfo {
|
||||
tag: string;
|
||||
type: 'release' | 'prerelease' | 'action';
|
||||
/** Action artifact 专用字段 */
|
||||
artifactId?: number;
|
||||
artifactName?: string;
|
||||
createdAt?: string;
|
||||
expiresAt?: string;
|
||||
size?: number;
|
||||
workflowRunId?: number;
|
||||
headSha?: string;
|
||||
workflowTitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用的版本(release + action artifacts)
|
||||
* 支持分页,懒加载:根据 type 参数只获取需要的版本类型
|
||||
*/
|
||||
export const getAllReleasesHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const page = parseInt(req.query['page'] as string) || 1;
|
||||
const pageSize = parseInt(req.query['pageSize'] as string) || 20;
|
||||
const typeFilter = req.query['type'] as string | undefined; // 'release' | 'action' | 'all'
|
||||
const searchQuery = (req.query['search'] as string || '').toLowerCase().trim();
|
||||
const mirror = req.query['mirror'] as string | undefined;
|
||||
|
||||
let versions: VersionInfo[] = [];
|
||||
let actionVersions: VersionInfo[] = [];
|
||||
let usedMirror = '';
|
||||
|
||||
// If mirror is specified, report it as used (will be confirmed by actual fetching response)
|
||||
if (mirror) {
|
||||
usedMirror = mirror;
|
||||
}
|
||||
|
||||
// 懒加载:只获取需要的版本类型
|
||||
const needReleases = !typeFilter || typeFilter === 'all' || typeFilter === 'release';
|
||||
const needActions = typeFilter === 'action' || typeFilter === 'all';
|
||||
|
||||
// 获取正式版本(仅当需要时)
|
||||
if (needReleases) {
|
||||
try {
|
||||
const result = await getAllTags(mirror);
|
||||
// 如果没有指定镜像,使用实际上使用的镜像
|
||||
if (!mirror) {
|
||||
usedMirror = result.mirror;
|
||||
}
|
||||
|
||||
versions = result.tags.map(tag => {
|
||||
const isPrerelease = /-(alpha|beta|rc|dev|pre|snapshot)/i.test(tag);
|
||||
return {
|
||||
tag,
|
||||
type: isPrerelease ? 'prerelease' : 'release',
|
||||
} as VersionInfo;
|
||||
});
|
||||
|
||||
// 使用语义化版本排序(最新的在前)
|
||||
versions.sort((a, b) => -compareSemVer(a.tag, b.tag));
|
||||
} catch {
|
||||
// 如果获取 tags 失败,返回空列表而不是抛出错误
|
||||
versions = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 Action Artifacts(仅当需要时)
|
||||
if (needActions) {
|
||||
try {
|
||||
const { artifacts, mirror: actionMirror } = await getLatestActionArtifacts('NapNeko', 'NapCatQQ', 'build.yml', 'main', 10, mirror);
|
||||
|
||||
// 根据当前工作环境自动过滤对应的 artifact 类型
|
||||
const isFramework = WebUiDataRuntime.getWorkingEnv() === NapCatCoreWorkingEnv.Framework;
|
||||
const targetArtifactName = isFramework ? 'NapCat.Framework' : 'NapCat.Shell';
|
||||
|
||||
// 如果没有指定镜像,且 action 实际上用了一个镜像(自动选择的),更新 usedMirror
|
||||
if (!mirror && actionMirror) {
|
||||
usedMirror = actionMirror;
|
||||
}
|
||||
|
||||
actionVersions = artifacts
|
||||
.filter(a => a && a.name === targetArtifactName)
|
||||
.map(a => ({
|
||||
tag: `action-${a.id}`,
|
||||
type: 'action' as const,
|
||||
artifactId: a.id,
|
||||
artifactName: a.name,
|
||||
createdAt: a.created_at,
|
||||
expiresAt: a.expires_at,
|
||||
size: a.size_in_bytes,
|
||||
workflowRunId: a.workflow_run_id,
|
||||
headSha: a.head_sha,
|
||||
workflowTitle: a.workflow_title,
|
||||
}));
|
||||
} catch {
|
||||
// 获取失败时返回空列表
|
||||
actionVersions = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 合并版本列表(action 在最前面)
|
||||
let allVersions = [...actionVersions, ...versions];
|
||||
|
||||
// 搜索过滤
|
||||
if (searchQuery) {
|
||||
allVersions = allVersions.filter(v => {
|
||||
const tagMatch = v.tag.toLowerCase().includes(searchQuery);
|
||||
const nameMatch = v.artifactName?.toLowerCase().includes(searchQuery);
|
||||
const titleMatch = v.workflowTitle?.toLowerCase().includes(searchQuery);
|
||||
const shaMatch = v.headSha?.toLowerCase().includes(searchQuery);
|
||||
return tagMatch || nameMatch || titleMatch || shaMatch;
|
||||
});
|
||||
}
|
||||
|
||||
// 分页
|
||||
const total = allVersions.length;
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
const start = (page - 1) * pageSize;
|
||||
const end = start + pageSize;
|
||||
const paginatedVersions = allVersions.slice(start, end);
|
||||
|
||||
sendSuccess(res, {
|
||||
versions: paginatedVersions,
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages,
|
||||
},
|
||||
mirror: usedMirror,
|
||||
});
|
||||
} catch (_error) {
|
||||
res.status(500).json({ error: 'Failed to fetch releases' });
|
||||
}
|
||||
};
|
||||
|
||||
export const QQVersionHandler: RequestHandler = (_, res) => {
|
||||
const data = WebUiDataRuntime.getQQVersion();
|
||||
sendSuccess(res, data);
|
||||
};
|
||||
|
||||
export const GetThemeConfigHandler: RequestHandler = async (_, res) => {
|
||||
const data = await WebUiConfig.GetTheme();
|
||||
sendSuccess(res, data);
|
||||
};
|
||||
|
||||
export const SetThemeConfigHandler: RequestHandler = async (req, res) => {
|
||||
const { theme } = req.body;
|
||||
await WebUiConfig.UpdateTheme(theme);
|
||||
sendSuccess(res, { message: '更新成功' });
|
||||
};
|
||||
|
||||
export const GetMirrorsHandler: RequestHandler = (_, res) => {
|
||||
const config = getMirrorConfig();
|
||||
sendSuccess(res, { mirrors: config.fileMirrors });
|
||||
};
|
||||
|
||||
export const GetNapCatFileHashHandler: RequestHandler = (_, res) => {
|
||||
try {
|
||||
const filePath = path.join(webUiPathWrapper.binaryPath, 'napcat.mjs');
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
const hash = crypto.createHash('sha512').update(fileBuffer).digest('hex');
|
||||
sendSuccess(res, { hash, file: 'napcat.mjs', algorithm: 'sha512' });
|
||||
} catch (error) {
|
||||
sendError(res, `无法计算 napcat.mjs 的 hash:${(error as Error).message}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,452 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { WebSocket, WebSocketServer, RawData } from 'ws';
|
||||
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { IncomingMessage } from 'http';
|
||||
import { OB11Response, OneBotAction } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { OB11LifeCycleEvent, LifeCycleSubType } from '@/napcat-onebot/event/meta/OB11LifeCycleEvent';
|
||||
import { IOB11NetworkAdapter } from '@/napcat-onebot/network/adapter';
|
||||
import { WebsocketServerConfig } from '@/napcat-onebot/config/config';
|
||||
import { ActionMap } from '@/napcat-onebot/action';
|
||||
import { NapCatCore } from '@/napcat-core/index';
|
||||
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
|
||||
import { OB11EmitEventContent, OB11NetworkReloadType } from '@/napcat-onebot/network/index';
|
||||
|
||||
import json5 from 'json5';
|
||||
|
||||
type ActionNameType = typeof ActionName[keyof typeof ActionName];
|
||||
|
||||
const router: Router = Router();
|
||||
const DEFAULT_ADAPTER_NAME = 'debug-primary';
|
||||
|
||||
/**
|
||||
* 获取所有 Action 的 Schema 信息
|
||||
*/
|
||||
router.get('/schemas', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const obContext = WebUiDataRuntime.getOneBotContext();
|
||||
if (!obContext) {
|
||||
return sendError(res, 'OneBot 未初始化');
|
||||
}
|
||||
const schemas: Record<string, any> = {};
|
||||
|
||||
// 遍历 ActionName 中定义的所有路由
|
||||
for (const key in ActionName) {
|
||||
const actionName = (ActionName as any)[key];
|
||||
if (actionName === ActionName.Unknown) continue;
|
||||
|
||||
const handler = obContext.actions.get(actionName);
|
||||
if (handler) {
|
||||
const action = handler as OneBotAction<unknown, unknown>;
|
||||
schemas[actionName] = {
|
||||
description: action.actionSummary || action.actionDescription,
|
||||
payload: action.payloadSchema,
|
||||
response: action.returnSchema,
|
||||
payloadExample: action.payloadExample,
|
||||
tags: action.actionTags,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
sendSuccess(res, schemas);
|
||||
} catch (error: unknown) {
|
||||
sendError(res, (error as Error).message);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 统一的调试适配器
|
||||
* 用于注入到 OneBot NetworkManager,接收所有事件并转发给 WebSocket 客户端
|
||||
*/
|
||||
class DebugAdapter extends IOB11NetworkAdapter<WebsocketServerConfig> {
|
||||
readonly token: string;
|
||||
wsClients: WebSocket[] = [];
|
||||
wsClientWithEvent: WebSocket[] = [];
|
||||
lastActivityTime: number = Date.now();
|
||||
inactivityTimer: NodeJS.Timeout | null = null;
|
||||
readonly INACTIVITY_TIMEOUT = 5 * 60 * 1000; // 5分钟不活跃
|
||||
|
||||
override get isActive (): boolean {
|
||||
return this.isEnable && this.wsClientWithEvent.length > 0;
|
||||
}
|
||||
|
||||
constructor (sessionId: string, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap) {
|
||||
const config: WebsocketServerConfig = {
|
||||
enable: true,
|
||||
name: `debug-${sessionId}`,
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
messagePostFormat: 'array',
|
||||
reportSelfMessage: true,
|
||||
token: '',
|
||||
enableForcePushEvent: true,
|
||||
debug: true,
|
||||
heartInterval: 0,
|
||||
};
|
||||
|
||||
super(`debug-${sessionId}`, config, core, obContext, actions);
|
||||
this.token = Math.random().toString(36).substring(2) + Math.random().toString(36).substring(2);
|
||||
this.isEnable = false;
|
||||
this.startInactivityCheck();
|
||||
}
|
||||
|
||||
async open (): Promise<void> {
|
||||
if (this.isEnable) {
|
||||
this.logger.logError('[Debug] Cannot open an already opened adapter');
|
||||
return;
|
||||
}
|
||||
this.logger.log('[Debug] Adapter opened:', this.name);
|
||||
this.isEnable = true;
|
||||
}
|
||||
|
||||
async close (): Promise<void> {
|
||||
if (!this.isEnable) {
|
||||
return;
|
||||
}
|
||||
this.logger.log('[Debug] Adapter closing:', this.name);
|
||||
this.isEnable = false;
|
||||
|
||||
// 停止不活跃检查定时器
|
||||
if (this.inactivityTimer) {
|
||||
clearInterval(this.inactivityTimer);
|
||||
this.inactivityTimer = null;
|
||||
}
|
||||
|
||||
// 关闭所有 WebSocket 连接并移除事件监听器
|
||||
this.wsClients.forEach((client) => {
|
||||
try {
|
||||
client.removeAllListeners();
|
||||
client.close();
|
||||
} catch (error) {
|
||||
this.logger.logError('[Debug] 关闭 WebSocket 失败:', error);
|
||||
}
|
||||
});
|
||||
this.wsClients = [];
|
||||
this.wsClientWithEvent = [];
|
||||
}
|
||||
|
||||
async reload (_config: unknown): Promise<OB11NetworkReloadType> {
|
||||
return OB11NetworkReloadType.NetWorkReload;
|
||||
}
|
||||
|
||||
async onEvent<T extends OB11EmitEventContent> (event: T): Promise<void> {
|
||||
this.updateActivity();
|
||||
|
||||
const payload = JSON.stringify(event);
|
||||
this.wsClientWithEvent.forEach((wsClient) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
wsClient.send(payload);
|
||||
} catch (error) {
|
||||
this.logger.logError('[Debug] 发送事件失败:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async callApi (actionName: ActionNameType, params: Record<string, unknown>): Promise<unknown> {
|
||||
this.updateActivity();
|
||||
|
||||
const action = this.actions.get(actionName as Parameters<typeof this.actions.get>[0]);
|
||||
if (!action) {
|
||||
throw new Error(`不支持的 API: ${actionName}`);
|
||||
}
|
||||
|
||||
type ActionHandler = { handle: (params: unknown, ...args: unknown[]) => Promise<unknown>; };
|
||||
return await (action as ActionHandler).handle(params, this.name, this.config);
|
||||
}
|
||||
|
||||
private async handleMessage (wsClient: WebSocket, message: RawData): Promise<void> {
|
||||
this.updateActivity();
|
||||
let receiveData: { action: ActionNameType, params?: Record<string, unknown>, echo?: unknown; } = {
|
||||
action: ActionName.Unknown,
|
||||
params: {},
|
||||
};
|
||||
let echo: unknown;
|
||||
|
||||
try {
|
||||
receiveData = json5.parse(message.toString());
|
||||
echo = receiveData.echo;
|
||||
} catch {
|
||||
this.sendToClient(wsClient, OB11Response.error('json解析失败,请检查数据格式', 1400, echo));
|
||||
return;
|
||||
}
|
||||
|
||||
receiveData.params = receiveData?.params || {};
|
||||
|
||||
const action = this.actions.get(receiveData.action as Parameters<typeof this.actions.get>[0]);
|
||||
if (!action) {
|
||||
this.logger.logError('[Debug] 不支持的API:', receiveData.action);
|
||||
this.sendToClient(wsClient, OB11Response.error('不支持的API ' + receiveData.action, 1404, echo));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
type ActionHandler = { websocketHandle: (params: unknown, ...args: unknown[]) => Promise<unknown>; };
|
||||
const retdata = await (action as ActionHandler).websocketHandle(receiveData.params, echo ?? '', this.name, this.config, {
|
||||
send: async (data: object) => {
|
||||
this.sendToClient(wsClient, OB11Response.ok(data, echo ?? '', true));
|
||||
},
|
||||
});
|
||||
this.sendToClient(wsClient, retdata);
|
||||
} catch (e: unknown) {
|
||||
const error = e as Error;
|
||||
this.logger.logError('[Debug] 处理消息失败:', error);
|
||||
this.sendToClient(wsClient, OB11Response.error(error.message || '内部错误', 1200, echo));
|
||||
}
|
||||
}
|
||||
|
||||
private sendToClient (wsClient: WebSocket, data: unknown): void {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
wsClient.send(JSON.stringify(data));
|
||||
} catch (error) {
|
||||
this.logger.logError('[Debug] 发送消息失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async addWsClient (ws: WebSocket): Promise<void> {
|
||||
this.wsClientWithEvent.push(ws);
|
||||
this.wsClients.push(ws);
|
||||
this.updateActivity();
|
||||
|
||||
// 发送连接事件
|
||||
this.sendToClient(ws, new OB11LifeCycleEvent(this.core, LifeCycleSubType.CONNECT));
|
||||
|
||||
ws.on('error', (err) => this.logger.log('[Debug] WebSocket Error:', err.message));
|
||||
ws.on('message', (message) => {
|
||||
this.handleMessage(ws, message).catch((e: unknown) => {
|
||||
this.logger.logError('[Debug] handleMessage error:', e);
|
||||
});
|
||||
});
|
||||
ws.on('ping', () => ws.pong());
|
||||
ws.once('close', () => this.removeWsClient(ws));
|
||||
}
|
||||
|
||||
private removeWsClient (ws: WebSocket): void {
|
||||
const normalIndex = this.wsClients.indexOf(ws);
|
||||
if (normalIndex !== -1) {
|
||||
this.wsClients.splice(normalIndex, 1);
|
||||
}
|
||||
const eventIndex = this.wsClientWithEvent.indexOf(ws);
|
||||
if (eventIndex !== -1) {
|
||||
this.wsClientWithEvent.splice(eventIndex, 1);
|
||||
}
|
||||
}
|
||||
|
||||
updateActivity (): void {
|
||||
this.lastActivityTime = Date.now();
|
||||
}
|
||||
|
||||
startInactivityCheck (): void {
|
||||
this.inactivityTimer = setInterval(() => {
|
||||
const inactive = Date.now() - this.lastActivityTime;
|
||||
if (inactive > this.INACTIVITY_TIMEOUT && this.wsClients.length === 0) {
|
||||
this.logger.log(`[Debug] Adapter ${this.name} 不活跃,自动关闭`);
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
if (oneBotContext) {
|
||||
// 先从管理器移除,避免重复销毁
|
||||
debugAdapterManager.removeAdapter(this.name);
|
||||
// 使用 NetworkManager 标准流程关闭
|
||||
oneBotContext.networkManager.closeSomeAdapters([this]).catch((e: unknown) => {
|
||||
this.logger.logError('[Debug] 自动关闭适配器失败:', e);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
validateToken (inputToken: string): boolean {
|
||||
return this.token === inputToken;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试适配器管理器(单例管理)
|
||||
*/
|
||||
class DebugAdapterManager {
|
||||
private currentAdapter: DebugAdapter | null = null;
|
||||
|
||||
getOrCreateAdapter (): DebugAdapter {
|
||||
// 如果已存在且活跃,直接返回
|
||||
if (this.currentAdapter) {
|
||||
this.currentAdapter.updateActivity();
|
||||
return this.currentAdapter;
|
||||
}
|
||||
|
||||
// 获取 OneBot 上下文
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
if (!oneBotContext) {
|
||||
throw new Error('OneBot 未初始化,无法创建调试适配器');
|
||||
}
|
||||
|
||||
// 创建新实例
|
||||
const adapter = new DebugAdapter('primary', oneBotContext.core, oneBotContext, oneBotContext.actions);
|
||||
this.currentAdapter = adapter;
|
||||
|
||||
// 使用 NetworkManager 标准流程注册并打开适配器
|
||||
oneBotContext.networkManager.registerAdapterAndOpen(adapter).catch((e: unknown) => {
|
||||
console.error('[Debug] 注册适配器失败:', e);
|
||||
});
|
||||
|
||||
return adapter;
|
||||
}
|
||||
|
||||
getAdapter (name: string): DebugAdapter | undefined {
|
||||
if (this.currentAdapter && this.currentAdapter.name === name) {
|
||||
return this.currentAdapter;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
removeAdapter (name: string) {
|
||||
if (this.currentAdapter && this.currentAdapter.name === name) {
|
||||
this.currentAdapter = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const debugAdapterManager = new DebugAdapterManager();
|
||||
|
||||
/**
|
||||
* 获取或创建调试会话
|
||||
*/
|
||||
router.post('/create', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const adapter = debugAdapterManager.getOrCreateAdapter();
|
||||
sendSuccess(res, {
|
||||
adapterName: adapter.name,
|
||||
token: adapter.token,
|
||||
message: '调试适配器已就绪',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
sendError(res, err.message);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* HTTP 调用 OneBot API (支持默认 adapter)
|
||||
*/
|
||||
const handleCallApi = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const adapterName = req.params['adapterName'] || req.body.adapterName || DEFAULT_ADAPTER_NAME;
|
||||
|
||||
let adapter = debugAdapterManager.getAdapter(adapterName);
|
||||
|
||||
// 如果是默认 adapter 且不存在,尝试创建
|
||||
if (!adapter && adapterName === DEFAULT_ADAPTER_NAME) {
|
||||
adapter = debugAdapterManager.getOrCreateAdapter();
|
||||
}
|
||||
|
||||
if (!adapter) {
|
||||
return sendError(res, '调试适配器不存在');
|
||||
}
|
||||
|
||||
const { action, params } = req.body;
|
||||
const result = await adapter.callApi(action as ActionNameType, params || {});
|
||||
sendSuccess(res, result);
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
sendError(res, err.message);
|
||||
}
|
||||
};
|
||||
|
||||
router.post('/call/:adapterName', handleCallApi);
|
||||
router.post('/call', handleCallApi);
|
||||
|
||||
/**
|
||||
* 关闭调试适配器
|
||||
*/
|
||||
router.post('/close/:adapterName', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { adapterName } = req.params;
|
||||
if (!adapterName) {
|
||||
return sendError(res, '缺少 adapterName 参数');
|
||||
}
|
||||
|
||||
const adapter = debugAdapterManager.getAdapter(adapterName);
|
||||
if (!adapter) {
|
||||
return sendError(res, '调试适配器不存在');
|
||||
}
|
||||
|
||||
// 先从管理器移除,避免重复销毁
|
||||
debugAdapterManager.removeAdapter(adapterName);
|
||||
|
||||
// 使用 NetworkManager 标准流程关闭适配器
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
if (oneBotContext) {
|
||||
await oneBotContext.networkManager.closeSomeAdapters([adapter]);
|
||||
}
|
||||
|
||||
sendSuccess(res, { message: '调试适配器已关闭' });
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
sendError(res, err.message);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* WebSocket 连接处理
|
||||
* 路径: /api/Debug/ws?adapterName=xxx&token=xxx
|
||||
*/
|
||||
export function handleDebugWebSocket (request: IncomingMessage, socket: unknown, head: unknown) {
|
||||
const url = new URL(request.url || '', `http://${request.headers.host}`);
|
||||
let adapterName = url.searchParams.get('adapterName');
|
||||
const token = url.searchParams.get('token') || url.searchParams.get('access_token');
|
||||
|
||||
// 默认 adapterName
|
||||
if (!adapterName) {
|
||||
adapterName = DEFAULT_ADAPTER_NAME;
|
||||
}
|
||||
|
||||
// Debug session should provide token
|
||||
if (!token) {
|
||||
console.log('[Debug] WebSocket 连接被拒绝: 缺少 Token');
|
||||
(socket as { write: (data: string) => void; destroy: () => void; }).write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
(socket as { destroy: () => void; }).destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
let adapter = debugAdapterManager.getAdapter(adapterName);
|
||||
|
||||
// 如果是默认 adapter 且不存在,尝试创建
|
||||
if (!adapter && adapterName === DEFAULT_ADAPTER_NAME) {
|
||||
try {
|
||||
adapter = debugAdapterManager.getOrCreateAdapter();
|
||||
} catch (error) {
|
||||
console.log('[Debug] WebSocket 连接被拒绝: 无法创建适配器', error);
|
||||
(socket as { write: (data: string) => void; destroy: () => void; }).write('HTTP/1.1 500 Internal Server Error\r\n\r\n');
|
||||
(socket as { destroy: () => void; }).destroy();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!adapter) {
|
||||
console.log('[Debug] WebSocket 连接被拒绝: 适配器不存在');
|
||||
(socket as { write: (data: string) => void; destroy: () => void; }).write('HTTP/1.1 404 Not Found\r\n\r\n');
|
||||
(socket as { destroy: () => void; }).destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!adapter.validateToken(token)) {
|
||||
console.log('[Debug] WebSocket 连接被拒绝: Token 无效');
|
||||
(socket as { write: (data: string) => void; destroy: () => void; }).write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
||||
(socket as { destroy: () => void; }).destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建 WebSocket 服务器
|
||||
const wsServer = new WebSocketServer({ noServer: true });
|
||||
|
||||
wsServer.handleUpgrade(request, socket as never, head as Buffer, (ws) => {
|
||||
adapter.addWsClient(ws).catch((e: unknown) => {
|
||||
console.error('[Debug] 添加 WebSocket 客户端失败:', e);
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,665 @@
|
||||
import type { RequestHandler } from 'express';
|
||||
import { sendError, sendSuccess } from '../utils/response';
|
||||
import fsProm from 'fs/promises';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import compressing from 'compressing';
|
||||
import { PassThrough } from 'stream';
|
||||
import multer from 'multer';
|
||||
import webUIFontUploader from '../uploader/webui_font';
|
||||
import diskUploader from '../uploader/disk';
|
||||
import { WebUiConfig, getInitialWebUiToken, webUiPathWrapper } from '@/napcat-webui-backend/index';
|
||||
|
||||
const isWindows = os.platform() === 'win32';
|
||||
|
||||
// 安全地从查询参数中提取字符串值,防止类型混淆
|
||||
const getQueryStringParam = (param: any): string => {
|
||||
if (Array.isArray(param)) {
|
||||
return String(param[0] || '');
|
||||
}
|
||||
return String(param || '');
|
||||
};
|
||||
|
||||
// 获取系统根目录列表(Windows返回盘符列表,其他系统返回['/'])
|
||||
const getRootDirs = async (): Promise<string[]> => {
|
||||
if (!isWindows) return ['/'];
|
||||
|
||||
// Windows 驱动器字母 (A-Z)
|
||||
const drives: string[] = [];
|
||||
for (let i = 65; i <= 90; i++) {
|
||||
const driveLetter = String.fromCharCode(i);
|
||||
try {
|
||||
await fsProm.access(`${driveLetter}:\\`);
|
||||
drives.push(`${driveLetter}:`);
|
||||
} catch {
|
||||
// 如果驱动器不存在或无法访问,跳过
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return drives.length > 0 ? drives : ['C:'];
|
||||
};
|
||||
|
||||
// 规范化路径并进行安全验证
|
||||
const normalizePath = (inputPath: string): string => {
|
||||
if (!inputPath) {
|
||||
// 对于空路径,Windows返回用户主目录,其他系统返回根目录
|
||||
return isWindows ? process.env['USERPROFILE'] || 'C:\\' : '/';
|
||||
}
|
||||
|
||||
// 对输入路径进行清理,移除潜在的危险字符
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const cleanedPath = inputPath.replace(/[\x01-\x1f\x7f]/g, ''); // 移除控制字符
|
||||
|
||||
// 如果是Windows且输入为纯盘符(可能带或不带斜杠),统一返回 "X:\"
|
||||
if (isWindows && /^[A-Z]:[\\/]*$/i.test(cleanedPath)) {
|
||||
return cleanedPath.slice(0, 2) + '\\';
|
||||
}
|
||||
|
||||
// 安全验证:检查是否包含危险的路径遍历模式(在规范化之前)
|
||||
if (containsPathTraversal(cleanedPath)) {
|
||||
throw new Error('Invalid path: path traversal detected');
|
||||
}
|
||||
|
||||
// 进行路径规范化
|
||||
const normalized = path.resolve(cleanedPath);
|
||||
|
||||
// 再次检查规范化后的路径,确保没有绕过安全检查
|
||||
if (containsPathTraversal(normalized)) {
|
||||
throw new Error('Invalid path: path traversal detected after normalization');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保规范化后的路径不包含连续的路径分隔符
|
||||
const finalPath = normalized.replace(/[\\/]+/g, path.sep);
|
||||
|
||||
return finalPath;
|
||||
};
|
||||
|
||||
// 检查路径是否包含路径遍历攻击模式
|
||||
const containsPathTraversal = (inputPath: string): boolean => {
|
||||
// 对输入进行URL解码,防止编码绕过
|
||||
let decodedPath = inputPath;
|
||||
try {
|
||||
decodedPath = decodeURIComponent(inputPath);
|
||||
} catch {
|
||||
// 如果解码失败,使用原始路径
|
||||
}
|
||||
|
||||
// 将路径统一为正斜杠格式进行检查
|
||||
const normalizedForCheck = decodedPath.replace(/\\/g, '/');
|
||||
|
||||
// 检查危险模式 - 更全面的路径遍历检测
|
||||
const dangerousPatterns = [
|
||||
/\.\.\//, // ../ 模式
|
||||
/\/\.\./, // /.. 模式
|
||||
/^\.\./, // 以.. 开头
|
||||
/\.\.$/, // 以.. 结尾
|
||||
/\.\.\\/, // ..\ 模式(Windows)
|
||||
/\\\.\./, // \.. 模式(Windows)
|
||||
/%2e%2e/i, // URL编码的..
|
||||
/%252e%252e/i, // 双重URL编码的..
|
||||
// eslint-disable-next-line no-control-regex
|
||||
/\.\.\x00/, // null字节攻击
|
||||
/\0/, // null字节
|
||||
];
|
||||
|
||||
return dangerousPatterns.some(pattern => pattern.test(normalizedForCheck));
|
||||
};
|
||||
|
||||
interface FileInfo {
|
||||
name: string;
|
||||
isDirectory: boolean;
|
||||
size: number;
|
||||
mtime: Date;
|
||||
}
|
||||
|
||||
// 添加系统文件黑名单
|
||||
const SYSTEM_FILES = new Set(['pagefile.sys', 'swapfile.sys', 'hiberfil.sys', 'System Volume Information']);
|
||||
|
||||
// 检查是否为WebUI配置文件
|
||||
const isWebUIConfigFile = (filePath: string): boolean => {
|
||||
// 先用字符串快速筛选
|
||||
if (!filePath.includes('webui.json')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 进入更严格的路径判断 - 统一路径分隔符为 /
|
||||
const webUIConfigPath = path.resolve(webUiPathWrapper.configPath, 'webui.json').replace(/\\/g, '/');
|
||||
const targetPath = path.resolve(filePath).replace(/\\/g, '/');
|
||||
|
||||
// 统一分隔符后进行路径比较
|
||||
return targetPath === webUIConfigPath;
|
||||
};
|
||||
|
||||
// WebUI配置文件脱敏处理
|
||||
const sanitizeWebUIConfig = (content: string): string => {
|
||||
try {
|
||||
const config = JSON.parse(content);
|
||||
if (config.token) {
|
||||
config.token = '******';
|
||||
}
|
||||
return JSON.stringify(config, null, 4);
|
||||
} catch {
|
||||
// 如果解析失败,返回原内容
|
||||
return content;
|
||||
}
|
||||
};
|
||||
|
||||
// 检查同类型的文件或目录是否存在
|
||||
const checkSameTypeExists = async (pathToCheck: string, isDirectory: boolean): Promise<boolean> => {
|
||||
try {
|
||||
const stat = await fsProm.stat(pathToCheck);
|
||||
// 只有当类型相同时才认为是冲突
|
||||
return stat.isDirectory() === isDirectory;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取目录内容
|
||||
export const ListFilesHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const requestPath = getQueryStringParam(req.query['path']) || (isWindows ? process.env['USERPROFILE'] || 'C:\\' : '/');
|
||||
|
||||
let normalizedPath: string;
|
||||
try {
|
||||
normalizedPath = normalizePath(requestPath);
|
||||
} catch (_pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
const onlyDirectory = req.query['onlyDirectory'] === 'true';
|
||||
|
||||
// 如果是根路径且在Windows系统上,返回盘符列表
|
||||
if (isWindows && (!requestPath || requestPath === '/' || requestPath === '\\')) {
|
||||
const drives = await getRootDirs();
|
||||
const driveInfos: FileInfo[] = await Promise.all(
|
||||
drives.map(async (drive) => {
|
||||
try {
|
||||
const stat = await fsProm.stat(`${drive}\\`);
|
||||
return {
|
||||
name: drive,
|
||||
isDirectory: true,
|
||||
size: 0,
|
||||
mtime: stat.mtime,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
name: drive,
|
||||
isDirectory: true,
|
||||
size: 0,
|
||||
mtime: new Date(),
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
return sendSuccess(res, driveInfos);
|
||||
}
|
||||
|
||||
const files = await fsProm.readdir(normalizedPath);
|
||||
let fileInfos: FileInfo[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
// 跳过系统文件
|
||||
if (SYSTEM_FILES.has(file)) continue;
|
||||
|
||||
try {
|
||||
const fullPath = path.join(normalizedPath, file);
|
||||
const stat = await fsProm.stat(fullPath);
|
||||
fileInfos.push({
|
||||
name: file,
|
||||
isDirectory: stat.isDirectory(),
|
||||
size: stat.size,
|
||||
mtime: stat.mtime,
|
||||
});
|
||||
} catch (_error) {
|
||||
// 忽略无法访问的文件
|
||||
// console.warn(`无法访问文件 ${file}:`, error);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果请求参数 onlyDirectory 为 true,则只返回目录信息
|
||||
if (onlyDirectory) {
|
||||
fileInfos = fileInfos.filter((info) => info.isDirectory);
|
||||
}
|
||||
|
||||
return sendSuccess(res, fileInfos);
|
||||
} catch (_error) {
|
||||
console.error('读取目录失败:', _error);
|
||||
return sendError(res, '读取目录失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 创建目录
|
||||
export const CreateDirHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { path: dirPath } = req.body;
|
||||
|
||||
let normalizedPath: string;
|
||||
try {
|
||||
normalizedPath = normalizePath(dirPath);
|
||||
} catch (_pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保路径是绝对路径
|
||||
if (!path.isAbsolute(normalizedPath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
// 检查是否已存在同类型(目录)
|
||||
if (await checkSameTypeExists(normalizedPath, true)) {
|
||||
return sendError(res, '同名目录已存在');
|
||||
}
|
||||
|
||||
await fsProm.mkdir(normalizedPath, { recursive: true });
|
||||
return sendSuccess(res, true);
|
||||
} catch (_error) {
|
||||
return sendError(res, '创建目录失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 删除文件/目录
|
||||
export const DeleteHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { path: targetPath } = req.body;
|
||||
|
||||
let normalizedPath: string;
|
||||
try {
|
||||
normalizedPath = normalizePath(targetPath);
|
||||
} catch (_pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保路径是绝对路径
|
||||
if (!path.isAbsolute(normalizedPath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
const stat = await fsProm.stat(normalizedPath);
|
||||
if (stat.isDirectory()) {
|
||||
await fsProm.rm(normalizedPath, { recursive: true });
|
||||
} else {
|
||||
await fsProm.unlink(normalizedPath);
|
||||
}
|
||||
return sendSuccess(res, true);
|
||||
} catch (_error) {
|
||||
return sendError(res, '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 批量删除文件/目录
|
||||
export const BatchDeleteHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { paths } = req.body;
|
||||
for (const targetPath of paths) {
|
||||
let normalizedPath: string;
|
||||
try {
|
||||
normalizedPath = normalizePath(targetPath);
|
||||
} catch (_pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保路径是绝对路径
|
||||
if (!path.isAbsolute(normalizedPath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
const stat = await fsProm.stat(normalizedPath);
|
||||
if (stat.isDirectory()) {
|
||||
await fsProm.rm(normalizedPath, { recursive: true });
|
||||
} else {
|
||||
await fsProm.unlink(normalizedPath);
|
||||
}
|
||||
}
|
||||
return sendSuccess(res, true);
|
||||
} catch (_error) {
|
||||
return sendError(res, '批量删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 读取文件内容
|
||||
export const ReadFileHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
let filePath: string;
|
||||
try {
|
||||
filePath = normalizePath(getQueryStringParam(req.query['path']));
|
||||
} catch (_pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保路径是绝对路径
|
||||
if (!path.isAbsolute(filePath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
let content = await fsProm.readFile(filePath, 'utf-8');
|
||||
|
||||
// 如果是WebUI配置文件,对token进行脱敏处理
|
||||
if (isWebUIConfigFile(filePath)) {
|
||||
content = sanitizeWebUIConfig(content);
|
||||
}
|
||||
|
||||
return sendSuccess(res, content);
|
||||
} catch (_error) {
|
||||
return sendError(res, '读取文件失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 写入文件内容
|
||||
export const WriteFileHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { path: filePath, content } = req.body;
|
||||
|
||||
// 安全的路径规范化,如果检测到路径遍历攻击会抛出异常
|
||||
let normalizedPath: string;
|
||||
try {
|
||||
normalizedPath = normalizePath(filePath);
|
||||
} catch (_pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保路径是绝对路径
|
||||
if (!path.isAbsolute(normalizedPath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
let finalContent = content;
|
||||
|
||||
// 检查是否为WebUI配置文件
|
||||
if (isWebUIConfigFile(normalizedPath)) {
|
||||
try {
|
||||
// 解析要写入的配置
|
||||
const configToWrite = JSON.parse(content);
|
||||
// 获取内存中的token,覆盖前端传来的token
|
||||
const memoryToken = getInitialWebUiToken();
|
||||
if (memoryToken) {
|
||||
configToWrite.token = memoryToken;
|
||||
finalContent = JSON.stringify(configToWrite, null, 4);
|
||||
}
|
||||
} catch (_e) {
|
||||
// 如果解析失败 说明不符合json格式 不允许写入
|
||||
return sendError(res, '写入的WebUI配置文件内容格式错误,必须是合法的JSON');
|
||||
}
|
||||
}
|
||||
|
||||
await fsProm.writeFile(normalizedPath, finalContent, 'utf-8');
|
||||
return sendSuccess(res, true);
|
||||
} catch (_error) {
|
||||
return sendError(res, '写入文件失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 创建新文件
|
||||
export const CreateFileHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { path: filePath } = req.body;
|
||||
|
||||
let normalizedPath: string;
|
||||
try {
|
||||
normalizedPath = normalizePath(filePath);
|
||||
} catch (_pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保路径是绝对路径
|
||||
if (!path.isAbsolute(normalizedPath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
// 检查是否已存在同类型(文件)
|
||||
if (await checkSameTypeExists(normalizedPath, false)) {
|
||||
return sendError(res, '同名文件已存在');
|
||||
}
|
||||
|
||||
await fsProm.writeFile(normalizedPath, '', 'utf-8');
|
||||
return sendSuccess(res, true);
|
||||
} catch (_error) {
|
||||
return sendError(res, '创建文件失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 重命名文件/目录
|
||||
export const RenameHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { oldPath, newPath } = req.body;
|
||||
|
||||
let normalizedOldPath: string;
|
||||
let normalizedNewPath: string;
|
||||
try {
|
||||
normalizedOldPath = normalizePath(oldPath);
|
||||
normalizedNewPath = normalizePath(newPath);
|
||||
} catch (_pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保路径是绝对路径
|
||||
if (!path.isAbsolute(normalizedOldPath) || !path.isAbsolute(normalizedNewPath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
await fsProm.rename(normalizedOldPath, normalizedNewPath);
|
||||
return sendSuccess(res, true);
|
||||
} catch (_error) {
|
||||
return sendError(res, '重命名失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 移动文件/目录
|
||||
export const MoveHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { sourcePath, targetPath } = req.body;
|
||||
|
||||
let normalizedSourcePath: string;
|
||||
let normalizedTargetPath: string;
|
||||
try {
|
||||
normalizedSourcePath = normalizePath(sourcePath);
|
||||
normalizedTargetPath = normalizePath(targetPath);
|
||||
} catch (_pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保路径是绝对路径
|
||||
if (!path.isAbsolute(normalizedSourcePath) || !path.isAbsolute(normalizedTargetPath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
await fsProm.rename(normalizedSourcePath, normalizedTargetPath);
|
||||
return sendSuccess(res, true);
|
||||
} catch (_error) {
|
||||
return sendError(res, '移动失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 批量移动
|
||||
export const BatchMoveHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { items } = req.body;
|
||||
for (const { sourcePath, targetPath } of items) {
|
||||
let normalizedSourcePath: string;
|
||||
let normalizedTargetPath: string;
|
||||
try {
|
||||
normalizedSourcePath = normalizePath(sourcePath);
|
||||
normalizedTargetPath = normalizePath(targetPath);
|
||||
} catch (_pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保路径是绝对路径
|
||||
if (!path.isAbsolute(normalizedSourcePath) || !path.isAbsolute(normalizedTargetPath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
await fsProm.rename(normalizedSourcePath, normalizedTargetPath);
|
||||
}
|
||||
return sendSuccess(res, true);
|
||||
} catch (_error) {
|
||||
return sendError(res, '批量移动失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 新增:文件下载处理方法(注意流式传输,不将整个文件读入内存)
|
||||
export const DownloadHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
let filePath: string;
|
||||
try {
|
||||
filePath = normalizePath(getQueryStringParam(req.query['path']));
|
||||
} catch (_pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
return sendError(res, '参数错误');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保路径是绝对路径
|
||||
if (!path.isAbsolute(filePath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
const stat = await fsProm.stat(filePath);
|
||||
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
let filename = path.basename(filePath);
|
||||
if (stat.isDirectory()) {
|
||||
filename = path.basename(filePath) + '.zip';
|
||||
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`);
|
||||
const zipStream = new PassThrough();
|
||||
compressing.zip.compressDir(filePath, zipStream as unknown as fs.WriteStream).catch((err) => {
|
||||
console.error('压缩目录失败:', err);
|
||||
res.end();
|
||||
});
|
||||
zipStream.pipe(res);
|
||||
return;
|
||||
}
|
||||
res.setHeader('Content-Length', stat.size);
|
||||
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`);
|
||||
const stream = fs.createReadStream(filePath);
|
||||
stream.pipe(res);
|
||||
} catch (_error) {
|
||||
return sendError(res, '下载失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 批量下载:将多个文件/目录打包为 zip 文件下载
|
||||
export const BatchDownloadHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { paths } = req.body as { paths: string[]; };
|
||||
if (!paths || !Array.isArray(paths) || paths.length === 0) {
|
||||
return sendError(res, '参数错误');
|
||||
}
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename=files.zip');
|
||||
|
||||
const zipStream = new compressing.zip.Stream();
|
||||
// 修改:根据文件类型设置 relativePath
|
||||
for (const filePath of paths) {
|
||||
let normalizedPath: string;
|
||||
try {
|
||||
normalizedPath = normalizePath(filePath);
|
||||
} catch (_pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保规范化后的路径是绝对路径
|
||||
if (!path.isAbsolute(normalizedPath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
const stat = await fsProm.stat(normalizedPath);
|
||||
if (stat.isDirectory()) {
|
||||
zipStream.addEntry(normalizedPath, { relativePath: '' });
|
||||
} else {
|
||||
// 确保相对路径只使用文件名,防止路径遍历
|
||||
const safeName = path.basename(normalizedPath);
|
||||
zipStream.addEntry(normalizedPath, { relativePath: safeName });
|
||||
}
|
||||
}
|
||||
zipStream.pipe(res);
|
||||
res.on('finish', () => {
|
||||
zipStream.destroy();
|
||||
});
|
||||
} catch (_error) {
|
||||
return sendError(res, '下载失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 修改上传处理方法
|
||||
export const UploadHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
await diskUploader(req, res);
|
||||
return sendSuccess(res, true, '文件上传成功', true);
|
||||
} catch (_error) {
|
||||
let errorMessage = '文件上传失败';
|
||||
|
||||
if (_error instanceof multer.MulterError) {
|
||||
switch (_error.code) {
|
||||
case 'LIMIT_FILE_SIZE':
|
||||
errorMessage = '文件大小超过限制(40MB)';
|
||||
break;
|
||||
case 'LIMIT_UNEXPECTED_FILE':
|
||||
errorMessage = '无效的文件上传字段';
|
||||
break;
|
||||
default:
|
||||
errorMessage = `上传错误: ${_error.message}`;
|
||||
}
|
||||
} else if (_error instanceof Error) {
|
||||
errorMessage = _error.message;
|
||||
}
|
||||
return sendError(res, errorMessage, true);
|
||||
}
|
||||
};
|
||||
|
||||
// 上传WebUI字体文件处理方法
|
||||
export const UploadWebUIFontHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
await webUIFontUploader(req, res);
|
||||
return sendSuccess(res, true, '字体文件上传成功', true);
|
||||
} catch (error) {
|
||||
let errorMessage = '字体文件上传失败';
|
||||
|
||||
if (error instanceof multer.MulterError) {
|
||||
switch (error.code) {
|
||||
case 'LIMIT_FILE_SIZE':
|
||||
errorMessage = '字体文件大小超过限制(40MB)';
|
||||
break;
|
||||
case 'LIMIT_UNEXPECTED_FILE':
|
||||
errorMessage = '无效的文件上传字段';
|
||||
break;
|
||||
default:
|
||||
errorMessage = `上传错误: ${error.message}`;
|
||||
}
|
||||
} else if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
return sendError(res, errorMessage, true);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除WebUI字体文件处理方法
|
||||
export const DeleteWebUIFontHandler: RequestHandler = async (_req, res) => {
|
||||
try {
|
||||
const fontPath = await WebUiConfig.GetWebUIFontPath();
|
||||
const exists = await WebUiConfig.CheckWebUIFontExist();
|
||||
|
||||
if (!exists || !fontPath) {
|
||||
return sendSuccess(res, true);
|
||||
}
|
||||
|
||||
await fsProm.unlink(fontPath);
|
||||
return sendSuccess(res, true);
|
||||
} catch (_error) {
|
||||
return sendError(res, '删除字体文件失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 检查WebUI字体文件是否存在
|
||||
export const CheckWebUIFontExistHandler: RequestHandler = async (_req, res) => {
|
||||
try {
|
||||
const exists = await WebUiConfig.CheckWebUIFontExist();
|
||||
return sendSuccess(res, exists);
|
||||
} catch (_error) {
|
||||
return sendError(res, '检查字体文件失败');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { RequestHandler } from 'express';
|
||||
import { sendError, sendSuccess } from '../utils/response';
|
||||
import { terminalManager } from '../terminal/terminal_manager';
|
||||
import { logSubscription, WebUiConfig } from '@/napcat-webui-backend/index';
|
||||
// 判断是否是 macos
|
||||
const isMacOS = process.platform === 'darwin';
|
||||
|
||||
// 日志脱敏函数
|
||||
const sanitizeLog = (log: string): string => {
|
||||
// 脱敏 token 参数,将 token=xxx 替换为 token=***
|
||||
return log.replace(/token=[\w\d]+/gi, 'token=***');
|
||||
};
|
||||
// 日志记录
|
||||
export const LogHandler: RequestHandler = async (req, res) => {
|
||||
const filename = req.query['id'];
|
||||
if (!filename || typeof filename !== 'string') {
|
||||
return sendError(res, 'ID不能为空');
|
||||
}
|
||||
|
||||
if (filename.includes('..')) {
|
||||
return sendError(res, 'ID不合法');
|
||||
}
|
||||
const logContent = await WebUiConfig.GetLogContent(filename);
|
||||
const sanitizedLogContent = sanitizeLog(logContent);
|
||||
return sendSuccess(res, sanitizedLogContent);
|
||||
};
|
||||
|
||||
// 日志列表
|
||||
export const LogListHandler: RequestHandler = async (_, res) => {
|
||||
const logList = await WebUiConfig.GetLogsList();
|
||||
return sendSuccess(res, logList);
|
||||
};
|
||||
|
||||
// 实时日志(SSE)
|
||||
export const LogRealTimeHandler: RequestHandler = async (req, res) => {
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
const listener = (log: string) => {
|
||||
try {
|
||||
const sanitizedLog = sanitizeLog(log);
|
||||
res.write(`data: ${sanitizedLog}\n\n`);
|
||||
} catch (error) {
|
||||
console.error('向客户端写入日志数据时出错:', error);
|
||||
}
|
||||
};
|
||||
logSubscription.subscribe(listener);
|
||||
req.on('close', () => {
|
||||
logSubscription.unsubscribe(listener);
|
||||
});
|
||||
};
|
||||
|
||||
// 终端相关处理器
|
||||
export const CreateTerminalHandler: RequestHandler = async (req, res) => {
|
||||
if (isMacOS) {
|
||||
return sendError(res, 'MacOS不支持终端');
|
||||
}
|
||||
try {
|
||||
const { cols, rows } = req.body;
|
||||
const { id } = terminalManager.createTerminal(cols, rows);
|
||||
return sendSuccess(res, { id });
|
||||
} catch (error) {
|
||||
console.error('Failed to create terminal:', error);
|
||||
return sendError(res, '创建终端失败');
|
||||
}
|
||||
};
|
||||
|
||||
export const GetTerminalListHandler: RequestHandler = (_, res) => {
|
||||
const list = terminalManager.getTerminalList();
|
||||
return sendSuccess(res, list);
|
||||
};
|
||||
|
||||
export const CloseTerminalHandler: RequestHandler = (req, res) => {
|
||||
const id = req.params['id'];
|
||||
if (!id) {
|
||||
return sendError(res, 'ID不能为空');
|
||||
}
|
||||
terminalManager.closeTerminal(id);
|
||||
return sendSuccess(res, {});
|
||||
};
|
||||
@@ -0,0 +1,230 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import { sendSuccess, sendError } from '@/napcat-webui-backend/src/utils/response';
|
||||
import {
|
||||
GITHUB_FILE_MIRRORS,
|
||||
GITHUB_RAW_MIRRORS,
|
||||
buildMirrorUrl,
|
||||
getMirrorConfig,
|
||||
setCustomMirror,
|
||||
clearMirrorCache,
|
||||
} from 'napcat-common/src/mirror';
|
||||
import https from 'https';
|
||||
import http from 'http';
|
||||
|
||||
export interface MirrorTestResult {
|
||||
mirror: string;
|
||||
latency: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试单个镜像的延迟
|
||||
*/
|
||||
async function testMirrorLatency (mirror: string, testUrl: string, timeout: number = 5000): Promise<MirrorTestResult> {
|
||||
const url = mirror ? buildMirrorUrl(testUrl, mirror) : testUrl;
|
||||
const start = Date.now();
|
||||
|
||||
return new Promise<MirrorTestResult>((resolve) => {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const isHttps = urlObj.protocol === 'https:';
|
||||
const client = isHttps ? https : http;
|
||||
|
||||
const req = client.request({
|
||||
hostname: urlObj.hostname,
|
||||
port: urlObj.port || (isHttps ? 443 : 80),
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
method: 'HEAD',
|
||||
timeout,
|
||||
headers: {
|
||||
'User-Agent': 'NapCat-Mirror-Test',
|
||||
},
|
||||
}, (res) => {
|
||||
const statusCode = res.statusCode || 0;
|
||||
const isValid = statusCode >= 200 && statusCode < 400;
|
||||
resolve({
|
||||
mirror: mirror || 'https://github.com',
|
||||
latency: Date.now() - start,
|
||||
success: isValid,
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (e) => {
|
||||
resolve({
|
||||
mirror: mirror || 'https://github.com',
|
||||
latency: Date.now() - start,
|
||||
success: false,
|
||||
error: e.message,
|
||||
});
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve({
|
||||
mirror: mirror || 'https://github.com',
|
||||
latency: timeout,
|
||||
success: false,
|
||||
error: 'Timeout',
|
||||
});
|
||||
});
|
||||
|
||||
req.end();
|
||||
} catch (e: any) {
|
||||
resolve({
|
||||
mirror: mirror || 'https://github.com',
|
||||
latency: Date.now() - start,
|
||||
success: false,
|
||||
error: e.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用的镜像列表
|
||||
*/
|
||||
export const GetMirrorListHandler: RequestHandler = async (_req, res) => {
|
||||
try {
|
||||
const config = getMirrorConfig();
|
||||
return sendSuccess(res, {
|
||||
fileMirrors: GITHUB_FILE_MIRRORS.filter(m => m),
|
||||
rawMirrors: GITHUB_RAW_MIRRORS,
|
||||
customMirror: config.customMirror,
|
||||
timeout: config.timeout,
|
||||
});
|
||||
} catch (e: any) {
|
||||
return sendError(res, e.message);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置自定义镜像
|
||||
*/
|
||||
export const SetCustomMirrorHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { mirror } = req.body;
|
||||
setCustomMirror(mirror || '');
|
||||
clearMirrorCache();
|
||||
return sendSuccess(res, { message: 'Mirror set successfully' });
|
||||
} catch (e: any) {
|
||||
return sendError(res, e.message);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* SSE 实时测速所有镜像
|
||||
*/
|
||||
export const TestMirrorsSSEHandler: RequestHandler = async (req, res) => {
|
||||
const { type = 'file' } = req.query;
|
||||
|
||||
// 设置 SSE 响应头
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders();
|
||||
|
||||
const sendProgress = (data: any) => {
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
|
||||
try {
|
||||
// 选择镜像列表
|
||||
let mirrors: string[];
|
||||
let testUrl: string;
|
||||
|
||||
if (type === 'raw') {
|
||||
mirrors = GITHUB_RAW_MIRRORS;
|
||||
testUrl = 'https://raw.githubusercontent.com/NapNeko/NapCatQQ/main/README.md';
|
||||
} else {
|
||||
mirrors = GITHUB_FILE_MIRRORS.filter(m => m);
|
||||
testUrl = 'https://github.com/NapNeko/NapCatQQ/releases/latest';
|
||||
}
|
||||
|
||||
// 添加原始 URL 测试
|
||||
if (!mirrors.includes('')) {
|
||||
mirrors = ['', ...mirrors];
|
||||
}
|
||||
|
||||
sendProgress({
|
||||
type: 'start',
|
||||
total: mirrors.length,
|
||||
message: `开始测试 ${mirrors.length} 个镜像源...`,
|
||||
});
|
||||
|
||||
const results: MirrorTestResult[] = [];
|
||||
const timeout = 5000;
|
||||
|
||||
// 逐个测试并实时推送结果
|
||||
for (let i = 0; i < mirrors.length; i++) {
|
||||
const mirror = mirrors[i] ?? '';
|
||||
const displayName = mirror || 'https://github.com (原始)';
|
||||
|
||||
sendProgress({
|
||||
type: 'testing',
|
||||
index: i,
|
||||
total: mirrors.length,
|
||||
mirror: displayName,
|
||||
message: `正在测试: ${displayName}`,
|
||||
});
|
||||
|
||||
const result = await testMirrorLatency(mirror, testUrl, timeout);
|
||||
results.push(result);
|
||||
|
||||
sendProgress({
|
||||
type: 'result',
|
||||
index: i,
|
||||
total: mirrors.length,
|
||||
result: {
|
||||
...result,
|
||||
mirror: result.mirror || 'https://github.com (原始)',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 按延迟排序
|
||||
const sortedResults = results
|
||||
.filter(r => r.success)
|
||||
.sort((a, b) => a.latency - b.latency);
|
||||
|
||||
const failedResults = results.filter(r => !r.success);
|
||||
|
||||
sendProgress({
|
||||
type: 'complete',
|
||||
results: sortedResults,
|
||||
failed: failedResults,
|
||||
fastest: sortedResults[0] || null,
|
||||
message: `测试完成!${sortedResults.length} 个可用,${failedResults.length} 个失败`,
|
||||
});
|
||||
|
||||
res.end();
|
||||
} catch (e: any) {
|
||||
sendProgress({
|
||||
type: 'error',
|
||||
error: e.message,
|
||||
});
|
||||
res.end();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 快速测试单个镜像
|
||||
*/
|
||||
export const TestSingleMirrorHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { mirror, type = 'file' } = req.body;
|
||||
|
||||
let testUrl: string;
|
||||
if (type === 'raw') {
|
||||
testUrl = 'https://raw.githubusercontent.com/NapNeko/NapCatQQ/main/README.md';
|
||||
} else {
|
||||
testUrl = 'https://github.com/NapNeko/NapCatQQ/releases/latest';
|
||||
}
|
||||
|
||||
const result = await testMirrorLatency(mirror || '', testUrl, 5000);
|
||||
|
||||
return sendSuccess(res, result);
|
||||
} catch (e: any) {
|
||||
return sendError(res, e.message);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { webUiPathWrapper } from '@/napcat-webui-backend/index';
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||
import json5 from 'json5';
|
||||
|
||||
import { Value } from '@sinclair/typebox/value';
|
||||
import { NapcatConfigSchema } from '@/napcat-core/helper/config';
|
||||
|
||||
// 动态获取 NapCat 配置默认值
|
||||
function getDefaultNapcatConfig (): Record<string, unknown> {
|
||||
const data = {};
|
||||
return Value.Parse(NapcatConfigSchema, data) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 napcat 配置文件路径
|
||||
*/
|
||||
function getNapcatConfigPath (): string {
|
||||
return resolve(webUiPathWrapper.configPath, './napcat.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 napcat 配置
|
||||
*/
|
||||
function readNapcatConfig (): Record<string, unknown> {
|
||||
const configPath = getNapcatConfigPath();
|
||||
try {
|
||||
if (existsSync(configPath)) {
|
||||
const content = readFileSync(configPath, 'utf-8');
|
||||
return { ...getDefaultNapcatConfig(), ...json5.parse(content) };
|
||||
}
|
||||
} catch (_e) {
|
||||
// 读取失败,使用默认值
|
||||
}
|
||||
return { ...getDefaultNapcatConfig() };
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入 napcat 配置
|
||||
*/
|
||||
function writeNapcatConfig (config: Record<string, unknown>): void {
|
||||
const configPath = resolve(webUiPathWrapper.configPath, './napcat.json');
|
||||
mkdirSync(webUiPathWrapper.configPath, { recursive: true });
|
||||
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
// 获取 NapCat 配置
|
||||
export const NapCatGetConfigHandler: RequestHandler = (_, res) => {
|
||||
try {
|
||||
const config = readNapcatConfig();
|
||||
return sendSuccess(res, config);
|
||||
} catch (e) {
|
||||
return sendError(res, 'Config Get Error: ' + (e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
// 设置 NapCat 配置
|
||||
export const NapCatSetConfigHandler: RequestHandler = (req, res) => {
|
||||
try {
|
||||
const newConfig = req.body;
|
||||
if (!newConfig || typeof newConfig !== 'object') {
|
||||
return sendError(res, 'config is empty or invalid');
|
||||
}
|
||||
|
||||
// 读取当前配置并合并
|
||||
const currentConfig = readNapcatConfig();
|
||||
const mergedConfig = { ...currentConfig, ...newConfig };
|
||||
|
||||
// 验证 bypass 字段
|
||||
if (mergedConfig.bypass && typeof mergedConfig.bypass === 'object') {
|
||||
const bypass = mergedConfig.bypass as Record<string, unknown>;
|
||||
const validKeys = ['hook', 'window', 'module', 'process', 'container', 'js'];
|
||||
for (const key of validKeys) {
|
||||
if (key in bypass && typeof bypass[key] !== 'boolean') {
|
||||
return sendError(res, `bypass.${key} must be boolean`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeNapcatConfig(mergedConfig);
|
||||
return sendSuccess(res, null);
|
||||
} catch (e) {
|
||||
return sendError(res, 'Config Set Error: ' + (e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Per-UIN 配置(napcat_{uin}.json)—— 登录后配置
|
||||
// ============================================================
|
||||
|
||||
function readUinConfig (uin: string): Record<string, unknown> {
|
||||
const uinPath = resolve(webUiPathWrapper.configPath, `./napcat_${uin}.json`);
|
||||
const fallbackPath = resolve(webUiPathWrapper.configPath, './napcat.json');
|
||||
const configPath = existsSync(uinPath) ? uinPath : fallbackPath;
|
||||
try {
|
||||
if (existsSync(configPath)) {
|
||||
const content = readFileSync(configPath, 'utf-8');
|
||||
return { ...getDefaultNapcatConfig(), ...json5.parse(content) };
|
||||
}
|
||||
} catch (_e) {
|
||||
// ignore
|
||||
}
|
||||
return { ...getDefaultNapcatConfig() };
|
||||
}
|
||||
|
||||
function writeUinConfig (uin: string, config: Record<string, unknown>): void {
|
||||
const configPath = resolve(webUiPathWrapper.configPath, `./napcat_${uin}.json`);
|
||||
mkdirSync(webUiPathWrapper.configPath, { recursive: true });
|
||||
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
// 获取 per-uin NapCat 配置
|
||||
export const NapCatGetUinConfigHandler: RequestHandler = (_, res) => {
|
||||
try {
|
||||
const isLogin = WebUiDataRuntime.getQQLoginStatus();
|
||||
if (!isLogin) return sendError(res, 'Not Login');
|
||||
const uin = WebUiDataRuntime.getQQLoginUin();
|
||||
return sendSuccess(res, readUinConfig(uin));
|
||||
} catch (e) {
|
||||
return sendError(res, 'Config Get Error: ' + (e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
// 设置 per-uin NapCat 配置
|
||||
export const NapCatSetUinConfigHandler: RequestHandler = (req, res) => {
|
||||
try {
|
||||
const isLogin = WebUiDataRuntime.getQQLoginStatus();
|
||||
if (!isLogin) return sendError(res, 'Not Login');
|
||||
const uin = WebUiDataRuntime.getQQLoginUin();
|
||||
|
||||
const newConfig = req.body;
|
||||
if (!newConfig || typeof newConfig !== 'object') {
|
||||
return sendError(res, 'config is empty or invalid');
|
||||
}
|
||||
const currentConfig = readUinConfig(uin);
|
||||
const mergedConfig = { ...currentConfig, ...newConfig };
|
||||
writeUinConfig(uin, mergedConfig);
|
||||
return sendSuccess(res, null);
|
||||
} catch (e) {
|
||||
return sendError(res, 'Config Set Error: ' + (e as Error).message);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { loadConfig, OneBotConfig } from '@/napcat-webui-backend/src/onebot/config';
|
||||
import { webUiPathWrapper } from '@/napcat-webui-backend/index';
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||
import { isEmpty } from '@/napcat-webui-backend/src/utils/check';
|
||||
import json5 from 'json5';
|
||||
|
||||
// 获取OneBot11配置
|
||||
export const OB11GetConfigHandler: RequestHandler = (_, res) => {
|
||||
// 获取QQ登录状态
|
||||
const isLogin = WebUiDataRuntime.getQQLoginStatus();
|
||||
// 如果未登录,返回错误
|
||||
if (!isLogin) {
|
||||
return sendError(res, 'Not Login');
|
||||
}
|
||||
// 获取登录的QQ号
|
||||
const uin = WebUiDataRuntime.getQQLoginUin();
|
||||
// 读取配置文件路径
|
||||
const configFilePath = resolve(webUiPathWrapper.configPath, `./onebot11_${uin}.json`);
|
||||
// 尝试解析配置文件
|
||||
try {
|
||||
// 读取配置文件内容
|
||||
const configFileContent = existsSync(configFilePath)
|
||||
? readFileSync(configFilePath).toString()
|
||||
: readFileSync(resolve(webUiPathWrapper.configPath, './onebot11.json')).toString();
|
||||
// 解析配置文件并加载配置
|
||||
const data = loadConfig(json5.parse(configFileContent)) as OneBotConfig;
|
||||
// 返回配置文件
|
||||
return sendSuccess(res, data);
|
||||
} catch (_e) {
|
||||
return sendError(res, 'Config Get Error');
|
||||
}
|
||||
};
|
||||
|
||||
// 写入OneBot11配置
|
||||
export const OB11SetConfigHandler: RequestHandler = async (req, res) => {
|
||||
// 获取QQ登录状态
|
||||
const isLogin = WebUiDataRuntime.getQQLoginStatus();
|
||||
// 如果未登录,返回错误
|
||||
if (!isLogin) {
|
||||
return sendError(res, 'Not Login');
|
||||
}
|
||||
// 如果配置为空,返回错误
|
||||
if (isEmpty(req.body.config)) {
|
||||
return sendError(res, 'config is empty');
|
||||
}
|
||||
// 写入配置
|
||||
try {
|
||||
// 解析并加载配置
|
||||
const config = loadConfig(json5.parse(req.body.config)) as OneBotConfig;
|
||||
// 写入配置
|
||||
await WebUiDataRuntime.setOB11Config(config);
|
||||
return sendSuccess(res, null);
|
||||
} catch (e) {
|
||||
return sendError(res, 'Error: ' + e);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,667 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
|
||||
import { OB11PluginMangerAdapter } from '@/napcat-onebot/network/plugin-manger';
|
||||
import { webUiPathWrapper } from '@/napcat-webui-backend/index';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import compressing from 'compressing';
|
||||
|
||||
/**
|
||||
* 获取插件图标 URL
|
||||
* 优先使用 package.json 中的 icon 字段,否则检查缓存的图标文件
|
||||
*/
|
||||
function getPluginIconUrl (pluginId: string, pluginPath: string, iconField?: string): string | undefined {
|
||||
// 1. 检查 package.json 中指定的 icon 文件
|
||||
if (iconField) {
|
||||
const iconPath = path.join(pluginPath, iconField);
|
||||
if (fs.existsSync(iconPath)) {
|
||||
return `/api/Plugin/Icon/${encodeURIComponent(pluginId)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查 config 目录中缓存的图标 (固定 icon.png)
|
||||
const cachedIcon = path.join(webUiPathWrapper.configPath, 'plugins', pluginId, 'icon.png');
|
||||
if (fs.existsSync(cachedIcon)) {
|
||||
return `/api/Plugin/Icon/${encodeURIComponent(pluginId)}`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找插件图标文件的实际路径
|
||||
*/
|
||||
function findPluginIconPath (pluginId: string, pluginPath: string, iconField?: string): string | undefined {
|
||||
// 1. 优先使用 package.json 中指定的 icon
|
||||
if (iconField) {
|
||||
const iconPath = path.join(pluginPath, iconField);
|
||||
if (fs.existsSync(iconPath)) {
|
||||
return iconPath;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查 config 目录中缓存的图标 (固定 icon.png)
|
||||
const cachedIcon = path.join(webUiPathWrapper.configPath, 'plugins', pluginId, 'icon.png');
|
||||
if (fs.existsSync(cachedIcon)) {
|
||||
return cachedIcon;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Helper to get the plugin manager adapter
|
||||
const getPluginManager = (): OB11PluginMangerAdapter | null => {
|
||||
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter;
|
||||
if (!ob11) return null;
|
||||
return ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter;
|
||||
};
|
||||
|
||||
// Helper to get OneBot context
|
||||
const getOneBotContext = (): NapCatOneBot11Adapter | null => {
|
||||
return WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter;
|
||||
};
|
||||
|
||||
/**
|
||||
* 手动注册插件管理器到 NetworkManager
|
||||
*/
|
||||
export const RegisterPluginManagerHandler: RequestHandler = async (_req, res) => {
|
||||
const ob11 = getOneBotContext();
|
||||
if (!ob11) {
|
||||
return sendError(res, 'OneBot context not found');
|
||||
}
|
||||
|
||||
// 检查是否已经注册
|
||||
const existingManager = ob11.networkManager.findSomeAdapter('plugin_manager');
|
||||
if (existingManager) {
|
||||
return sendError(res, '插件管理器已经注册');
|
||||
}
|
||||
|
||||
try {
|
||||
// 确保插件目录存在
|
||||
const pluginPath = webUiPathWrapper.pluginPath;
|
||||
if (!fs.existsSync(pluginPath)) {
|
||||
fs.mkdirSync(pluginPath, { recursive: true });
|
||||
}
|
||||
|
||||
// 创建并注册插件管理器
|
||||
const pluginManager = new OB11PluginMangerAdapter(
|
||||
'plugin_manager',
|
||||
ob11.core,
|
||||
ob11,
|
||||
ob11.actions
|
||||
);
|
||||
|
||||
await ob11.networkManager.registerAdapterAndOpen(pluginManager);
|
||||
|
||||
return sendSuccess(res, { message: '插件管理器注册成功' });
|
||||
} catch (e: any) {
|
||||
return sendError(res, '注册插件管理器失败: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
export const GetPluginListHandler: RequestHandler = async (_req, res) => {
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) {
|
||||
// 返回成功但带特殊标记
|
||||
return sendSuccess(res, { plugins: [], pluginManagerNotFound: true, extensionPages: [] });
|
||||
}
|
||||
|
||||
const loadedPlugins = pluginManager.getAllPlugins();
|
||||
const AllPlugins: Array<{
|
||||
name: string;
|
||||
id: string;
|
||||
version: string;
|
||||
description: string;
|
||||
author: string;
|
||||
status: string;
|
||||
hasConfig: boolean;
|
||||
hasPages: boolean;
|
||||
homepage?: string;
|
||||
repository?: string;
|
||||
icon?: string;
|
||||
}> = [];
|
||||
|
||||
// 收集所有插件的扩展页面
|
||||
const extensionPages: Array<{
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
path: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
description?: string;
|
||||
}> = [];
|
||||
|
||||
// 1. 整理已加载的插件
|
||||
for (const p of loadedPlugins) {
|
||||
// 根据插件状态确定 status
|
||||
let status: string;
|
||||
if (!p.enable) {
|
||||
status = 'disabled';
|
||||
} else if (p.loaded) {
|
||||
status = 'active';
|
||||
} else {
|
||||
status = 'stopped'; // 启用但未加载(可能加载失败)
|
||||
}
|
||||
|
||||
// 检查插件是否有注册页面
|
||||
const pluginRouter = pluginManager.getPluginRouter(p.id);
|
||||
const hasPages = pluginRouter?.hasPages() ?? false;
|
||||
|
||||
AllPlugins.push({
|
||||
name: p.packageJson?.plugin || p.name || '', // 优先显示 package.json 的 plugin 字段
|
||||
id: p.id, // 包名,用于 API 操作
|
||||
version: p.version || '0.0.0',
|
||||
description: p.packageJson?.description || '',
|
||||
author: p.packageJson?.author || '',
|
||||
status,
|
||||
hasConfig: !!(p.runtime.module?.plugin_config_schema || p.runtime.module?.plugin_config_ui),
|
||||
hasPages,
|
||||
homepage: p.packageJson?.homepage,
|
||||
repository: typeof p.packageJson?.repository === 'string'
|
||||
? p.packageJson.repository
|
||||
: p.packageJson?.repository?.url,
|
||||
icon: getPluginIconUrl(p.id, p.pluginPath, p.packageJson?.icon),
|
||||
});
|
||||
|
||||
// 收集插件的扩展页面
|
||||
if (hasPages && pluginRouter) {
|
||||
const pages = pluginRouter.getPages();
|
||||
for (const page of pages) {
|
||||
extensionPages.push({
|
||||
pluginId: p.id,
|
||||
pluginName: p.packageJson?.plugin || p.name || p.id,
|
||||
path: page.path,
|
||||
title: page.title,
|
||||
icon: page.icon,
|
||||
description: page.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sendSuccess(res, { plugins: AllPlugins, pluginManagerNotFound: false, extensionPages });
|
||||
};
|
||||
|
||||
export const SetPluginStatusHandler: RequestHandler = async (req, res) => {
|
||||
const { enable, id } = req.body;
|
||||
|
||||
if (!id) return sendError(res, 'Plugin id is required');
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) {
|
||||
return sendError(res, 'Plugin Manager not found');
|
||||
}
|
||||
|
||||
try {
|
||||
// 设置插件状态(需要 await,因为内部会加载/卸载插件)
|
||||
await pluginManager.setPluginStatus(id, enable);
|
||||
|
||||
// 如果启用,检查插件是否加载成功
|
||||
if (enable) {
|
||||
const plugin = pluginManager.getPluginInfo(id);
|
||||
if (!plugin || !plugin.loaded) {
|
||||
return sendError(res, 'Plugin load failed: ' + id);
|
||||
}
|
||||
}
|
||||
|
||||
return sendSuccess(res, { message: 'Status updated successfully' });
|
||||
} catch (e: any) {
|
||||
return sendError(res, 'Failed to update status: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
export const UninstallPluginHandler: RequestHandler = async (req, res) => {
|
||||
const { id, cleanData } = req.body;
|
||||
|
||||
if (!id) return sendError(res, 'Plugin id is required');
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) {
|
||||
return sendError(res, 'Plugin Manager not found');
|
||||
}
|
||||
|
||||
try {
|
||||
await pluginManager.uninstallPlugin(id, cleanData);
|
||||
return sendSuccess(res, { message: 'Uninstalled successfully' });
|
||||
} catch (e: any) {
|
||||
return sendError(res, 'Failed to uninstall: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
export const GetPluginConfigHandler: RequestHandler = async (req, res) => {
|
||||
const id = req.query['id'] as string;
|
||||
|
||||
if (!id) return sendError(res, 'Plugin id is required');
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) return sendError(res, 'Plugin Manager not found');
|
||||
|
||||
const plugin = pluginManager.getPluginInfo(id);
|
||||
if (!plugin) return sendError(res, 'Plugin not loaded');
|
||||
|
||||
// 获取配置值
|
||||
let config: unknown = {};
|
||||
if (plugin.runtime.module?.plugin_get_config && plugin.runtime.context) {
|
||||
try {
|
||||
config = await plugin.runtime.module?.plugin_get_config(plugin.runtime.context);
|
||||
} catch (_e) { }
|
||||
} else {
|
||||
// Default behavior: read from default config path
|
||||
try {
|
||||
const configPath = plugin.runtime.context?.configPath || pluginManager.getPluginConfigPath(id);
|
||||
if (fs.existsSync(configPath)) {
|
||||
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
}
|
||||
} catch (_e) { }
|
||||
}
|
||||
|
||||
// 获取静态 schema
|
||||
const schema = plugin.runtime.module?.plugin_config_schema || plugin.runtime.module?.plugin_config_ui || [];
|
||||
|
||||
// 检查是否支持动态控制
|
||||
const supportReactive = !!(plugin.runtime.module?.plugin_config_controller || plugin.runtime.module?.plugin_on_config_change);
|
||||
|
||||
return sendSuccess(res, { schema, config, supportReactive });
|
||||
};
|
||||
|
||||
/** 活跃的 SSE 连接 */
|
||||
const activeConfigSessions = new Map<string, {
|
||||
res: any;
|
||||
cleanup?: () => void;
|
||||
currentConfig: Record<string, any>;
|
||||
}>();
|
||||
|
||||
/**
|
||||
* 插件配置 SSE 连接 - 用于动态更新配置界面
|
||||
*/
|
||||
export const PluginConfigSSEHandler: RequestHandler = (req, res): void => {
|
||||
const id = req.query['id'] as string;
|
||||
const initialConfigStr = req.query['config'] as string;
|
||||
|
||||
if (!id) {
|
||||
res.status(400).json({ error: 'Plugin id is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) {
|
||||
res.status(400).json({ error: 'Plugin Manager not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const plugin = pluginManager.getPluginInfo(id);
|
||||
if (!plugin) {
|
||||
res.status(400).json({ error: 'Plugin not loaded' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置 SSE 头
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
res.flushHeaders();
|
||||
|
||||
// 生成会话 ID
|
||||
const sessionId = `${id}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
// 解析初始配置
|
||||
let currentConfig: Record<string, any> = {};
|
||||
if (initialConfigStr) {
|
||||
try {
|
||||
currentConfig = JSON.parse(initialConfigStr);
|
||||
} catch (_e) { }
|
||||
}
|
||||
|
||||
// 发送 SSE 消息的辅助函数
|
||||
const sendSSE = (event: string, data: any) => {
|
||||
res.write(`event: ${event}\n`);
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
|
||||
// 创建 UI 控制器
|
||||
const uiController = {
|
||||
updateSchema: (schema: any[]) => {
|
||||
sendSSE('schema', { type: 'full', schema });
|
||||
},
|
||||
updateField: (key: string, field: any) => {
|
||||
sendSSE('schema', { type: 'updateField', key, field });
|
||||
},
|
||||
removeField: (key: string) => {
|
||||
sendSSE('schema', { type: 'removeField', key });
|
||||
},
|
||||
addField: (field: any, afterKey?: string) => {
|
||||
sendSSE('schema', { type: 'addField', field, afterKey });
|
||||
},
|
||||
showField: (key: string) => {
|
||||
sendSSE('schema', { type: 'showField', key });
|
||||
},
|
||||
hideField: (key: string) => {
|
||||
sendSSE('schema', { type: 'hideField', key });
|
||||
},
|
||||
getCurrentConfig: () => currentConfig,
|
||||
};
|
||||
|
||||
// 存储会话
|
||||
activeConfigSessions.set(sessionId, { res, currentConfig });
|
||||
|
||||
// 发送连接成功消息
|
||||
sendSSE('connected', { sessionId });
|
||||
|
||||
// 调用插件的控制器初始化(异步处理)
|
||||
(async () => {
|
||||
let cleanup: (() => void) | undefined;
|
||||
if (plugin.runtime.module?.plugin_config_controller && plugin.runtime.context) {
|
||||
try {
|
||||
const result = await plugin.runtime.module.plugin_config_controller(
|
||||
plugin.runtime.context,
|
||||
uiController,
|
||||
currentConfig
|
||||
);
|
||||
if (typeof result === 'function') {
|
||||
cleanup = result;
|
||||
}
|
||||
} catch (e: any) {
|
||||
sendSSE('error', { message: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 更新会话的 cleanup
|
||||
const session = activeConfigSessions.get(sessionId);
|
||||
if (session) {
|
||||
session.cleanup = cleanup;
|
||||
}
|
||||
})();
|
||||
|
||||
// 心跳保持连接
|
||||
const heartbeat = setInterval(() => {
|
||||
sendSSE('ping', { time: Date.now() });
|
||||
}, 30000);
|
||||
|
||||
// 连接关闭时清理
|
||||
req.on('close', () => {
|
||||
clearInterval(heartbeat);
|
||||
const session = activeConfigSessions.get(sessionId);
|
||||
if (session?.cleanup) {
|
||||
try {
|
||||
session.cleanup();
|
||||
} catch (_e) { }
|
||||
}
|
||||
activeConfigSessions.delete(sessionId);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 插件配置字段变化通知
|
||||
*/
|
||||
export const PluginConfigChangeHandler: RequestHandler = async (req, res) => {
|
||||
const { id, sessionId, key, value, currentConfig } = req.body;
|
||||
|
||||
if (!id || !sessionId || !key) {
|
||||
return sendError(res, 'Missing required parameters');
|
||||
}
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) return sendError(res, 'Plugin Manager not found');
|
||||
|
||||
const plugin = pluginManager.getPluginInfo(id);
|
||||
if (!plugin) return sendError(res, 'Plugin not loaded');
|
||||
|
||||
// 获取会话
|
||||
const session = activeConfigSessions.get(sessionId);
|
||||
if (!session) {
|
||||
return sendError(res, 'Session not found');
|
||||
}
|
||||
|
||||
// 更新会话中的当前配置
|
||||
session.currentConfig = currentConfig || {};
|
||||
|
||||
// 如果插件有响应式处理器,调用它
|
||||
if (plugin.runtime.module?.plugin_on_config_change) {
|
||||
const uiController = {
|
||||
updateSchema: (schema: any[]) => {
|
||||
session.res.write('event: schema\n');
|
||||
session.res.write(`data: ${JSON.stringify({ type: 'full', schema })}\n\n`);
|
||||
},
|
||||
updateField: (fieldKey: string, field: any) => {
|
||||
session.res.write('event: schema\n');
|
||||
session.res.write(`data: ${JSON.stringify({ type: 'updateField', key: fieldKey, field })}\n\n`);
|
||||
},
|
||||
removeField: (fieldKey: string) => {
|
||||
session.res.write('event: schema\n');
|
||||
session.res.write(`data: ${JSON.stringify({ type: 'removeField', key: fieldKey })}\n\n`);
|
||||
},
|
||||
addField: (field: any, afterKey?: string) => {
|
||||
session.res.write('event: schema\n');
|
||||
session.res.write(`data: ${JSON.stringify({ type: 'addField', field, afterKey })}\n\n`);
|
||||
},
|
||||
showField: (fieldKey: string) => {
|
||||
session.res.write('event: schema\n');
|
||||
session.res.write(`data: ${JSON.stringify({ type: 'showField', key: fieldKey })}\n\n`);
|
||||
},
|
||||
hideField: (fieldKey: string) => {
|
||||
session.res.write('event: schema\n');
|
||||
session.res.write(`data: ${JSON.stringify({ type: 'hideField', key: fieldKey })}\n\n`);
|
||||
},
|
||||
getCurrentConfig: () => session.currentConfig,
|
||||
};
|
||||
|
||||
try {
|
||||
if (plugin.runtime.context) {
|
||||
await plugin.runtime.module.plugin_on_config_change(
|
||||
plugin.runtime.context,
|
||||
uiController,
|
||||
key,
|
||||
value,
|
||||
currentConfig || {}
|
||||
);
|
||||
}
|
||||
} catch (e: any) {
|
||||
session.res.write('event: error\n');
|
||||
session.res.write(`data: ${JSON.stringify({ message: e.message })}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
return sendSuccess(res, { message: 'Change processed' });
|
||||
};
|
||||
|
||||
export const SetPluginConfigHandler: RequestHandler = async (req, res) => {
|
||||
const { id, config } = req.body;
|
||||
if (!id || !config) return sendError(res, 'Plugin id and config required');
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) return sendError(res, 'Plugin Manager not found');
|
||||
|
||||
const plugin = pluginManager.getPluginInfo(id);
|
||||
if (!plugin) return sendError(res, 'Plugin not loaded');
|
||||
|
||||
if (plugin.runtime.module?.plugin_set_config && plugin.runtime.context) {
|
||||
try {
|
||||
await plugin.runtime.module.plugin_set_config(plugin.runtime.context, config);
|
||||
return sendSuccess(res, { message: 'Config updated' });
|
||||
} catch (e: any) {
|
||||
return sendError(res, 'Error updating config: ' + e.message);
|
||||
}
|
||||
} else if (plugin.runtime.module?.plugin_config_schema || plugin.runtime.module?.plugin_config_ui || plugin.runtime.module?.plugin_config_controller) {
|
||||
// Default behavior: write to default config path
|
||||
try {
|
||||
const configPath = plugin.runtime.context?.configPath || pluginManager.getPluginConfigPath(id);
|
||||
|
||||
const configDir = path.dirname(configPath);
|
||||
if (!fs.existsSync(configDir)) {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
|
||||
// Auto-Reload plugin to apply changes
|
||||
await pluginManager.reloadPlugin(id);
|
||||
|
||||
return sendSuccess(res, { message: 'Config saved and plugin reloaded' });
|
||||
} catch (e: any) {
|
||||
return sendError(res, 'Error saving config: ' + e.message);
|
||||
}
|
||||
} else {
|
||||
return sendError(res, 'Plugin does not support config update');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 导入本地插件包(支持 .zip 文件)
|
||||
*/
|
||||
export const ImportLocalPluginHandler: RequestHandler = async (req, res) => {
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) {
|
||||
return sendError(res, 'Plugin Manager not found');
|
||||
}
|
||||
|
||||
// multer 会将文件信息放在 req.file 中
|
||||
const file = req.file;
|
||||
if (!file) {
|
||||
return sendError(res, 'No file uploaded');
|
||||
}
|
||||
|
||||
const PLUGINS_DIR = webUiPathWrapper.pluginPath;
|
||||
|
||||
// 确保插件目录存在
|
||||
if (!fs.existsSync(PLUGINS_DIR)) {
|
||||
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const tempZipPath = file.path;
|
||||
|
||||
try {
|
||||
// 创建临时解压目录
|
||||
const tempExtractDir = path.join(PLUGINS_DIR, `_temp_extract_${Date.now()}`);
|
||||
fs.mkdirSync(tempExtractDir, { recursive: true });
|
||||
|
||||
// 解压到临时目录
|
||||
await compressing.zip.uncompress(tempZipPath, tempExtractDir);
|
||||
|
||||
// 检查解压后的内容
|
||||
const extractedItems = fs.readdirSync(tempExtractDir);
|
||||
|
||||
let pluginSourceDir: string;
|
||||
let pluginId: string;
|
||||
|
||||
// 判断解压结构:可能是直接的插件文件,或者包含一个子目录
|
||||
const hasPackageJson = extractedItems.includes('package.json');
|
||||
const hasIndexFile = extractedItems.some(item =>
|
||||
['index.js', 'index.mjs', 'main.js', 'main.mjs'].includes(item)
|
||||
);
|
||||
|
||||
if (hasPackageJson || hasIndexFile) {
|
||||
// 直接是插件文件
|
||||
pluginSourceDir = tempExtractDir;
|
||||
|
||||
// 尝试从 package.json 获取插件 ID
|
||||
const packageJsonPath = path.join(tempExtractDir, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||
pluginId = pkg.name || path.basename(file.originalname, '.zip');
|
||||
} catch {
|
||||
pluginId = path.basename(file.originalname, '.zip');
|
||||
}
|
||||
} else {
|
||||
pluginId = path.basename(file.originalname, '.zip');
|
||||
}
|
||||
} else if (extractedItems.length === 1 && fs.statSync(path.join(tempExtractDir, extractedItems[0]!)).isDirectory()) {
|
||||
// 包含一个子目录
|
||||
const subDir = extractedItems[0]!;
|
||||
pluginSourceDir = path.join(tempExtractDir, subDir);
|
||||
|
||||
// 尝试从子目录的 package.json 获取插件 ID
|
||||
const packageJsonPath = path.join(pluginSourceDir, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||
pluginId = pkg.name || subDir;
|
||||
} catch {
|
||||
pluginId = subDir;
|
||||
}
|
||||
} else {
|
||||
pluginId = subDir;
|
||||
}
|
||||
} else {
|
||||
// 清理临时文件
|
||||
fs.rmSync(tempExtractDir, { recursive: true, force: true });
|
||||
fs.unlinkSync(tempZipPath);
|
||||
return sendError(res, 'Invalid plugin package structure');
|
||||
}
|
||||
|
||||
// 目标插件目录
|
||||
const targetPluginDir = path.join(PLUGINS_DIR, pluginId);
|
||||
|
||||
// 如果目标目录已存在,先删除
|
||||
if (fs.existsSync(targetPluginDir)) {
|
||||
// 先卸载已存在的插件
|
||||
const existingPlugin = pluginManager.getPluginInfo(pluginId);
|
||||
if (existingPlugin && existingPlugin.loaded) {
|
||||
await pluginManager.unregisterPlugin(pluginId);
|
||||
}
|
||||
fs.rmSync(targetPluginDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// 移动插件文件到目标目录
|
||||
if (pluginSourceDir === tempExtractDir) {
|
||||
// 直接重命名临时目录
|
||||
fs.renameSync(tempExtractDir, targetPluginDir);
|
||||
} else {
|
||||
// 移动子目录内容
|
||||
fs.renameSync(pluginSourceDir, targetPluginDir);
|
||||
// 清理临时目录
|
||||
fs.rmSync(tempExtractDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// 删除上传的临时文件
|
||||
if (fs.existsSync(tempZipPath)) {
|
||||
fs.unlinkSync(tempZipPath);
|
||||
}
|
||||
|
||||
// 加载插件
|
||||
const loaded = await pluginManager.loadPluginById(pluginId);
|
||||
|
||||
if (loaded) {
|
||||
return sendSuccess(res, {
|
||||
message: 'Plugin imported and loaded successfully',
|
||||
pluginId,
|
||||
installPath: targetPluginDir,
|
||||
});
|
||||
} else {
|
||||
return sendSuccess(res, {
|
||||
message: 'Plugin imported but failed to load (check plugin structure)',
|
||||
pluginId,
|
||||
installPath: targetPluginDir,
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
// 清理临时文件
|
||||
if (fs.existsSync(tempZipPath)) {
|
||||
fs.unlinkSync(tempZipPath);
|
||||
}
|
||||
return sendError(res, 'Failed to import plugin: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取插件图标
|
||||
*/
|
||||
export const GetPluginIconHandler: RequestHandler = async (req, res) => {
|
||||
const pluginId = req.params['pluginId'];
|
||||
if (!pluginId) return sendError(res, 'Plugin ID is required');
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) return sendError(res, 'Plugin Manager not found');
|
||||
|
||||
const plugin = pluginManager.getPluginInfo(pluginId);
|
||||
if (!plugin) return sendError(res, 'Plugin not found');
|
||||
|
||||
const iconPath = findPluginIconPath(pluginId, plugin.pluginPath, plugin.packageJson?.icon);
|
||||
if (!iconPath) {
|
||||
return res.status(404).json({ code: -1, message: 'Icon not found' });
|
||||
}
|
||||
|
||||
return res.sendFile(iconPath);
|
||||
};
|
||||
@@ -0,0 +1,623 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||
import { PluginStoreList } from '@/napcat-webui-backend/src/types/PluginStore';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { createWriteStream } from 'fs';
|
||||
import compressing from 'compressing';
|
||||
import { findAvailableDownloadUrl, GITHUB_RAW_MIRRORS } from 'napcat-common/src/mirror';
|
||||
import { webUiPathWrapper } from '@/napcat-webui-backend/index';
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
|
||||
import { OB11PluginMangerAdapter } from '@/napcat-onebot/network/plugin-manger';
|
||||
|
||||
// Helper to get the plugin manager adapter
|
||||
const getPluginManager = (): OB11PluginMangerAdapter | null => {
|
||||
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter;
|
||||
if (!ob11) return null;
|
||||
return ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter;
|
||||
};
|
||||
|
||||
// 插件商店源配置
|
||||
const PLUGIN_STORE_SOURCES = [
|
||||
'https://raw.githubusercontent.com/NapNeko/napcat-plugin-index/main/plugins.v4.json',
|
||||
];
|
||||
|
||||
// 插件目录 - 使用 pathWrapper
|
||||
const getPluginsDir = () => webUiPathWrapper.pluginPath;
|
||||
|
||||
/**
|
||||
* 验证插件 ID,防止路径注入攻击
|
||||
*/
|
||||
function validatePluginId (id: any): string {
|
||||
if (typeof id !== 'string') {
|
||||
throw new Error('Invalid plugin ID');
|
||||
}
|
||||
// 仅允许字母、数字、点、下划线、连字符,禁止路径遍历字符
|
||||
// 通过 path.basename 进一步确保不包含路径分隔符
|
||||
const safeId = path.basename(id);
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(safeId) || safeId !== id) {
|
||||
throw new Error('Invalid plugin ID format');
|
||||
}
|
||||
return safeId;
|
||||
}
|
||||
|
||||
// 插件列表缓存
|
||||
let pluginListCache: PluginStoreList | null = null;
|
||||
let cacheTimestamp: number = 0;
|
||||
const CACHE_TTL = 10 * 60 * 1000; // 10分钟缓存
|
||||
|
||||
/**
|
||||
* 从多个源获取插件列表,使用镜像系统
|
||||
* 带10分钟缓存,支持强制刷新
|
||||
*/
|
||||
async function fetchPluginList (forceRefresh: boolean = false): Promise<PluginStoreList> {
|
||||
// 检查缓存(如果不是强制刷新)
|
||||
const now = Date.now();
|
||||
if (!forceRefresh && pluginListCache && (now - cacheTimestamp) < CACHE_TTL) {
|
||||
// console.log('Using cached plugin list');
|
||||
return pluginListCache;
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const source of PLUGIN_STORE_SOURCES) {
|
||||
// 使用镜像系统的 raw 镜像列表
|
||||
for (const mirror of GITHUB_RAW_MIRRORS) {
|
||||
try {
|
||||
const url = mirror ? `${mirror}/${source.replace('https://raw.githubusercontent.com/', '')}` : source;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'NapCat-WebUI',
|
||||
},
|
||||
signal: AbortSignal.timeout(10000), // 10秒超时
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// console.log(`Successfully fetched plugin list from: ${url}`);
|
||||
|
||||
// 更新缓存
|
||||
pluginListCache = data as PluginStoreList;
|
||||
cacheTimestamp = now;
|
||||
|
||||
return pluginListCache;
|
||||
} catch (e: any) {
|
||||
const errorMsg = `Failed to fetch from ${source} via mirror: ${e.message}`;
|
||||
console.warn(errorMsg);
|
||||
errors.push(errorMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`All plugin sources failed:\n${errors.join('\n')}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件,使用镜像系统
|
||||
* 自动识别 GitHub Release URL 并使用镜像加速
|
||||
*/
|
||||
async function downloadFile (
|
||||
url: string,
|
||||
destPath: string,
|
||||
customMirror?: string,
|
||||
onProgress?: (percent: number, downloaded: number, total: number, speed: number) => void,
|
||||
timeout: number = 120000 // 默认120秒超时
|
||||
): Promise<void> {
|
||||
try {
|
||||
let downloadUrl: string;
|
||||
|
||||
// 判断是否是 GitHub Release URL
|
||||
// 格式: https://github.com/{owner}/{repo}/releases/download/{tag}/{filename}
|
||||
const githubReleasePattern = /^https:\/\/github\.com\/[^/]+\/[^/]+\/releases\/download\//;
|
||||
|
||||
if (githubReleasePattern.test(url)) {
|
||||
// 使用镜像系统查找可用的下载 URL(支持 GitHub Release 镜像)
|
||||
console.log(`Detected GitHub Release URL: ${url}`);
|
||||
console.log(`Custom mirror: ${customMirror || 'auto'}`);
|
||||
|
||||
downloadUrl = await findAvailableDownloadUrl(url, {
|
||||
validateContent: false, // 不验证内容,只检查状态码和 Content-Type
|
||||
timeout: 5000, // 每个镜像测试5秒超时
|
||||
useFastMirrors: false, // 不使用快速镜像列表(避免测速阻塞)
|
||||
customMirror: customMirror || undefined, // 使用用户选择的镜像
|
||||
});
|
||||
|
||||
console.log(`Selected download URL: ${downloadUrl}`);
|
||||
} else {
|
||||
// 其他URL直接下载
|
||||
console.log(`Direct download URL: ${url}`);
|
||||
downloadUrl = url;
|
||||
}
|
||||
|
||||
console.log(`Starting download from: ${downloadUrl}`);
|
||||
|
||||
// 确保目标目录存在
|
||||
const destDir = path.dirname(destPath);
|
||||
if (!fs.existsSync(destDir)) {
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
console.log(`Created directory: ${destDir}`);
|
||||
}
|
||||
|
||||
const response = await fetch(downloadUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'NapCat-WebUI',
|
||||
},
|
||||
signal: AbortSignal.timeout(timeout), // 使用传入的超时时间
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('Response body is null');
|
||||
}
|
||||
|
||||
const totalLength = Number(response.headers.get('content-length')) || 0;
|
||||
|
||||
// 初始进度通知
|
||||
if (onProgress) {
|
||||
onProgress(0, 0, totalLength, 0);
|
||||
}
|
||||
|
||||
let downloaded = 0;
|
||||
let lastTime = Date.now();
|
||||
let lastDownloaded = 0;
|
||||
|
||||
// 进度监控流
|
||||
// eslint-disable-next-line @stylistic/generator-star-spacing
|
||||
const progressMonitor = async function* (source: any) {
|
||||
for await (const chunk of source) {
|
||||
downloaded += chunk.length;
|
||||
const now = Date.now();
|
||||
const elapsedSinceLast = now - lastTime;
|
||||
|
||||
// 每隔 500ms 或完成时计算一次速度并更新进度
|
||||
if (elapsedSinceLast >= 500 || (totalLength && downloaded === totalLength)) {
|
||||
const percent = totalLength ? Math.round((downloaded / totalLength) * 100) : 0;
|
||||
const speed = (downloaded - lastDownloaded) / (elapsedSinceLast / 1000); // bytes/s
|
||||
|
||||
if (onProgress) {
|
||||
onProgress(percent, downloaded, totalLength, speed);
|
||||
}
|
||||
|
||||
lastTime = now;
|
||||
lastDownloaded = downloaded;
|
||||
}
|
||||
|
||||
yield chunk;
|
||||
}
|
||||
};
|
||||
|
||||
// 写入文件
|
||||
const fileStream = createWriteStream(destPath);
|
||||
await pipeline(progressMonitor(response.body), fileStream);
|
||||
|
||||
console.log(`Successfully downloaded to: ${destPath}`);
|
||||
} catch (e: any) {
|
||||
// 删除可能的不完整文件
|
||||
if (fs.existsSync(destPath)) {
|
||||
fs.unlinkSync(destPath);
|
||||
}
|
||||
throw new Error(`Download failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解压插件到指定目录
|
||||
*/
|
||||
async function extractPlugin (zipPath: string, pluginId: string): Promise<void> {
|
||||
// 验证 pluginId 确保安全
|
||||
const safeId = validatePluginId(pluginId);
|
||||
const PLUGINS_DIR = getPluginsDir();
|
||||
const pluginDir = path.join(PLUGINS_DIR, safeId);
|
||||
const dataDir = path.join(pluginDir, 'data');
|
||||
const tempDataDir = path.join(PLUGINS_DIR, `${safeId}.data.backup`);
|
||||
|
||||
console.log(`[extractPlugin] PLUGINS_DIR: ${PLUGINS_DIR}`);
|
||||
console.log(`[extractPlugin] pluginId: ${safeId}`);
|
||||
console.log(`[extractPlugin] Target directory: ${pluginDir}`);
|
||||
console.log(`[extractPlugin] Zip file: ${zipPath}`);
|
||||
|
||||
// 确保插件根目录存在
|
||||
if (!fs.existsSync(PLUGINS_DIR)) {
|
||||
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
||||
console.log(`[extractPlugin] Created plugins root directory: ${PLUGINS_DIR}`);
|
||||
}
|
||||
|
||||
// 如果目录已存在,先备份 data 文件夹,再删除
|
||||
let hasDataBackup = false;
|
||||
if (fs.existsSync(pluginDir)) {
|
||||
// 备份 data 文件夹
|
||||
if (fs.existsSync(dataDir)) {
|
||||
console.log(`[extractPlugin] Backing up data directory: ${dataDir}`);
|
||||
if (fs.existsSync(tempDataDir)) {
|
||||
fs.rmSync(tempDataDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.renameSync(dataDir, tempDataDir);
|
||||
hasDataBackup = true;
|
||||
}
|
||||
|
||||
console.log(`[extractPlugin] Directory exists, removing: ${pluginDir}`);
|
||||
fs.rmSync(pluginDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// 创建插件目录
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
console.log(`[extractPlugin] Created directory: ${pluginDir}`);
|
||||
|
||||
try {
|
||||
// 解压
|
||||
await compressing.zip.uncompress(zipPath, pluginDir);
|
||||
|
||||
console.log(`[extractPlugin] Plugin extracted to: ${pluginDir}`);
|
||||
|
||||
// 恢复 data 文件夹
|
||||
if (hasDataBackup && fs.existsSync(tempDataDir)) {
|
||||
// 如果新版本也有 data 文件夹,先删除
|
||||
if (fs.existsSync(dataDir)) {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
console.log(`[extractPlugin] Restoring data directory: ${dataDir}`);
|
||||
fs.renameSync(tempDataDir, dataDir);
|
||||
}
|
||||
} catch (e) {
|
||||
// 解压失败时,尝试恢复 data 文件夹
|
||||
if (hasDataBackup && fs.existsSync(tempDataDir)) {
|
||||
console.log('[extractPlugin] Extract failed, restoring data directory');
|
||||
if (!fs.existsSync(pluginDir)) {
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
}
|
||||
if (fs.existsSync(dataDir)) {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.renameSync(tempDataDir, dataDir);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
// 列出解压后的文件
|
||||
const files = fs.readdirSync(pluginDir);
|
||||
console.log('[extractPlugin] Extracted files:', files);
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装后尝试缓存插件图标
|
||||
* 如果插件 package.json 没有 icon 字段,则尝试从 GitHub 头像获取并缓存到 config 目录
|
||||
*/
|
||||
async function cachePluginIcon (pluginId: string, storePlugin: PluginStoreList['plugins'][0]): Promise<void> {
|
||||
const PLUGINS_DIR = getPluginsDir();
|
||||
const pluginDir = path.join(PLUGINS_DIR, pluginId);
|
||||
const configDir = path.join(webUiPathWrapper.configPath, 'plugins', pluginId);
|
||||
|
||||
// 检查 package.json 是否已有 icon 字段
|
||||
const packageJsonPath = path.join(pluginDir, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||
if (pkg.icon) {
|
||||
const iconPath = path.join(pluginDir, pkg.icon);
|
||||
if (fs.existsSync(iconPath)) {
|
||||
return; // 已有 icon,无需缓存
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否已有缓存的图标 (固定 icon.png)
|
||||
if (fs.existsSync(path.join(configDir, 'icon.png'))) {
|
||||
return; // 已有缓存图标
|
||||
}
|
||||
|
||||
// 尝试从 GitHub 获取头像
|
||||
let avatarUrl: string | undefined;
|
||||
|
||||
// 从 downloadUrl 提取 GitHub 用户名
|
||||
if (storePlugin.downloadUrl) {
|
||||
try {
|
||||
const url = new URL(storePlugin.downloadUrl);
|
||||
if (url.hostname === 'github.com' || url.hostname === 'www.github.com') {
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
if (parts.length >= 1) {
|
||||
avatarUrl = `https://github.com/${parts[0]}.png?size=128`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
// 从 homepage 提取
|
||||
if (!avatarUrl && storePlugin.homepage) {
|
||||
try {
|
||||
const url = new URL(storePlugin.homepage);
|
||||
if (url.hostname === 'github.com' || url.hostname === 'www.github.com') {
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
if (parts.length >= 1) {
|
||||
avatarUrl = `https://github.com/${parts[0]}.png?size=128`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
if (!avatarUrl) return;
|
||||
|
||||
try {
|
||||
// 确保 config 目录存在
|
||||
if (!fs.existsSync(configDir)) {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
}
|
||||
|
||||
const response = await fetch(avatarUrl, {
|
||||
headers: { 'User-Agent': 'NapCat-WebUI' },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) return;
|
||||
|
||||
const iconPath = path.join(configDir, 'icon.png');
|
||||
const fileStream = createWriteStream(iconPath);
|
||||
await pipeline(response.body as any, fileStream);
|
||||
|
||||
console.log(`[cachePluginIcon] Cached icon for ${pluginId} at ${iconPath}`);
|
||||
} catch (e: any) {
|
||||
console.warn(`[cachePluginIcon] Failed to cache icon for ${pluginId}:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件商店列表
|
||||
*/
|
||||
export const GetPluginStoreListHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
// 支持 forceRefresh 查询参数强制刷新缓存
|
||||
const forceRefresh = req.query['forceRefresh'] === 'true';
|
||||
const data = await fetchPluginList(forceRefresh);
|
||||
return sendSuccess(res, data);
|
||||
} catch (e: any) {
|
||||
return sendError(res, 'Failed to fetch plugin store list: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取单个插件详情
|
||||
*/
|
||||
export const GetPluginStoreDetailHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const id = validatePluginId(req.params['id']);
|
||||
const data = await fetchPluginList();
|
||||
const plugin = data.plugins.find(p => p.id === id);
|
||||
|
||||
if (!plugin) {
|
||||
return sendError(res, 'Plugin not found');
|
||||
}
|
||||
|
||||
return sendSuccess(res, plugin);
|
||||
} catch (e: any) {
|
||||
return sendError(res, 'Failed to fetch plugin detail: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 安装插件(从商店)- 普通 POST 接口
|
||||
*/
|
||||
export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { id: rawId, mirror } = req.body;
|
||||
|
||||
if (!rawId) {
|
||||
return sendError(res, 'Plugin ID is required');
|
||||
}
|
||||
|
||||
const id = validatePluginId(rawId);
|
||||
|
||||
// 获取插件信息
|
||||
const data = await fetchPluginList();
|
||||
const plugin = data.plugins.find(p => p.id === id);
|
||||
|
||||
if (!plugin) {
|
||||
return sendError(res, 'Plugin not found in store');
|
||||
}
|
||||
|
||||
// 检查是否已安装相同版本
|
||||
const pm = getPluginManager();
|
||||
if (pm) {
|
||||
const installedInfo = pm.getPluginInfo(id);
|
||||
if (installedInfo && installedInfo.version === plugin.version) {
|
||||
return sendError(res, '该插件已安装且版本相同,无需重复安装');
|
||||
}
|
||||
}
|
||||
|
||||
// 下载插件
|
||||
const PLUGINS_DIR = getPluginsDir();
|
||||
const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`);
|
||||
|
||||
try {
|
||||
await downloadFile(plugin.downloadUrl, tempZipPath, mirror, undefined, 300000);
|
||||
|
||||
// 解压插件
|
||||
await extractPlugin(tempZipPath, id);
|
||||
|
||||
// 删除临时文件
|
||||
fs.unlinkSync(tempZipPath);
|
||||
|
||||
// 如果 pluginManager 存在,立即注册或重载插件
|
||||
const pluginManager = getPluginManager();
|
||||
if (pluginManager) {
|
||||
// 如果插件已存在,则重载以刷新版本信息;否则注册新插件
|
||||
if (pluginManager.getPluginInfo(id)) {
|
||||
await pluginManager.reloadPlugin(id);
|
||||
} else {
|
||||
await pluginManager.loadPluginById(id);
|
||||
}
|
||||
}
|
||||
|
||||
// 安装后尝试缓存插件图标(如果 package.json 没有 icon 字段),失败可跳过
|
||||
try {
|
||||
await cachePluginIcon(id, plugin);
|
||||
} catch (e: any) {
|
||||
console.warn(`[InstallPlugin] Failed to cache icon for ${id}, skipping:`, e.message);
|
||||
}
|
||||
|
||||
return sendSuccess(res, {
|
||||
message: 'Plugin installed successfully',
|
||||
plugin,
|
||||
installPath: path.join(PLUGINS_DIR, id),
|
||||
});
|
||||
} catch (downloadError: any) {
|
||||
// 清理临时文件
|
||||
if (fs.existsSync(tempZipPath)) {
|
||||
fs.unlinkSync(tempZipPath);
|
||||
}
|
||||
throw downloadError;
|
||||
}
|
||||
} catch (e: any) {
|
||||
return sendError(res, 'Failed to install plugin: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 安装插件(从商店)- SSE 版本,实时推送进度
|
||||
*/
|
||||
export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res) => {
|
||||
const { id: rawId, mirror } = req.query;
|
||||
|
||||
if (!rawId || typeof rawId !== 'string') {
|
||||
res.status(400).json({ error: 'Plugin ID is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
let id: string;
|
||||
try {
|
||||
id = validatePluginId(rawId);
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置 SSE 响应头
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders();
|
||||
|
||||
const sendProgress = (message: string, progress?: number, detail?: any) => {
|
||||
res.write(`data: ${JSON.stringify({ message, progress, ...detail })}\n\n`);
|
||||
};
|
||||
|
||||
try {
|
||||
sendProgress('正在获取插件信息...', 10);
|
||||
|
||||
// 获取插件信息
|
||||
const data = await fetchPluginList();
|
||||
const plugin = data.plugins.find(p => p.id === id);
|
||||
|
||||
if (!plugin) {
|
||||
sendProgress('错误: 插件不存在', 0);
|
||||
res.write(`data: ${JSON.stringify({ error: 'Plugin not found in store' })}\n\n`);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已安装相同版本
|
||||
const pm = getPluginManager();
|
||||
if (pm) {
|
||||
const installedInfo = pm.getPluginInfo(id);
|
||||
if (installedInfo && installedInfo.version === plugin.version) {
|
||||
sendProgress('错误: 该插件已安装且版本相同', 0);
|
||||
res.write(`data: ${JSON.stringify({ error: '该插件已安装且版本相同,无需重复安装' })}\n\n`);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
sendProgress(`找到插件: ${plugin.name} v${plugin.version}`, 20);
|
||||
sendProgress(`下载地址: ${plugin.downloadUrl}`, 25);
|
||||
|
||||
if (mirror && typeof mirror === 'string') {
|
||||
sendProgress(`使用镜像: ${mirror}`, 28);
|
||||
}
|
||||
|
||||
// 下载插件
|
||||
const PLUGINS_DIR = getPluginsDir();
|
||||
const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`);
|
||||
|
||||
try {
|
||||
sendProgress('正在下载插件...', 30);
|
||||
await downloadFile(plugin.downloadUrl, tempZipPath, mirror as string | undefined, (percent, downloaded, total, speed) => {
|
||||
const overallProgress = 30 + Math.round(percent * 0.5);
|
||||
const downloadedMb = (downloaded / 1024 / 1024).toFixed(1);
|
||||
const totalMb = total ? (total / 1024 / 1024).toFixed(1) : '?';
|
||||
const speedMb = (speed / 1024 / 1024).toFixed(2);
|
||||
const eta = (total > 0 && speed > 0) ? Math.round((total - downloaded) / speed) : -1;
|
||||
|
||||
sendProgress(`正在下载插件... ${percent}%`, overallProgress, {
|
||||
downloaded,
|
||||
total,
|
||||
speed,
|
||||
eta,
|
||||
downloadedStr: `${downloadedMb}MB`,
|
||||
totalStr: `${totalMb}MB`,
|
||||
speedStr: `${speedMb}MB/s`,
|
||||
});
|
||||
}, 300000);
|
||||
|
||||
sendProgress('下载完成,正在解压...', 85);
|
||||
await extractPlugin(tempZipPath, id);
|
||||
|
||||
sendProgress('解压完成,正在清理...', 95);
|
||||
fs.unlinkSync(tempZipPath);
|
||||
|
||||
// 如果 pluginManager 存在,立即注册或重载插件
|
||||
const pluginManager = getPluginManager();
|
||||
if (pluginManager) {
|
||||
// 如果插件已存在,则重载以刷新版本信息;否则注册新插件
|
||||
if (pluginManager.getPluginInfo(id)) {
|
||||
sendProgress('正在刷新插件信息...', 95);
|
||||
await pluginManager.reloadPlugin(id);
|
||||
} else {
|
||||
sendProgress('正在注册插件...', 95);
|
||||
await pluginManager.loadPluginById(id);
|
||||
}
|
||||
}
|
||||
|
||||
sendProgress('安装成功!', 100);
|
||||
|
||||
// 安装后尝试缓存插件图标(如果 package.json 没有 icon 字段)
|
||||
cachePluginIcon(id, plugin).catch(e => {
|
||||
console.warn(`[cachePluginIcon] Failed to cache icon for ${id}:`, e.message);
|
||||
});
|
||||
|
||||
res.write(`data: ${JSON.stringify({
|
||||
success: true,
|
||||
message: 'Plugin installed successfully',
|
||||
plugin,
|
||||
installPath: path.join(PLUGINS_DIR, id),
|
||||
})}\n\n`);
|
||||
res.end();
|
||||
} catch (downloadError: any) {
|
||||
// 清理临时文件
|
||||
if (fs.existsSync(tempZipPath)) {
|
||||
fs.unlinkSync(tempZipPath);
|
||||
}
|
||||
sendProgress(`错误: ${downloadError.message}`, 0);
|
||||
res.write(`data: ${JSON.stringify({ error: downloadError.message })}\n\n`);
|
||||
res.end();
|
||||
}
|
||||
} catch (e: any) {
|
||||
sendProgress(`错误: ${e.message}`, 0);
|
||||
res.write(`data: ${JSON.stringify({ error: e.message })}\n\n`);
|
||||
res.end();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { WebUiDataRuntime } from '../helper/Data';
|
||||
import { sendError, sendSuccess } from '../utils/response';
|
||||
|
||||
/**
|
||||
* 重启进程处理器
|
||||
* POST /api/Process/Restart
|
||||
*/
|
||||
export async function RestartProcessHandler (_req: Request, res: Response) {
|
||||
try {
|
||||
const result = await WebUiDataRuntime.requestRestartProcess();
|
||||
|
||||
if (result.result) {
|
||||
return sendSuccess(res, { message: result.message || '进程重启请求已发送' });
|
||||
} else {
|
||||
return sendError(res, result.message || '进程重启失败');
|
||||
}
|
||||
} catch (e) {
|
||||
return sendError(res, '重启进程时发生错误: ' + (e as Error).message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import { RequestUtil } from 'napcat-common/src/request';
|
||||
import { sendError, sendSuccess } from '../utils/response';
|
||||
|
||||
export const GetProxyHandler: RequestHandler = async (req, res) => {
|
||||
let { url } = req.query;
|
||||
if (url && typeof url === 'string') {
|
||||
url = decodeURIComponent(url);
|
||||
const responseText = await RequestUtil.HttpGetText(url);
|
||||
return sendSuccess(res, responseText);
|
||||
} else {
|
||||
return sendError(res, 'url参数不合法');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,707 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import https from 'https';
|
||||
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
||||
import { isEmpty } from '@/napcat-webui-backend/src/utils/check';
|
||||
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||
import {
|
||||
Registry20Utils,
|
||||
MachineInfoUtils,
|
||||
getGlobalDataPath,
|
||||
MacMachineInfoUtils,
|
||||
computeMacGuidInfo,
|
||||
getMacPlatformUuid,
|
||||
getMacSn,
|
||||
getMacSerialNumber,
|
||||
getMacDiskSerial,
|
||||
getMacPrimaryMac,
|
||||
} from '@/napcat-webui-backend/src/utils/guid';
|
||||
import os from 'node:os';
|
||||
|
||||
// oidb 新设备验证请求辅助函数
|
||||
function oidbRequest (uid: string, body: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const postData = JSON.stringify(body);
|
||||
const req = https.request({
|
||||
hostname: 'oidb.tim.qq.com',
|
||||
path: `/v3/oidbinterface/oidb_0xc9e_8?uid=${encodeURIComponent(uid)}&getqrcode=1&sdkappid=39998&actype=2`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(postData),
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36',
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
},
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch {
|
||||
reject(new Error('Failed to parse oidb response'));
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(postData);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// 获取 QQ 数据根目录 (dataPath)
|
||||
const getQQDataPath = (): string => {
|
||||
// 优先从 WebUiDataRuntime 获取早期设置的 dataPath
|
||||
let dataPath = WebUiDataRuntime.getQQDataPath();
|
||||
if (!dataPath) {
|
||||
// 回退: 从 OneBotContext 获取
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
dataPath = oneBotContext?.core?.dataPath;
|
||||
}
|
||||
if (!dataPath) {
|
||||
throw new Error('QQ data path not available yet');
|
||||
}
|
||||
return dataPath;
|
||||
};
|
||||
|
||||
// 获取 Registry20 路径的辅助函数
|
||||
const getRegistryPath = () => {
|
||||
return Registry20Utils.getRegistryPath(getQQDataPath());
|
||||
};
|
||||
|
||||
// 获取 machine-info 路径的辅助函数 (Linux)
|
||||
const getMachineInfoPath = () => {
|
||||
return MachineInfoUtils.getMachineInfoPath(getQQDataPath());
|
||||
};
|
||||
|
||||
// 获取QQ登录二维码
|
||||
export const QQGetQRcodeHandler: RequestHandler = async (_, res) => {
|
||||
// 判断是否已经登录
|
||||
if (WebUiDataRuntime.getQQLoginStatus()) {
|
||||
// 已经登录
|
||||
return sendError(res, 'QQ Is Logined');
|
||||
}
|
||||
// 获取二维码
|
||||
const qrcodeUrl = WebUiDataRuntime.getQQLoginQrcodeURL();
|
||||
// 判断二维码是否为空
|
||||
if (isEmpty(qrcodeUrl)) {
|
||||
return sendError(res, 'QRCode Get Error');
|
||||
}
|
||||
// 返回二维码URL
|
||||
const data = {
|
||||
qrcode: qrcodeUrl,
|
||||
};
|
||||
return sendSuccess(res, data);
|
||||
};
|
||||
|
||||
// 获取QQ登录状态
|
||||
export const QQCheckLoginStatusHandler: RequestHandler = async (_, res) => {
|
||||
// 从 OneBot 上下文获取实时的 selfInfo.online 状态
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
const selfInfo = oneBotContext?.core?.selfInfo;
|
||||
const isOnline = selfInfo?.online;
|
||||
const qqLoginStatus = WebUiDataRuntime.getQQLoginStatus();
|
||||
const loginPhase = WebUiDataRuntime.getQQLoginPhase();
|
||||
// 必须同时满足:已登录且在线(online 必须明确为 true)
|
||||
// 登录状态以 QQ 登录成功和 Core 明确在线为准。loginPhase 用于展示进度,
|
||||
// 不能作为跳转的额外门槛,否则 phase 事件延迟时会卡在扫码页。
|
||||
const isLogin = qqLoginStatus && isOnline === true;
|
||||
// 检测掉线状态:已登录但不在线
|
||||
const isOffline = qqLoginStatus && isOnline === false;
|
||||
const data = {
|
||||
isLogin,
|
||||
isOffline,
|
||||
loginPhase,
|
||||
qrLoginAccepted: qqLoginStatus && (loginPhase === 'qrcode_scanned' || loginPhase === 'initializing'),
|
||||
coreReady: qqLoginStatus && isOnline === true,
|
||||
qrcodeurl: WebUiDataRuntime.getQQLoginQrcodeURL(),
|
||||
loginError: WebUiDataRuntime.getQQLoginError(),
|
||||
};
|
||||
return sendSuccess(res, data);
|
||||
};
|
||||
|
||||
// 快速登录
|
||||
export const QQSetQuickLoginHandler: RequestHandler = async (req, res) => {
|
||||
// 获取QQ号
|
||||
const { uin } = req.body;
|
||||
// 判断是否已经登录
|
||||
const isLogin = WebUiDataRuntime.getQQLoginStatus();
|
||||
if (isLogin) {
|
||||
return sendError(res, 'QQ Is Logined');
|
||||
}
|
||||
// 判断QQ号是否为空
|
||||
if (isEmpty(uin)) {
|
||||
return sendError(res, 'uin is empty');
|
||||
}
|
||||
|
||||
// 获取快速登录状态
|
||||
const { result, message } = await WebUiDataRuntime.requestQuickLogin(uin);
|
||||
if (!result) {
|
||||
return sendError(res, message);
|
||||
}
|
||||
// 本来应该验证 但是http不宜这么搞 建议前端验证
|
||||
// isLogin = WebUiDataRuntime.getQQLoginStatus();
|
||||
return sendSuccess(res, null);
|
||||
};
|
||||
|
||||
// 获取快速登录列表
|
||||
export const QQGetQuickLoginListHandler: RequestHandler = async (_, res) => {
|
||||
const quickLoginList = WebUiDataRuntime.getQQQuickLoginList();
|
||||
return sendSuccess(res, quickLoginList);
|
||||
};
|
||||
|
||||
// 获取快速登录列表(新)
|
||||
export const QQGetLoginListNewHandler: RequestHandler = async (_, res) => {
|
||||
const newLoginList = WebUiDataRuntime.getQQNewLoginList();
|
||||
return sendSuccess(res, newLoginList);
|
||||
};
|
||||
|
||||
// 获取登录的QQ的信息
|
||||
export const getQQLoginInfoHandler: RequestHandler = async (_, res) => {
|
||||
const basicInfo = WebUiDataRuntime.getQQLoginInfo();
|
||||
// 从 OneBot 上下文获取实时的 selfInfo.online 状态
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
const selfInfo = oneBotContext?.core?.selfInfo;
|
||||
const online = selfInfo?.online ?? undefined;
|
||||
const avatarUrl = selfInfo?.avatarUrl;
|
||||
const data = {
|
||||
...basicInfo,
|
||||
online,
|
||||
avatarUrl,
|
||||
};
|
||||
return sendSuccess(res, data);
|
||||
};
|
||||
|
||||
// 获取自动登录QQ账号
|
||||
export const getAutoLoginAccountHandler: RequestHandler = async (_, res) => {
|
||||
const data = WebUiConfig.getAutoLoginAccount();
|
||||
return sendSuccess(res, data);
|
||||
};
|
||||
|
||||
// 设置自动登录QQ账号
|
||||
export const setAutoLoginAccountHandler: RequestHandler = async (req, res) => {
|
||||
const { uin } = req.body;
|
||||
await WebUiConfig.UpdateAutoLoginAccount(uin);
|
||||
return sendSuccess(res, null);
|
||||
};
|
||||
|
||||
// 刷新QQ登录二维码
|
||||
export const QQRefreshQRcodeHandler: RequestHandler = async (_, res) => {
|
||||
// 与 CheckLoginStatus 保持一致:运行时登录标记可能在账号下线后
|
||||
// 尚未同步,但 core.selfInfo.online 已能反映实际在线状态。
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
const isOnline = oneBotContext?.core?.selfInfo?.online === true;
|
||||
if (WebUiDataRuntime.getQQLoginStatus() && isOnline) {
|
||||
// 已经登录
|
||||
return sendError(res, 'QQ Is Logined');
|
||||
}
|
||||
// 被踢下线后,后台会从 KickedOffLine 事件异步重启 Worker。不要在这个
|
||||
// HTTP 请求里再次触发刷新/重启,否则 Worker 退出会打断该响应。
|
||||
if (WebUiDataRuntime.getQQLoginPhase() === 'reconnecting') {
|
||||
return sendSuccess(res, { qrcodeurl: '', restarting: true });
|
||||
}
|
||||
try {
|
||||
const qrcodeurl = await WebUiDataRuntime.refreshQRCode();
|
||||
return sendSuccess(res, { qrcodeurl });
|
||||
} catch (error) {
|
||||
return sendError(res, `QRCode Refresh Error: ${(error as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 密码登录
|
||||
export const QQPasswordLoginHandler: RequestHandler = async (req, res) => {
|
||||
// 获取QQ号和密码MD5
|
||||
const { uin, passwordMd5 } = req.body;
|
||||
// 判断是否已经登录
|
||||
const isLogin = WebUiDataRuntime.getQQLoginStatus();
|
||||
if (isLogin) {
|
||||
return sendError(res, 'QQ Is Logined');
|
||||
}
|
||||
// 判断QQ号是否为空
|
||||
if (isEmpty(uin)) {
|
||||
return sendError(res, 'uin is empty');
|
||||
}
|
||||
// 判断密码MD5是否为空
|
||||
if (isEmpty(passwordMd5)) {
|
||||
return sendError(res, 'passwordMd5 is empty');
|
||||
}
|
||||
|
||||
// 执行密码登录
|
||||
const { result, message, needCaptcha, proofWaterUrl, needNewDevice, jumpUrl, newDevicePullQrCodeSig } = await WebUiDataRuntime.requestPasswordLogin(uin, passwordMd5);
|
||||
if (!result) {
|
||||
if (needCaptcha && proofWaterUrl) {
|
||||
return sendSuccess(res, { needCaptcha: true, proofWaterUrl });
|
||||
}
|
||||
if (needNewDevice && jumpUrl) {
|
||||
return sendSuccess(res, { needNewDevice: true, jumpUrl, newDevicePullQrCodeSig });
|
||||
}
|
||||
return sendError(res, message);
|
||||
}
|
||||
return sendSuccess(res, null);
|
||||
};
|
||||
|
||||
// 验证码登录(密码登录需要验证码时的第二步)
|
||||
export const QQCaptchaLoginHandler: RequestHandler = async (req, res) => {
|
||||
const { uin, passwordMd5, ticket, randstr, sid } = req.body;
|
||||
const isLogin = WebUiDataRuntime.getQQLoginStatus();
|
||||
if (isLogin) {
|
||||
return sendError(res, 'QQ Is Logined');
|
||||
}
|
||||
if (isEmpty(uin) || isEmpty(passwordMd5)) {
|
||||
return sendError(res, 'uin or passwordMd5 is empty');
|
||||
}
|
||||
if (isEmpty(ticket) || isEmpty(randstr)) {
|
||||
return sendError(res, 'captcha ticket or randstr is empty');
|
||||
}
|
||||
|
||||
const { result, message, needNewDevice, jumpUrl, newDevicePullQrCodeSig: sig } = await WebUiDataRuntime.requestCaptchaLogin(uin, passwordMd5, ticket, randstr, sid || '');
|
||||
if (!result) {
|
||||
if (needNewDevice && jumpUrl) {
|
||||
return sendSuccess(res, { needNewDevice: true, jumpUrl, newDevicePullQrCodeSig: sig });
|
||||
}
|
||||
return sendError(res, message);
|
||||
}
|
||||
return sendSuccess(res, null);
|
||||
};
|
||||
|
||||
// 新设备验证登录(密码登录需要新设备验证时的第二步)
|
||||
export const QQNewDeviceLoginHandler: RequestHandler = async (req, res) => {
|
||||
const { uin, passwordMd5, newDevicePullQrCodeSig } = req.body;
|
||||
const isLogin = WebUiDataRuntime.getQQLoginStatus();
|
||||
if (isLogin) {
|
||||
return sendError(res, 'QQ Is Logined');
|
||||
}
|
||||
if (isEmpty(uin) || isEmpty(passwordMd5)) {
|
||||
return sendError(res, 'uin or passwordMd5 is empty');
|
||||
}
|
||||
if (isEmpty(newDevicePullQrCodeSig)) {
|
||||
return sendError(res, 'newDevicePullQrCodeSig is empty');
|
||||
}
|
||||
|
||||
const { result, message, needNewDevice, jumpUrl, newDevicePullQrCodeSig: sig } = await WebUiDataRuntime.requestNewDeviceLogin(uin, passwordMd5, newDevicePullQrCodeSig);
|
||||
if (!result) {
|
||||
if (needNewDevice && jumpUrl) {
|
||||
return sendSuccess(res, { needNewDevice: true, jumpUrl, newDevicePullQrCodeSig: sig });
|
||||
}
|
||||
return sendError(res, message);
|
||||
}
|
||||
return sendSuccess(res, null);
|
||||
};
|
||||
|
||||
// 重置设备信息
|
||||
export const QQResetDeviceIDHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const registryPath = getRegistryPath();
|
||||
// 自动备份
|
||||
try {
|
||||
await Registry20Utils.backup(registryPath);
|
||||
} catch (_e) {
|
||||
// 忽略备份错误(例如文件不存在)
|
||||
}
|
||||
|
||||
await Registry20Utils.delete(registryPath);
|
||||
return sendSuccess(res, { message: 'Device ID reset successfully (Registry20 deleted)' });
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to reset Device ID: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取设备 GUID
|
||||
export const QQGetDeviceGUIDHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const registryPath = getRegistryPath();
|
||||
const guid = await Registry20Utils.readGuid(registryPath);
|
||||
return sendSuccess(res, { guid });
|
||||
} catch (e) {
|
||||
// 可能是文件不存在,或者非 Windows 平台,或者解密失败
|
||||
return sendError(res, `Failed to get GUID: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 设置设备 GUID
|
||||
export const QQSetDeviceGUIDHandler: RequestHandler = async (req, res) => {
|
||||
const { guid } = req.body;
|
||||
if (!guid || typeof guid !== 'string' || guid.length !== 32) {
|
||||
return sendError(res, 'Invalid GUID format, must be 32 hex characters');
|
||||
}
|
||||
try {
|
||||
const registryPath = getRegistryPath();
|
||||
// 自动备份
|
||||
try {
|
||||
await Registry20Utils.backup(registryPath);
|
||||
} catch { }
|
||||
|
||||
await Registry20Utils.writeGuid(registryPath, guid);
|
||||
return sendSuccess(res, { message: 'GUID set successfully' });
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to set GUID: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取备份列表
|
||||
export const QQGetGUIDBackupsHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const registryPath = getRegistryPath();
|
||||
const backups = Registry20Utils.getBackups(registryPath);
|
||||
return sendSuccess(res, backups);
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to get backups: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 恢复备份
|
||||
export const QQRestoreGUIDBackupHandler: RequestHandler = async (req, res) => {
|
||||
const { backupName } = req.body;
|
||||
if (!backupName) {
|
||||
return sendError(res, 'Backup name is required');
|
||||
}
|
||||
try {
|
||||
const registryPath = getRegistryPath();
|
||||
await Registry20Utils.restore(registryPath, backupName);
|
||||
return sendSuccess(res, { message: 'Restored successfully' });
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to restore: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 创建备份
|
||||
export const QQCreateGUIDBackupHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const registryPath = getRegistryPath();
|
||||
const backupPath = await Registry20Utils.backup(registryPath);
|
||||
return sendSuccess(res, { message: 'Backup created', path: backupPath });
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to backup: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 重启NapCat
|
||||
export const QQRestartNapCatHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const result = await WebUiDataRuntime.requestRestartProcess();
|
||||
if (result.result) {
|
||||
return sendSuccess(res, { message: result.message || 'Restart initiated' });
|
||||
} else {
|
||||
return sendError(res, result.message || 'Restart failed');
|
||||
}
|
||||
} catch (e) {
|
||||
return sendError(res, `Restart error: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 平台信息 & Linux GUID 管理
|
||||
// ============================================================
|
||||
|
||||
// 获取平台信息
|
||||
export const QQGetPlatformInfoHandler: RequestHandler = async (_, res) => {
|
||||
return sendSuccess(res, { platform: os.platform() });
|
||||
};
|
||||
|
||||
// 获取 Linux MAC 地址 (从 machine-info 文件读取)
|
||||
export const QQGetLinuxMACHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const machineInfoPath = getMachineInfoPath();
|
||||
const mac = MachineInfoUtils.readMac(machineInfoPath);
|
||||
return sendSuccess(res, { mac });
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to get MAC: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 设置 Linux MAC 地址 (写入 machine-info 文件)
|
||||
export const QQSetLinuxMACHandler: RequestHandler = async (req, res) => {
|
||||
const { mac } = req.body;
|
||||
if (!mac || typeof mac !== 'string') {
|
||||
return sendError(res, 'MAC address is required');
|
||||
}
|
||||
try {
|
||||
const machineInfoPath = getMachineInfoPath();
|
||||
// 自动备份
|
||||
try {
|
||||
MachineInfoUtils.backup(machineInfoPath);
|
||||
} catch { }
|
||||
|
||||
MachineInfoUtils.writeMac(machineInfoPath, mac);
|
||||
return sendSuccess(res, { message: 'MAC set successfully' });
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to set MAC: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取 Linux machine-id
|
||||
export const QQGetLinuxMachineIdHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const machineId = MachineInfoUtils.readMachineId();
|
||||
return sendSuccess(res, { machineId });
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to read machine-id: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 计算 Linux GUID (用于前端实时预览)
|
||||
export const QQComputeLinuxGUIDHandler: RequestHandler = async (req, res) => {
|
||||
const { mac, machineId } = req.body;
|
||||
try {
|
||||
// 如果没传 machineId,从 /etc/machine-id 读取
|
||||
let mid = machineId;
|
||||
if (!mid || typeof mid !== 'string') {
|
||||
try {
|
||||
mid = MachineInfoUtils.readMachineId();
|
||||
} catch {
|
||||
mid = '';
|
||||
}
|
||||
}
|
||||
// 如果没传 mac,从 machine-info 文件读取
|
||||
let macStr = mac;
|
||||
if (!macStr || typeof macStr !== 'string') {
|
||||
try {
|
||||
const machineInfoPath = getMachineInfoPath();
|
||||
macStr = MachineInfoUtils.readMac(machineInfoPath);
|
||||
} catch {
|
||||
macStr = '';
|
||||
}
|
||||
}
|
||||
const guid = MachineInfoUtils.computeGuid(mid, macStr);
|
||||
return sendSuccess(res, { guid, machineId: mid, mac: macStr });
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to compute GUID: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取 Linux machine-info 备份列表
|
||||
export const QQGetLinuxMachineInfoBackupsHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const machineInfoPath = getMachineInfoPath();
|
||||
const backups = MachineInfoUtils.getBackups(machineInfoPath);
|
||||
return sendSuccess(res, backups);
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to get backups: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 创建 Linux machine-info 备份
|
||||
export const QQCreateLinuxMachineInfoBackupHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const machineInfoPath = getMachineInfoPath();
|
||||
const backupPath = MachineInfoUtils.backup(machineInfoPath);
|
||||
return sendSuccess(res, { message: 'Backup created', path: backupPath });
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to backup: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 恢复 Linux machine-info 备份
|
||||
export const QQRestoreLinuxMachineInfoBackupHandler: RequestHandler = async (req, res) => {
|
||||
const { backupName } = req.body;
|
||||
if (!backupName) {
|
||||
return sendError(res, 'Backup name is required');
|
||||
}
|
||||
try {
|
||||
const machineInfoPath = getMachineInfoPath();
|
||||
MachineInfoUtils.restore(machineInfoPath, backupName);
|
||||
return sendSuccess(res, { message: 'Restored successfully' });
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to restore: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 重置 Linux 设备信息 (删除 machine-info)
|
||||
export const QQResetLinuxDeviceIDHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const machineInfoPath = getMachineInfoPath();
|
||||
// 自动备份
|
||||
try {
|
||||
MachineInfoUtils.backup(machineInfoPath);
|
||||
} catch { }
|
||||
|
||||
MachineInfoUtils.delete(machineInfoPath);
|
||||
return sendSuccess(res, { message: 'Device ID reset successfully (machine-info deleted)' });
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to reset Device ID: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// macOS GUID 管理 (machineid-info / TEA)
|
||||
// ============================================================
|
||||
|
||||
// macOS: 当前生效的 GUID 与来源信息
|
||||
export const QQGetMacGUIDInfoHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const dataPathGlobal = getGlobalDataPath(getQQDataPath());
|
||||
const info = await computeMacGuidInfo(dataPathGlobal);
|
||||
return sendSuccess(res, info);
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to get macOS GUID: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// macOS: 本机硬件信息 (UUID / SN / 主网卡 MAC)
|
||||
export const QQGetMacHardwareInfoHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const [mac, platformUuid, serialNumber, diskSerial] = await Promise.all([
|
||||
getMacPrimaryMac(),
|
||||
getMacPlatformUuid(),
|
||||
getMacSerialNumber(),
|
||||
getMacDiskSerial(),
|
||||
]);
|
||||
return sendSuccess(res, {
|
||||
platformUuid: platformUuid ?? '',
|
||||
serialNumber: serialNumber ?? '',
|
||||
diskSerial: diskSerial ?? '',
|
||||
mac: mac ? mac.toString('hex') : '',
|
||||
});
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to get macOS hardware info: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// macOS: 修改 machine_id (写入 TEA 加密的 machineid-info)
|
||||
export const QQSetMacMachineIdHandler: RequestHandler = async (req, res) => {
|
||||
const { machineId, sn } = req.body;
|
||||
if (!machineId || typeof machineId !== 'string' || !/^[0-9a-fA-F]{16}$/.test(machineId)) {
|
||||
return sendError(res, 'Invalid machine_id, must be 16 hex characters');
|
||||
}
|
||||
try {
|
||||
const dataPathGlobal = getGlobalDataPath(getQQDataPath());
|
||||
const uuid = await getMacPlatformUuid();
|
||||
if (!uuid) {
|
||||
return sendError(res, 'IOPlatformUUID not available, cannot derive TEA key');
|
||||
}
|
||||
const key = MacMachineInfoUtils.keyFromUuid(uuid);
|
||||
const machineIdInfoPath = MacMachineInfoUtils.getMachineIdInfoPath(dataPathGlobal);
|
||||
// SN 留空 = 沿用当前生效值: 优先 machineid-info 内已有 SN, 其次本机硬件 SN
|
||||
let snValue: string;
|
||||
if (typeof sn === 'string' && sn.length > 0) {
|
||||
snValue = sn;
|
||||
} else {
|
||||
const existing = MacMachineInfoUtils.readMachineIdInfo(machineIdInfoPath, key);
|
||||
snValue = existing?.sn || (await getMacSn());
|
||||
}
|
||||
// 自动备份
|
||||
try {
|
||||
MacMachineInfoUtils.backup(machineIdInfoPath);
|
||||
} catch { }
|
||||
MacMachineInfoUtils.writeMachineIdInfo(machineIdInfoPath, Buffer.from(machineId, 'hex'), snValue, key);
|
||||
const guid = MacMachineInfoUtils.computeGuid(Buffer.from(machineId, 'hex'), snValue);
|
||||
return sendSuccess(res, {
|
||||
message: 'machine_id set successfully',
|
||||
guid: guid.toString('hex'),
|
||||
guidUuid: MacMachineInfoUtils.guidToUuidStyle(guid),
|
||||
});
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to set machine_id: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// macOS: machineid-info 备份列表
|
||||
export const QQGetMacInfoBackupsHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const dataPathGlobal = getGlobalDataPath(getQQDataPath());
|
||||
const backups = MacMachineInfoUtils.getBackups(MacMachineInfoUtils.getMachineIdInfoPath(dataPathGlobal));
|
||||
return sendSuccess(res, backups);
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to get backups: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// macOS: 创建 machineid-info 备份
|
||||
export const QQCreateMacInfoBackupHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const dataPathGlobal = getGlobalDataPath(getQQDataPath());
|
||||
const backupPath = MacMachineInfoUtils.backup(MacMachineInfoUtils.getMachineIdInfoPath(dataPathGlobal));
|
||||
return sendSuccess(res, { message: 'Backup created', path: backupPath });
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to backup: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// macOS: 恢复 machineid-info 备份
|
||||
export const QQRestoreMacInfoBackupHandler: RequestHandler = async (req, res) => {
|
||||
const { backupName } = req.body;
|
||||
if (!backupName) {
|
||||
return sendError(res, 'Backup name is required');
|
||||
}
|
||||
try {
|
||||
const dataPathGlobal = getGlobalDataPath(getQQDataPath());
|
||||
MacMachineInfoUtils.restore(MacMachineInfoUtils.getMachineIdInfoPath(dataPathGlobal), backupName);
|
||||
return sendSuccess(res, { message: 'Restored successfully' });
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to restore: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// macOS: 重置设备信息 (删除 machineid-info, 回退到 MAC)
|
||||
export const QQResetMacDeviceIDHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const dataPathGlobal = getGlobalDataPath(getQQDataPath());
|
||||
const machineIdInfoPath = MacMachineInfoUtils.getMachineIdInfoPath(dataPathGlobal);
|
||||
// 自动备份
|
||||
try {
|
||||
MacMachineInfoUtils.backup(machineIdInfoPath);
|
||||
} catch { }
|
||||
MacMachineInfoUtils.delete(machineIdInfoPath);
|
||||
return sendSuccess(res, { message: 'Device ID reset successfully (machineid-info deleted)' });
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to reset Device ID: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// OIDB 新设备 QR 验证
|
||||
// ============================================================
|
||||
|
||||
// 获取新设备验证二维码 (通过 OIDB 接口)
|
||||
export const QQGetNewDeviceQRCodeHandler: RequestHandler = async (req, res) => {
|
||||
const { uin, jumpUrl } = req.body;
|
||||
if (!uin || !jumpUrl) {
|
||||
return sendError(res, 'uin and jumpUrl are required');
|
||||
}
|
||||
|
||||
// 从 jumpUrl 中提取参数
|
||||
// jumpUrl 格式: https://accounts.qq.com/safe/verify?...&uin-token=xxx&sig=yyy
|
||||
// sig -> str_dev_auth_token, uin-token -> str_uin_token
|
||||
const url = new URL(jumpUrl);
|
||||
const strDevAuthToken = url.searchParams.get('sig') || '';
|
||||
const strUinToken = url.searchParams.get('uin-token') || '';
|
||||
|
||||
if (!strDevAuthToken || !strUinToken) {
|
||||
return sendError(res, 'Failed to get new device QR code: unable to extract sig/uin-token from jumpUrl');
|
||||
}
|
||||
|
||||
const body = {
|
||||
str_dev_auth_token: strDevAuthToken,
|
||||
uint32_flag: 1,
|
||||
uint32_url_type: 0,
|
||||
str_uin_token: strUinToken,
|
||||
str_dev_type: 'Windows',
|
||||
str_dev_name: os.hostname() || 'DESKTOP-NAPCAT',
|
||||
};
|
||||
|
||||
const result = await oidbRequest(uin, body);
|
||||
return sendSuccess(res, result);
|
||||
};
|
||||
|
||||
// 轮询新设备验证二维码状态
|
||||
export const QQPollNewDeviceQRHandler: RequestHandler = async (req, res) => {
|
||||
const { uin, bytesToken } = req.body;
|
||||
if (!uin || !bytesToken) {
|
||||
return sendError(res, 'uin and bytesToken are required');
|
||||
}
|
||||
|
||||
try {
|
||||
const body = {
|
||||
uint32_flag: 0,
|
||||
bytes_token: bytesToken, // base64 编码的 token
|
||||
};
|
||||
|
||||
const result = await oidbRequest(uin, body);
|
||||
// result 应包含 uint32_guarantee_status:
|
||||
// 0 = 等待扫码, 3 = 已扫码, 1 = 已确认 (包含 str_nt_succ_token)
|
||||
return sendSuccess(res, result);
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to poll QR status: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { statusHelperSubscription } from '@/napcat-webui-backend';
|
||||
import { RequestHandler } from 'express';
|
||||
import { SystemStatus } from 'napcat-common/src/status-interface';
|
||||
export const StatusRealTimeHandler: RequestHandler = async (req, res) => {
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
const sendStatus = (status: SystemStatus) => {
|
||||
try {
|
||||
res.write(`data: ${JSON.stringify(status)}\n\n`);
|
||||
} catch (e) {
|
||||
console.error(`An error occurred when writing sendStatus data to client: ${e}`);
|
||||
}
|
||||
};
|
||||
statusHelperSubscription.on('statusUpdate', sendStatus);
|
||||
req.on('close', () => {
|
||||
statusHelperSubscription.off('statusUpdate', sendStatus);
|
||||
res.end();
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,422 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import { sendSuccess, sendError } from '@/napcat-webui-backend/src/utils/response';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as https from 'https';
|
||||
import compressing from 'compressing';
|
||||
import { webUiPathWrapper, webUiLogger } from '../../index';
|
||||
import { NapCatPathWrapper } from '@/napcat-common/src/path';
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { NapCatCoreWorkingEnv } from '@/napcat-webui-backend/src/types';
|
||||
import {
|
||||
getGitHubRelease,
|
||||
findAvailableDownloadUrl,
|
||||
} from '@/napcat-common/src/mirror';
|
||||
import { ILogWrapper } from '@/napcat-common/src/log-interface';
|
||||
|
||||
// 更新请求体接口
|
||||
interface UpdateRequestBody {
|
||||
/** 要更新到的版本 tag,如 "v4.9.9",不传则更新到最新版本 */
|
||||
targetVersion?: string;
|
||||
/** 是否强制更新(即使是降级也更新) */
|
||||
force?: boolean;
|
||||
/** 指定使用的镜像 */
|
||||
mirror?: string;
|
||||
}
|
||||
|
||||
// 更新配置文件接口
|
||||
interface UpdateConfig {
|
||||
version: string;
|
||||
updateTime: string;
|
||||
files: Array<{
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
backupPath?: string;
|
||||
}>;
|
||||
changelog?: string;
|
||||
}
|
||||
|
||||
// 需要跳过更新的文件
|
||||
const SKIP_UPDATE_FILES = [
|
||||
'NapCatWinBootMain.exe',
|
||||
'NapCatWinBootHook.dll',
|
||||
'quickLoginExample.bat',
|
||||
];
|
||||
|
||||
// 更新时若文件已存在则保留(不覆盖)的用户配置文件(使用相对路径精确匹配)
|
||||
// 这些文件保存了用户的自定义设置,更新时应予以保留;
|
||||
// 新增的配置项将在运行时通过 TypeBox schema 默认值自动填充,用户不会错过新配置选项。
|
||||
const PRESERVE_USER_CONFIG_RELATIVE_PATHS = new Set([
|
||||
path.normalize('config/napcat.json'),
|
||||
]);
|
||||
|
||||
/**
|
||||
* 递归扫描目录中的所有文件
|
||||
*/
|
||||
function scanFilesRecursively (dirPath: string, basePath: string = dirPath): Array<{
|
||||
sourcePath: string;
|
||||
relativePath: string;
|
||||
}> {
|
||||
const files: Array<{
|
||||
sourcePath: string;
|
||||
relativePath: string;
|
||||
}> = [];
|
||||
|
||||
const items = fs.readdirSync(dirPath);
|
||||
|
||||
for (const item of items) {
|
||||
const fullPath = path.join(dirPath, item);
|
||||
const relativePath = path.relative(basePath, fullPath);
|
||||
const stat = fs.lstatSync(fullPath);
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
// 跳过符号链接,避免潜在的路径穿越风险
|
||||
continue;
|
||||
} else if (stat.isDirectory()) {
|
||||
// 递归扫描子目录
|
||||
files.push(...scanFilesRecursively(fullPath, basePath));
|
||||
} else if (stat.isFile()) {
|
||||
files.push({
|
||||
sourcePath: fullPath,
|
||||
relativePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
// 注:镜像配置已迁移到 @/napcat-common/src/mirror 模块统一管理
|
||||
|
||||
/**
|
||||
* 下载文件(带进度和重试)
|
||||
*/
|
||||
async function downloadFile (url: string, dest: string): Promise<void> {
|
||||
webUiLogger?.log('[NapCat Update] Starting download from:', url);
|
||||
const file = fs.createWriteStream(dest);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = https.get(url, {
|
||||
headers: { 'User-Agent': 'NapCat-WebUI' },
|
||||
}, (res) => {
|
||||
webUiLogger?.log('[NapCat Update] Response status:', res.statusCode);
|
||||
webUiLogger?.log('[NapCat Update] Content-Type:', res.headers['content-type']);
|
||||
|
||||
if (res.statusCode === 302 || res.statusCode === 301) {
|
||||
webUiLogger?.log('[NapCat Update] Following redirect to:', res.headers.location);
|
||||
file.close();
|
||||
fs.unlinkSync(dest);
|
||||
downloadFile(res.headers.location!, dest).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
file.close();
|
||||
fs.unlinkSync(dest);
|
||||
reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
|
||||
return;
|
||||
}
|
||||
|
||||
res.pipe(file);
|
||||
file.on('finish', () => {
|
||||
file.close();
|
||||
webUiLogger?.log('[NapCat Update] Download completed');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', (err) => {
|
||||
webUiLogger?.logError('[NapCat Update] Download error:', err);
|
||||
file.close();
|
||||
fs.unlink(dest, () => { });
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
// 从请求体获取目标版本(可选)
|
||||
const { targetVersion, force, mirror } = req.body as UpdateRequestBody;
|
||||
|
||||
// 确定要下载的文件名
|
||||
const ReleaseName = WebUiDataRuntime.getWorkingEnv() === NapCatCoreWorkingEnv.Framework ? 'NapCat.Framework.zip' : 'NapCat.Shell.zip';
|
||||
|
||||
// 确定目标版本 tag
|
||||
// 如果指定了版本,使用指定版本;否则使用 'latest'
|
||||
const targetTag = targetVersion || 'latest';
|
||||
webUiLogger?.log(`[NapCat Update] Target version: ${targetTag}`);
|
||||
|
||||
// 检查是否是 action 临时版本
|
||||
const isActionVersion = targetTag.startsWith('action-');
|
||||
let downloadUrl: string;
|
||||
let actualVersion: string;
|
||||
|
||||
if (isActionVersion) {
|
||||
// 处理 action 临时版本
|
||||
const runId = parseInt(targetTag.replace('action-', ''));
|
||||
if (isNaN(runId)) {
|
||||
throw new Error(`Invalid action version format: ${targetTag}`);
|
||||
}
|
||||
|
||||
webUiLogger?.log(`[NapCat Update] Downloading action artifact from run: ${runId}`);
|
||||
|
||||
// 根据当前工作环境确定 artifact 名称
|
||||
const artifactName = ReleaseName.replace('.zip', ''); // NapCat.Framework 或 NapCat.Shell
|
||||
|
||||
// Action artifacts 通过 nightly.link 下载
|
||||
// 格式:https://nightly.link/{owner}/{repo}/actions/runs/{run_id}/{artifact_name}.zip
|
||||
const baseUrl = `https://nightly.link/NapNeko/NapCatQQ/actions/runs/${runId}/${artifactName}.zip`;
|
||||
actualVersion = targetTag;
|
||||
|
||||
webUiLogger?.log(`[NapCat Update] Action artifact URL: ${baseUrl}`);
|
||||
|
||||
// 使用 mirror 模块查找可用的 nightly.link 镜像
|
||||
try {
|
||||
downloadUrl = await findAvailableDownloadUrl(baseUrl, {
|
||||
validateContent: true,
|
||||
minFileSize: 1024 * 1024,
|
||||
timeout: 10000,
|
||||
customMirror: mirror,
|
||||
});
|
||||
webUiLogger?.log(`[NapCat Update] Using download URL: ${downloadUrl}`);
|
||||
} catch (_error) {
|
||||
// 如果镜像都不可用,直接使用原始 URL
|
||||
webUiLogger?.logWarn('[NapCat Update] All nightly.link mirrors failed, using original URL');
|
||||
downloadUrl = baseUrl;
|
||||
}
|
||||
} else {
|
||||
// 处理标准 release 版本
|
||||
// 使用 mirror 模块获取 release 信息(不依赖 API)
|
||||
// 通过 assetNames 参数直接构建下载 URL,避免调用 GitHub API
|
||||
const release = await getGitHubRelease('NapNeko', 'NapCatQQ', targetTag, {
|
||||
assetNames: [ReleaseName, 'NapCat.Framework.zip', 'NapCat.Shell.zip'],
|
||||
fetchChangelog: false, // 不需要 changelog,避免 API 调用
|
||||
mirror,
|
||||
});
|
||||
|
||||
const shellZipAsset = release.assets.find(asset => asset.name === ReleaseName);
|
||||
if (!shellZipAsset) {
|
||||
throw new Error(`未找到${ReleaseName}文件`);
|
||||
}
|
||||
|
||||
actualVersion = release.tag_name;
|
||||
|
||||
// 使用 mirror 模块查找可用的下载 URL
|
||||
// 启用内容验证,确保返回的是有效文件而非错误页面
|
||||
downloadUrl = await findAvailableDownloadUrl(shellZipAsset.browser_download_url, {
|
||||
validateContent: true, // 验证 Content-Type 和状态码
|
||||
minFileSize: 1024 * 1024, // 最小 1MB,确保不是错误页面
|
||||
timeout: 10000, // 10秒超时
|
||||
customMirror: mirror,
|
||||
});
|
||||
}
|
||||
|
||||
// 检查是否需要强制更新(降级警告)
|
||||
const currentVersion = WebUiDataRuntime.GetNapCatVersion();
|
||||
webUiLogger?.log(`[NapCat Update] Current version: ${currentVersion}, Target version: ${actualVersion}`);
|
||||
|
||||
if (!force && currentVersion && !isActionVersion) {
|
||||
// 简单的版本比较(可选的降级保护)
|
||||
const parseVersion = (v: string): [number, number, number] => {
|
||||
const match = v.match(/^v?(\d+)\.(\d+)\.(\d+)/);
|
||||
if (!match) return [0, 0, 0];
|
||||
return [parseInt(match[1] || '0'), parseInt(match[2] || '0'), parseInt(match[3] || '0')];
|
||||
};
|
||||
const [currMajor, currMinor, currPatch] = parseVersion(currentVersion);
|
||||
const [targetMajor, targetMinor, targetPatch] = parseVersion(actualVersion);
|
||||
|
||||
const isDowngrade =
|
||||
targetMajor < currMajor ||
|
||||
(targetMajor === currMajor && targetMinor < currMinor) ||
|
||||
(targetMajor === currMajor && targetMinor === currMinor && targetPatch < currPatch);
|
||||
|
||||
if (isDowngrade) {
|
||||
webUiLogger?.log(`[NapCat Update] Downgrade from ${currentVersion} to ${actualVersion}, force=${force}`);
|
||||
// 不阻止降级,只是记录日志
|
||||
}
|
||||
}
|
||||
|
||||
webUiLogger?.log(`[NapCat Update] Updating to version: ${actualVersion}`);
|
||||
|
||||
// 创建临时目录
|
||||
const tempDir = path.join(webUiPathWrapper.binaryPath, './temp');
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
}
|
||||
|
||||
webUiLogger?.log(`[NapCat Update] Using download URL: ${downloadUrl}`);
|
||||
|
||||
// 下载zip
|
||||
const zipPath = path.join(tempDir, 'napcat-latest.zip');
|
||||
webUiLogger?.log('[NapCat Update] Saving to:', zipPath);
|
||||
await downloadFile(downloadUrl, zipPath);
|
||||
|
||||
// 检查文件大小
|
||||
const stats = fs.statSync(zipPath);
|
||||
webUiLogger?.log('[NapCat Update] Downloaded file size:', stats.size, 'bytes');
|
||||
|
||||
// 解压到临时目录
|
||||
const extractPath = path.join(tempDir, 'napcat-extract');
|
||||
webUiLogger?.log('[NapCat Update] Extracting to:', extractPath);
|
||||
await compressing.zip.uncompress(zipPath, extractPath);
|
||||
|
||||
// 获取解压后的实际内容目录(NapCat.Shell.zip直接包含文件,无额外根目录)
|
||||
const sourcePath = extractPath;
|
||||
|
||||
// 执行更新操作
|
||||
try {
|
||||
// 扫描需要更新的文件
|
||||
const allFiles = scanFilesRecursively(sourcePath);
|
||||
const failedFiles: Array<{
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
}> = [];
|
||||
|
||||
// 先尝试直接替换文件
|
||||
const resolvedBinaryPath = path.resolve(webUiPathWrapper.binaryPath);
|
||||
for (const fileInfo of allFiles) {
|
||||
// 防止路径穿越攻击:确保目标路径严格位于 binaryPath 子目录内
|
||||
const targetFilePath = path.resolve(webUiPathWrapper.binaryPath, fileInfo.relativePath);
|
||||
if (!targetFilePath.startsWith(resolvedBinaryPath + path.sep)) {
|
||||
webUiLogger?.logWarn(`[NapCat Update] Skipping suspicious path: ${fileInfo.relativePath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedRelativePath = path.normalize(fileInfo.relativePath);
|
||||
|
||||
// 跳过指定的文件
|
||||
if (SKIP_UPDATE_FILES.includes(path.basename(fileInfo.relativePath))) {
|
||||
webUiLogger?.log(`[NapCat Update] Skipping update for ${fileInfo.relativePath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 保留已存在的用户配置文件,避免覆盖用户设置
|
||||
if (PRESERVE_USER_CONFIG_RELATIVE_PATHS.has(normalizedRelativePath) && fs.existsSync(targetFilePath)) {
|
||||
webUiLogger?.log(`[NapCat Update] Preserving existing user config: ${fileInfo.relativePath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// 确保目标目录存在
|
||||
const targetDir = path.dirname(targetFilePath);
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 尝试直接替换文件
|
||||
if (fs.existsSync(targetFilePath)) {
|
||||
fs.unlinkSync(targetFilePath); // 删除旧文件
|
||||
}
|
||||
fs.copyFileSync(fileInfo.sourcePath, targetFilePath);
|
||||
} catch (error) {
|
||||
// 如果替换失败,添加到失败列表
|
||||
webUiLogger?.logError(`[NapCat Update] Failed to update ${targetFilePath}, will retry on next startup:`, error);
|
||||
failedFiles.push({
|
||||
sourcePath: fileInfo.sourcePath,
|
||||
targetPath: targetFilePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有替换失败的文件,创建更新配置文件
|
||||
if (failedFiles.length > 0) {
|
||||
const updateConfig: UpdateConfig = {
|
||||
version: actualVersion,
|
||||
updateTime: new Date().toISOString(),
|
||||
files: failedFiles,
|
||||
changelog: '',
|
||||
};
|
||||
|
||||
// 保存更新配置文件
|
||||
const configPath = path.join(webUiPathWrapper.configPath, 'napcat-update.json');
|
||||
fs.writeFileSync(configPath, JSON.stringify(updateConfig, null, 2));
|
||||
webUiLogger?.log(`[NapCat Update] Update config saved for ${failedFiles.length} failed files: ${configPath}`);
|
||||
}
|
||||
|
||||
// 发送成功响应
|
||||
const message = failedFiles.length > 0
|
||||
? `更新完成,重启应用以应用剩余${failedFiles.length}个文件的更新`
|
||||
: '更新完成';
|
||||
sendSuccess(res, {
|
||||
status: 'completed',
|
||||
message,
|
||||
newVersion: actualVersion,
|
||||
failedFilesCount: failedFiles.length,
|
||||
});
|
||||
} catch (error) {
|
||||
webUiLogger?.logError('[NapCat Update] 更新失败:', error);
|
||||
sendError(res, '更新失败: ' + (error instanceof Error ? error.message : '未知错误'));
|
||||
}
|
||||
} catch (error: any) {
|
||||
webUiLogger?.logError('[NapCat Update] 更新失败:', error);
|
||||
sendError(res, '更新失败: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 注:getLatestRelease 已移除,现在使用 mirror 模块的 getGitHubRelease
|
||||
|
||||
/**
|
||||
* 应用待处理的更新(在应用启动时调用)
|
||||
*/
|
||||
export async function applyPendingUpdates (webUiPathWrapper: NapCatPathWrapper, logger: ILogWrapper): Promise<void> {
|
||||
const configPath = path.join(webUiPathWrapper.configPath, 'napcat-update.json');
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
// logger.log('[NapCat Update] No pending updates found');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.log('[NapCat Update] Applying pending updates...');
|
||||
const updateConfig: UpdateConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
|
||||
const remainingFiles: Array<{
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
}> = [];
|
||||
|
||||
for (const file of updateConfig.files) {
|
||||
try {
|
||||
// 检查源文件是否存在
|
||||
if (!fs.existsSync(file.sourcePath)) {
|
||||
logger.logWarn(`[NapCat Update] Source file not found: ${file.sourcePath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 确保目标目录存在
|
||||
const targetDir = path.dirname(file.targetPath);
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 尝试替换文件
|
||||
if (fs.existsSync(file.targetPath)) {
|
||||
fs.unlinkSync(file.targetPath); // 删除旧文件
|
||||
}
|
||||
fs.copyFileSync(file.sourcePath, file.targetPath);
|
||||
logger.log(`[NapCat Update] Updated ${path.basename(file.targetPath)} on startup`);
|
||||
} catch (error) {
|
||||
logger.logError(`[NapCat Update] Failed to update ${file.targetPath} on startup:`, error);
|
||||
// 如果仍然失败,保留在列表中
|
||||
remainingFiles.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果还有失败的文件,更新配置文件
|
||||
if (remainingFiles.length > 0) {
|
||||
const updatedConfig: UpdateConfig = {
|
||||
...updateConfig,
|
||||
files: remainingFiles,
|
||||
};
|
||||
fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2));
|
||||
logger.log(`[NapCat Update] ${remainingFiles.length} files still pending update`);
|
||||
} else {
|
||||
// 所有文件都成功更新,删除配置文件
|
||||
fs.unlinkSync(configPath);
|
||||
logger.log('[NapCat Update] All pending updates applied successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.logError('[NapCat Update] Failed to apply pending updates:', error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import { WebUiConfig, webUiPathWrapper } from '@/napcat-webui-backend/index';
|
||||
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||
import { isEmpty } from '@/napcat-webui-backend/src/utils/check';
|
||||
import { existsSync, promises as fsProm } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// 获取WebUI基础配置
|
||||
export const GetWebUIConfigHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const config = await WebUiConfig.GetWebUIConfig();
|
||||
return sendSuccess(res, {
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
loginRate: config.loginRate,
|
||||
disableWebUI: config.disableWebUI,
|
||||
accessControlMode: config.accessControlMode || 'none',
|
||||
ipWhitelist: config.ipWhitelist || [],
|
||||
ipBlacklist: config.ipBlacklist || [],
|
||||
enableXForwardedFor: config.enableXForwardedFor || false,
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
return sendError(res, `获取WebUI配置失败: ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取是否禁用WebUI
|
||||
export const GetDisableWebUIHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const disable = await WebUiConfig.GetDisableWebUI();
|
||||
return sendSuccess(res, disable);
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
return sendError(res, `获取WebUI禁用状态失败: ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 更新是否禁用WebUI
|
||||
export const UpdateDisableWebUIHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { disable } = req.body;
|
||||
|
||||
if (typeof disable !== 'boolean') {
|
||||
return sendError(res, 'disable参数必须是布尔值');
|
||||
}
|
||||
|
||||
await WebUiConfig.UpdateDisableWebUI(disable);
|
||||
return sendSuccess(res, null);
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
return sendError(res, `更新WebUI禁用状态失败: ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取当前客户端IP
|
||||
export const GetClientIPHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const config = await WebUiConfig.GetWebUIConfig();
|
||||
|
||||
// 根据配置决定如何获取客户端IP(与 CORS 中间件逻辑一致)
|
||||
let clientIP: string;
|
||||
if (config.enableXForwardedFor) {
|
||||
const forwardedFor = req.headers['x-forwarded-for'];
|
||||
if (typeof forwardedFor === 'string') {
|
||||
clientIP = forwardedFor.split(',')[0]?.trim() || '';
|
||||
} else if (Array.isArray(forwardedFor) && forwardedFor.length > 0) {
|
||||
clientIP = forwardedFor[0] || '';
|
||||
} else {
|
||||
clientIP = req.ip || req.socket.remoteAddress || '';
|
||||
}
|
||||
} else {
|
||||
clientIP = req.ip || req.socket.remoteAddress || '';
|
||||
}
|
||||
|
||||
// 标准化 IP(移除 IPv4-mapped IPv6 前缀,但保留纯 IPv6)
|
||||
let normalizedIP = clientIP;
|
||||
if (clientIP.startsWith('::ffff:')) {
|
||||
const ipv4 = clientIP.substring(7);
|
||||
// 检查是否是有效的 IPv4
|
||||
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(ipv4)) {
|
||||
normalizedIP = ipv4;
|
||||
}
|
||||
}
|
||||
|
||||
return sendSuccess(res, { ip: normalizedIP });
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
return sendError(res, `获取客户端IP失败: ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 更新WebUI基础配置
|
||||
export const UpdateWebUIConfigHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { host, port, loginRate, disableWebUI, accessControlMode, ipWhitelist, ipBlacklist, enableXForwardedFor } = req.body;
|
||||
|
||||
const updateConfig: any = {};
|
||||
|
||||
if (host !== undefined) {
|
||||
if (isEmpty(host)) {
|
||||
return sendError(res, 'host不能为空');
|
||||
}
|
||||
updateConfig.host = host;
|
||||
}
|
||||
|
||||
if (port !== undefined) {
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
return sendError(res, 'port必须是1-65535之间的整数');
|
||||
}
|
||||
updateConfig.port = port;
|
||||
}
|
||||
|
||||
if (loginRate !== undefined) {
|
||||
if (!Number.isInteger(loginRate) || loginRate < 1) {
|
||||
return sendError(res, 'loginRate必须是大于0的整数');
|
||||
}
|
||||
updateConfig.loginRate = loginRate;
|
||||
}
|
||||
|
||||
if (disableWebUI !== undefined) {
|
||||
if (typeof disableWebUI !== 'boolean') {
|
||||
return sendError(res, 'disableWebUI必须是布尔值');
|
||||
}
|
||||
updateConfig.disableWebUI = disableWebUI;
|
||||
}
|
||||
|
||||
if (accessControlMode !== undefined) {
|
||||
if (!['none', 'whitelist', 'blacklist'].includes(accessControlMode)) {
|
||||
return sendError(res, 'accessControlMode必须是none、whitelist或blacklist');
|
||||
}
|
||||
updateConfig.accessControlMode = accessControlMode;
|
||||
}
|
||||
|
||||
if (ipWhitelist !== undefined) {
|
||||
if (!Array.isArray(ipWhitelist)) {
|
||||
return sendError(res, 'ipWhitelist必须是数组');
|
||||
}
|
||||
updateConfig.ipWhitelist = ipWhitelist;
|
||||
}
|
||||
|
||||
if (ipBlacklist !== undefined) {
|
||||
if (!Array.isArray(ipBlacklist)) {
|
||||
return sendError(res, 'ipBlacklist必须是数组');
|
||||
}
|
||||
updateConfig.ipBlacklist = ipBlacklist;
|
||||
}
|
||||
|
||||
if (enableXForwardedFor !== undefined) {
|
||||
if (typeof enableXForwardedFor !== 'boolean') {
|
||||
return sendError(res, 'enableXForwardedFor必须是布尔值');
|
||||
}
|
||||
updateConfig.enableXForwardedFor = enableXForwardedFor;
|
||||
}
|
||||
|
||||
await WebUiConfig.UpdateWebUIConfig(updateConfig);
|
||||
return sendSuccess(res, null);
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
return sendError(res, `更新WebUI配置失败: ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取SSL证书状态
|
||||
export const GetSSLStatusHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const certPath = join(webUiPathWrapper.configPath, 'cert.pem');
|
||||
const keyPath = join(webUiPathWrapper.configPath, 'key.pem');
|
||||
|
||||
const certExists = existsSync(certPath);
|
||||
const keyExists = existsSync(keyPath);
|
||||
|
||||
let certContent = '';
|
||||
let keyContent = '';
|
||||
|
||||
if (certExists) {
|
||||
certContent = await fsProm.readFile(certPath, 'utf-8');
|
||||
}
|
||||
if (keyExists) {
|
||||
keyContent = await fsProm.readFile(keyPath, 'utf-8');
|
||||
}
|
||||
|
||||
return sendSuccess(res, {
|
||||
enabled: certExists && keyExists,
|
||||
certExists,
|
||||
keyExists,
|
||||
certContent,
|
||||
keyContent,
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
return sendError(res, `获取SSL状态失败: ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 保存SSL证书(通过文本内容)
|
||||
export const UploadSSLCertHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { cert, key } = req.body;
|
||||
|
||||
if (isEmpty(cert) || isEmpty(key)) {
|
||||
return sendError(res, 'cert和key内容不能为空');
|
||||
}
|
||||
|
||||
// 简单验证证书格式
|
||||
if (!cert.includes('-----BEGIN CERTIFICATE-----') || !cert.includes('-----END CERTIFICATE-----')) {
|
||||
return sendError(res, 'cert格式不正确,应为PEM格式的证书');
|
||||
}
|
||||
|
||||
if (!key.includes('-----BEGIN') || !key.includes('KEY-----')) {
|
||||
return sendError(res, 'key格式不正确,应为PEM格式的私钥');
|
||||
}
|
||||
|
||||
const certPath = join(webUiPathWrapper.configPath, 'cert.pem');
|
||||
const keyPath = join(webUiPathWrapper.configPath, 'key.pem');
|
||||
|
||||
await fsProm.writeFile(certPath, cert, 'utf-8');
|
||||
await fsProm.writeFile(keyPath, key, 'utf-8');
|
||||
|
||||
return sendSuccess(res, { message: 'SSL证书保存成功,重启后生效' });
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
return sendError(res, `保存SSL证书失败: ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除SSL证书
|
||||
export const DeleteSSLCertHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const certPath = join(webUiPathWrapper.configPath, 'cert.pem');
|
||||
const keyPath = join(webUiPathWrapper.configPath, 'key.pem');
|
||||
|
||||
if (existsSync(certPath)) {
|
||||
await fsProm.unlink(certPath);
|
||||
}
|
||||
if (existsSync(keyPath)) {
|
||||
await fsProm.unlink(keyPath);
|
||||
}
|
||||
|
||||
return sendSuccess(res, { message: 'SSL证书已删除,重启后生效' });
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
return sendError(res, `删除SSL证书失败: ${msg}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,337 @@
|
||||
/* eslint-disable no-undef */
|
||||
/**
|
||||
* NapCat WebUI Service Worker
|
||||
*
|
||||
* 路由缓存策略设计:
|
||||
*
|
||||
* 【永不缓存 - Network Only】
|
||||
* - /api/* WebUI API
|
||||
* - /plugin/:id/api/* 插件 API
|
||||
* - /files/theme.css 动态主题 CSS
|
||||
* - /webui/fonts/CustomFont.woff 用户自定义字体
|
||||
* - WebSocket / SSE 连接
|
||||
*
|
||||
* 【强缓存 - Cache First】
|
||||
* - /webui/assets/* 前端静态资源(带 hash)
|
||||
* - /webui/fonts/* 内置字体(排除 CustomFont)
|
||||
* - q1.qlogo.cn QQ 头像
|
||||
*
|
||||
* 【网络优先 - Network First】
|
||||
* - /webui/* (HTML 导航) SPA 页面
|
||||
* - /plugin/:id/page/* 插件页面
|
||||
* - /plugin/:id/files/* 插件文件系统静态资源
|
||||
*
|
||||
* 【后台更新 - Stale-While-Revalidate】
|
||||
* - /plugin/:id/mem/* 插件内存静态资源
|
||||
*/
|
||||
|
||||
const CACHE_NAME = 'napcat-webui-v{{VERSION}}';
|
||||
|
||||
// 缓存配置
|
||||
const CACHE_CONFIG = {
|
||||
// 静态资源缓存最大条目数
|
||||
MAX_STATIC_ENTRIES: 200,
|
||||
// QQ 头像缓存最大条目数
|
||||
MAX_AVATAR_ENTRIES: 100,
|
||||
// 插件资源缓存最大条目数
|
||||
MAX_PLUGIN_ENTRIES: 50,
|
||||
};
|
||||
|
||||
// ============ 路由匹配辅助函数 ============
|
||||
|
||||
/**
|
||||
* 检查是否为永不缓存的请求
|
||||
*/
|
||||
function isNeverCache (url, request) {
|
||||
// WebUI API
|
||||
if (url.pathname.startsWith('/api/')) return true;
|
||||
|
||||
// 插件 API: /plugin/:id/api/*
|
||||
if (/^\/plugin\/[^/]+\/api(\/|$)/.test(url.pathname)) return true;
|
||||
|
||||
// 动态主题 CSS
|
||||
if (url.pathname === '/files/theme.css' || url.pathname.endsWith('/files/theme.css')) return true;
|
||||
|
||||
// 用户自定义字体
|
||||
if (url.pathname.includes('/webui/fonts/CustomFont.woff')) return true;
|
||||
|
||||
// WebSocket 升级请求
|
||||
if (request.headers.get('Upgrade') === 'websocket') return true;
|
||||
|
||||
// SSE 请求
|
||||
if (request.headers.get('Accept') === 'text/event-stream') return true;
|
||||
|
||||
// Socket 相关
|
||||
if (url.pathname.includes('/socket')) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为 WebUI 静态资源(强缓存)
|
||||
*/
|
||||
function isWebUIStaticAsset (url) {
|
||||
// /webui/assets/* - 前端构建产物(带 hash)
|
||||
if (url.pathname.startsWith('/webui/assets/')) return true;
|
||||
|
||||
// /webui/fonts/* - 内置字体(排除 CustomFont)
|
||||
if (url.pathname.startsWith('/webui/fonts/') &&
|
||||
!url.pathname.includes('CustomFont.woff')) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为外部头像(强缓存)
|
||||
*/
|
||||
function isQLogoAvatar (url) {
|
||||
return url.hostname === 'q1.qlogo.cn' || url.hostname === 'q2.qlogo.cn';
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为插件文件系统静态资源(网络优先)
|
||||
*/
|
||||
function isPluginStaticFiles (url) {
|
||||
// /plugin/:id/files/*
|
||||
return /^\/plugin\/[^/]+\/files(\/|$)/.test(url.pathname);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为插件内存静态资源(Stale-While-Revalidate)
|
||||
*/
|
||||
function isPluginMemoryAsset (url) {
|
||||
// /plugin/:id/mem/*
|
||||
return /^\/plugin\/[^/]+\/mem(\/|$)/.test(url.pathname);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为插件页面(Network First)
|
||||
*/
|
||||
function isPluginPage (url) {
|
||||
// /plugin/:id/page/*
|
||||
return /^\/plugin\/[^/]+\/page(\/|$)/.test(url.pathname);
|
||||
}
|
||||
|
||||
// ============ 缓存管理函数 ============
|
||||
|
||||
/**
|
||||
* 限制缓存条目数量
|
||||
*/
|
||||
async function trimCache (cacheName, maxEntries) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const keys = await cache.keys();
|
||||
if (keys.length > maxEntries) {
|
||||
// 删除最早的条目
|
||||
const deleteCount = keys.length - maxEntries;
|
||||
for (let i = 0; i < deleteCount; i++) {
|
||||
await cache.delete(keys[i]);
|
||||
}
|
||||
console.log(`[SW] Trimmed ${deleteCount} entries from cache`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按类型获取缓存限制
|
||||
*/
|
||||
function getCacheLimitForRequest (url) {
|
||||
if (isQLogoAvatar(url)) return CACHE_CONFIG.MAX_AVATAR_ENTRIES;
|
||||
if (isPluginStaticFiles(url) || isPluginMemoryAsset(url)) return CACHE_CONFIG.MAX_PLUGIN_ENTRIES;
|
||||
return CACHE_CONFIG.MAX_STATIC_ENTRIES;
|
||||
}
|
||||
|
||||
// ============ Service Worker 生命周期 ============
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('[SW] Installing new version:', CACHE_NAME);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('[SW] Activating new version:', CACHE_NAME);
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
// 清理所有旧版本缓存
|
||||
const cacheNames = await caches.keys();
|
||||
await Promise.all(
|
||||
cacheNames.map((cacheName) => {
|
||||
if (cacheName.startsWith('napcat-webui-') && cacheName !== CACHE_NAME) {
|
||||
console.log('[SW] Deleting old cache:', cacheName);
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
);
|
||||
// 立即接管所有客户端
|
||||
await self.clients.claim();
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
// ============ 请求拦截 ============
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
const request = event.request;
|
||||
|
||||
// 1. 永不缓存的请求 - Network Only
|
||||
if (isNeverCache(url, request)) {
|
||||
// 不调用 respondWith,让请求直接穿透到网络
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. WebUI 静态资源 - Cache First
|
||||
if (isWebUIStaticAsset(url)) {
|
||||
event.respondWith(cacheFirst(request, url));
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. QQ 头像 - Cache First(支持 opaque response)
|
||||
if (isQLogoAvatar(url)) {
|
||||
event.respondWith(cacheFirstWithOpaque(request, url));
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 插件文件系统静态资源 - Network First
|
||||
if (isPluginStaticFiles(url)) {
|
||||
event.respondWith(networkFirst(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 插件内存静态资源 - Stale-While-Revalidate
|
||||
if (isPluginMemoryAsset(url)) {
|
||||
event.respondWith(staleWhileRevalidate(request, url));
|
||||
return;
|
||||
}
|
||||
|
||||
// 6. 插件页面 - Network First
|
||||
if (isPluginPage(url)) {
|
||||
event.respondWith(networkFirst(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// 7. HTML 导航请求 - Network First
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(networkFirst(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// 8. 其他同源请求 - Network Only(避免意外缓存)
|
||||
if (url.origin === self.location.origin) {
|
||||
// 不缓存,直接穿透
|
||||
|
||||
}
|
||||
|
||||
// 9. 其他外部请求 - Network Only
|
||||
});
|
||||
|
||||
// ============ 缓存策略实现 ============
|
||||
|
||||
/**
|
||||
* Cache First 策略
|
||||
* 优先从缓存返回,缓存未命中则从网络获取并缓存
|
||||
*/
|
||||
async function cacheFirst (request, url) {
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
try {
|
||||
const networkResponse = await fetch(request);
|
||||
if (networkResponse && networkResponse.status === 200) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
cache.put(request, networkResponse.clone());
|
||||
// 异步清理缓存
|
||||
trimCache(CACHE_NAME, getCacheLimitForRequest(url));
|
||||
}
|
||||
return networkResponse;
|
||||
} catch (error) {
|
||||
console.error('[SW] Cache First fetch failed:', error);
|
||||
return new Response('Network error', { status: 503 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache First 策略(支持 opaque response,用于跨域头像)
|
||||
*/
|
||||
async function cacheFirstWithOpaque (request, url) {
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
try {
|
||||
const networkResponse = await fetch(request);
|
||||
// opaque response 的 status 是 0,但仍可缓存
|
||||
const isValidResponse = networkResponse && (
|
||||
networkResponse.status === 200 ||
|
||||
networkResponse.type === 'opaque'
|
||||
);
|
||||
|
||||
if (isValidResponse) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
cache.put(request, networkResponse.clone());
|
||||
trimCache(CACHE_NAME, getCacheLimitForRequest(url));
|
||||
}
|
||||
return networkResponse;
|
||||
} catch (error) {
|
||||
console.error('[SW] Cache First (opaque) fetch failed:', error);
|
||||
return new Response('Network error', { status: 503 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Network First 策略
|
||||
* 优先从网络获取,网络失败则返回缓存
|
||||
*/
|
||||
async function networkFirst (request) {
|
||||
try {
|
||||
const networkResponse = await fetch(request);
|
||||
if (networkResponse && networkResponse.status === 200) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
cache.put(request, networkResponse.clone());
|
||||
}
|
||||
return networkResponse;
|
||||
} catch (error) {
|
||||
console.log('[SW] Network First: network failed, trying cache');
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
return new Response('Offline', { status: 503 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stale-While-Revalidate 策略
|
||||
* 立即返回缓存(如果有),同时后台更新缓存
|
||||
*/
|
||||
async function staleWhileRevalidate (request, url) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
const cachedResponse = await cache.match(request);
|
||||
|
||||
// 后台刷新缓存
|
||||
const fetchPromise = fetch(request).then((networkResponse) => {
|
||||
if (networkResponse && networkResponse.status === 200) {
|
||||
cache.put(request, networkResponse.clone());
|
||||
trimCache(CACHE_NAME, getCacheLimitForRequest(url));
|
||||
}
|
||||
return networkResponse;
|
||||
}).catch((error) => {
|
||||
console.log('[SW] SWR background fetch failed:', error);
|
||||
return null;
|
||||
});
|
||||
|
||||
// 如果有缓存,立即返回缓存
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
// 没有缓存,等待网络
|
||||
const networkResponse = await fetchPromise;
|
||||
if (networkResponse) {
|
||||
return networkResponse;
|
||||
}
|
||||
|
||||
return new Response('Network error', { status: 503 });
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export enum HttpStatusCode {
|
||||
OK = 200,
|
||||
BadRequest = 400,
|
||||
Unauthorized = 401,
|
||||
Forbidden = 403,
|
||||
NotFound = 404,
|
||||
InternalServerError = 500,
|
||||
}
|
||||
|
||||
export enum ResponseCode {
|
||||
Success = 0,
|
||||
Error = -1,
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import store from 'napcat-common/src/store';
|
||||
import { napCatVersion } from 'napcat-common/src/version';
|
||||
import { NapCatCoreWorkingEnv, type LoginRuntimeType, type QQLoginPhase } from '../types';
|
||||
|
||||
const LoginRuntime: LoginRuntimeType = {
|
||||
workingEnv: NapCatCoreWorkingEnv.Unknown,
|
||||
LoginCurrentTime: Date.now(),
|
||||
LoginCurrentRate: 0,
|
||||
QQLoginStatus: false, // 已实现 但太傻了 得去那边注册个回调刷新
|
||||
QQLoginPhase: 'waiting_qrcode',
|
||||
QQQRCodeURL: '',
|
||||
QQLoginUin: '',
|
||||
QQLoginInfo: {
|
||||
uid: '',
|
||||
uin: '',
|
||||
nick: '',
|
||||
},
|
||||
QQLoginError: '',
|
||||
QQVersion: 'unknown',
|
||||
QQDataPath: '',
|
||||
OneBotContext: null,
|
||||
onQQLoginStatusChange: async (status: boolean) => {
|
||||
LoginRuntime.QQLoginStatus = status;
|
||||
},
|
||||
onWebUiTokenChange: async (_token: string) => {
|
||||
|
||||
},
|
||||
onRefreshQRCode: async () => {
|
||||
// 默认空实现,由 shell 注册真实回调
|
||||
},
|
||||
NapCatHelper: {
|
||||
onOB11ConfigChanged: async () => {
|
||||
|
||||
},
|
||||
onQuickLoginRequested: async () => {
|
||||
return { result: false, message: '' };
|
||||
},
|
||||
onPasswordLoginRequested: async () => {
|
||||
return { result: false, message: '密码登录功能未初始化' };
|
||||
},
|
||||
onCaptchaLoginRequested: async () => {
|
||||
return { result: false, message: '验证码登录功能未初始化' };
|
||||
},
|
||||
onNewDeviceLoginRequested: async () => {
|
||||
return { result: false, message: '新设备登录功能未初始化' };
|
||||
},
|
||||
onRestartProcessRequested: async () => {
|
||||
return { result: false, message: '重启功能未初始化' };
|
||||
},
|
||||
QQLoginList: [],
|
||||
NewQQLoginList: [],
|
||||
},
|
||||
NapCatVersion: napCatVersion,
|
||||
WebUiConfigQuickFunction: async () => {
|
||||
|
||||
},
|
||||
};
|
||||
export const WebUiDataRuntime = {
|
||||
setWorkingEnv (env: NapCatCoreWorkingEnv): void {
|
||||
LoginRuntime.workingEnv = env;
|
||||
},
|
||||
getWorkingEnv (): NapCatCoreWorkingEnv {
|
||||
return LoginRuntime.workingEnv;
|
||||
},
|
||||
setWebUiTokenChangeCallback (func: (token: string) => Promise<void>): void {
|
||||
LoginRuntime.onWebUiTokenChange = func;
|
||||
},
|
||||
getWebUiTokenChangeCallback (): (token: string) => Promise<void> {
|
||||
return LoginRuntime.onWebUiTokenChange;
|
||||
},
|
||||
checkLoginRate (ip: string, RateLimit: number): boolean {
|
||||
const key = `login_rate:${ip}`;
|
||||
const count = store.get<number>(key) || 0;
|
||||
|
||||
if (count === 0) {
|
||||
// 第一次访问,设置计数器为1,并设置60秒过期
|
||||
store.set(key, 1, 60);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (count >= RateLimit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
store.set(key, count + 1);
|
||||
return true;
|
||||
},
|
||||
|
||||
getQQLoginStatus (): LoginRuntimeType['QQLoginStatus'] {
|
||||
return LoginRuntime.QQLoginStatus;
|
||||
},
|
||||
|
||||
setQQLoginCallback (func: (status: boolean) => Promise<void>): void {
|
||||
LoginRuntime.onQQLoginStatusChange = func;
|
||||
},
|
||||
|
||||
getQQLoginCallback (): (status: boolean) => Promise<void> {
|
||||
return LoginRuntime.onQQLoginStatusChange;
|
||||
},
|
||||
|
||||
setQQLoginStatus (status: LoginRuntimeType['QQLoginStatus']): void {
|
||||
LoginRuntime.QQLoginStatus = status;
|
||||
},
|
||||
|
||||
setQQLoginPhase (phase: QQLoginPhase): void {
|
||||
LoginRuntime.QQLoginPhase = phase;
|
||||
},
|
||||
|
||||
getQQLoginPhase (): QQLoginPhase {
|
||||
return LoginRuntime.QQLoginPhase;
|
||||
},
|
||||
|
||||
setQQLoginQrcodeURL (url: LoginRuntimeType['QQQRCodeURL']): void {
|
||||
LoginRuntime.QQQRCodeURL = url;
|
||||
},
|
||||
|
||||
getQQLoginQrcodeURL (): LoginRuntimeType['QQQRCodeURL'] {
|
||||
return LoginRuntime.QQQRCodeURL;
|
||||
},
|
||||
|
||||
setQQLoginInfo (info: LoginRuntimeType['QQLoginInfo']): void {
|
||||
LoginRuntime.QQLoginInfo = info;
|
||||
LoginRuntime.QQLoginUin = info.uin.toString();
|
||||
},
|
||||
|
||||
getQQLoginInfo (): LoginRuntimeType['QQLoginInfo'] {
|
||||
return LoginRuntime.QQLoginInfo;
|
||||
},
|
||||
|
||||
getQQLoginUin (): LoginRuntimeType['QQLoginUin'] {
|
||||
return LoginRuntime.QQLoginUin;
|
||||
},
|
||||
|
||||
getQQQuickLoginList (): LoginRuntimeType['NapCatHelper']['QQLoginList'] {
|
||||
return LoginRuntime.NapCatHelper.QQLoginList;
|
||||
},
|
||||
|
||||
setQQQuickLoginList (list: LoginRuntimeType['NapCatHelper']['QQLoginList']): void {
|
||||
LoginRuntime.NapCatHelper.QQLoginList = list;
|
||||
},
|
||||
|
||||
getQQNewLoginList (): LoginRuntimeType['NapCatHelper']['NewQQLoginList'] {
|
||||
return LoginRuntime.NapCatHelper.NewQQLoginList;
|
||||
},
|
||||
|
||||
setQQNewLoginList (list: LoginRuntimeType['NapCatHelper']['NewQQLoginList']): void {
|
||||
LoginRuntime.NapCatHelper.NewQQLoginList = list;
|
||||
},
|
||||
|
||||
setQuickLoginCall (func: LoginRuntimeType['NapCatHelper']['onQuickLoginRequested']): void {
|
||||
LoginRuntime.NapCatHelper.onQuickLoginRequested = func;
|
||||
},
|
||||
|
||||
requestQuickLogin: function (uin) {
|
||||
return LoginRuntime.NapCatHelper.onQuickLoginRequested(uin);
|
||||
} as LoginRuntimeType['NapCatHelper']['onQuickLoginRequested'],
|
||||
|
||||
setPasswordLoginCall (func: LoginRuntimeType['NapCatHelper']['onPasswordLoginRequested']): void {
|
||||
LoginRuntime.NapCatHelper.onPasswordLoginRequested = func;
|
||||
},
|
||||
|
||||
requestPasswordLogin: function (uin: string, passwordMd5: string) {
|
||||
return LoginRuntime.NapCatHelper.onPasswordLoginRequested(uin, passwordMd5);
|
||||
} as LoginRuntimeType['NapCatHelper']['onPasswordLoginRequested'],
|
||||
|
||||
setCaptchaLoginCall (func: LoginRuntimeType['NapCatHelper']['onCaptchaLoginRequested']): void {
|
||||
LoginRuntime.NapCatHelper.onCaptchaLoginRequested = func;
|
||||
},
|
||||
|
||||
requestCaptchaLogin: function (uin: string, passwordMd5: string, ticket: string, randstr: string, sid: string) {
|
||||
return LoginRuntime.NapCatHelper.onCaptchaLoginRequested(uin, passwordMd5, ticket, randstr, sid);
|
||||
} as LoginRuntimeType['NapCatHelper']['onCaptchaLoginRequested'],
|
||||
|
||||
setNewDeviceLoginCall (func: LoginRuntimeType['NapCatHelper']['onNewDeviceLoginRequested']): void {
|
||||
LoginRuntime.NapCatHelper.onNewDeviceLoginRequested = func;
|
||||
},
|
||||
|
||||
requestNewDeviceLogin: function (uin: string, passwordMd5: string, newDevicePullQrCodeSig: string) {
|
||||
return LoginRuntime.NapCatHelper.onNewDeviceLoginRequested(uin, passwordMd5, newDevicePullQrCodeSig);
|
||||
} as LoginRuntimeType['NapCatHelper']['onNewDeviceLoginRequested'],
|
||||
|
||||
setOnOB11ConfigChanged (func: LoginRuntimeType['NapCatHelper']['onOB11ConfigChanged']): void {
|
||||
LoginRuntime.NapCatHelper.onOB11ConfigChanged = func;
|
||||
},
|
||||
|
||||
setOB11Config: function (ob11) {
|
||||
return LoginRuntime.NapCatHelper.onOB11ConfigChanged(ob11);
|
||||
} as LoginRuntimeType['NapCatHelper']['onOB11ConfigChanged'],
|
||||
|
||||
GetNapCatVersion () {
|
||||
return LoginRuntime.NapCatVersion;
|
||||
},
|
||||
|
||||
setQQVersion (version: string) {
|
||||
LoginRuntime.QQVersion = version;
|
||||
},
|
||||
|
||||
getQQVersion () {
|
||||
return LoginRuntime.QQVersion;
|
||||
},
|
||||
|
||||
setQQDataPath (dataPath: string) {
|
||||
LoginRuntime.QQDataPath = dataPath;
|
||||
},
|
||||
|
||||
getQQDataPath (): string {
|
||||
return LoginRuntime.QQDataPath;
|
||||
},
|
||||
|
||||
setWebUiConfigQuickFunction (func: LoginRuntimeType['WebUiConfigQuickFunction']): void {
|
||||
LoginRuntime.WebUiConfigQuickFunction = func;
|
||||
},
|
||||
runWebUiConfigQuickFunction: async function () {
|
||||
await LoginRuntime.WebUiConfigQuickFunction();
|
||||
},
|
||||
|
||||
setOneBotContext (context: any): void {
|
||||
LoginRuntime.OneBotContext = context;
|
||||
},
|
||||
|
||||
getOneBotContext (): any | null {
|
||||
return LoginRuntime.OneBotContext;
|
||||
},
|
||||
|
||||
setRestartProcessCall (func: () => Promise<{ result: boolean; message: string; }>): void {
|
||||
LoginRuntime.NapCatHelper.onRestartProcessRequested = func;
|
||||
},
|
||||
|
||||
requestRestartProcess: async function () {
|
||||
return await LoginRuntime.NapCatHelper.onRestartProcessRequested();
|
||||
},
|
||||
|
||||
setQQLoginError (error: string): void {
|
||||
LoginRuntime.QQLoginError = error;
|
||||
},
|
||||
|
||||
getQQLoginError (): string {
|
||||
return LoginRuntime.QQLoginError;
|
||||
},
|
||||
|
||||
setRefreshQRCodeCallback (func: () => Promise<void>): void {
|
||||
LoginRuntime.onRefreshQRCode = func;
|
||||
},
|
||||
|
||||
getRefreshQRCodeCallback (): () => Promise<void> {
|
||||
return LoginRuntime.onRefreshQRCode;
|
||||
},
|
||||
|
||||
refreshQRCode: async function () {
|
||||
const previousQRCodeUrl = LoginRuntime.QQQRCodeURL;
|
||||
LoginRuntime.QQLoginError = '';
|
||||
LoginRuntime.QQLoginPhase = 'generating_qrcode';
|
||||
await LoginRuntime.onRefreshQRCode();
|
||||
|
||||
const refreshTimeout = 10_000;
|
||||
const deadline = Date.now() + refreshTimeout;
|
||||
while (Date.now() < deadline) {
|
||||
const qrcodeUrl = LoginRuntime.QQQRCodeURL;
|
||||
if (qrcodeUrl && qrcodeUrl !== previousQRCodeUrl) {
|
||||
LoginRuntime.QQLoginPhase = 'waiting_qrcode';
|
||||
return qrcodeUrl;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
LoginRuntime.QQLoginPhase = previousQRCodeUrl ? 'waiting_qrcode' : 'offline';
|
||||
throw new Error('二维码刷新超时:QQ 登录服务未返回新的二维码');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
generateRegistrationOptions,
|
||||
verifyRegistrationResponse,
|
||||
generateAuthenticationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
type AuthenticatorTransportFuture,
|
||||
} from '@simplewebauthn/server';
|
||||
import { isoBase64URL } from '@simplewebauthn/server/helpers';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { webUiPathWrapper } from '../../index';
|
||||
|
||||
interface PasskeyCredential {
|
||||
id: string;
|
||||
publicKey: string;
|
||||
counter: number;
|
||||
transports?: AuthenticatorTransportFuture[];
|
||||
}
|
||||
|
||||
const RP_NAME = 'NapCat WebUI';
|
||||
|
||||
export class PasskeyHelper {
|
||||
private static getPasskeyFilePath (): string {
|
||||
return path.join(webUiPathWrapper.configPath, 'passkey.json');
|
||||
}
|
||||
|
||||
// 内存中存储临时挑战数据
|
||||
private static challenges: Map<string, string> = new Map();
|
||||
private static async ensurePasskeyFile (): Promise<void> {
|
||||
try {
|
||||
// 确保配置文件目录存在
|
||||
const passkeyFile = this.getPasskeyFilePath();
|
||||
await fs.mkdir(path.dirname(passkeyFile), { recursive: true });
|
||||
// 检查文件是否存在,如果不存在创建空文件
|
||||
try {
|
||||
await fs.access(passkeyFile);
|
||||
} catch {
|
||||
await fs.writeFile(passkeyFile, JSON.stringify({}, null, 2));
|
||||
}
|
||||
} catch (_error) {
|
||||
// Directory or file already exists or other error
|
||||
}
|
||||
}
|
||||
|
||||
private static async getAllPasskeys (): Promise<Record<string, PasskeyCredential[]>> {
|
||||
await this.ensurePasskeyFile();
|
||||
try {
|
||||
const passkeyFile = this.getPasskeyFilePath();
|
||||
const data = await fs.readFile(passkeyFile, 'utf-8');
|
||||
const passkeys = JSON.parse(data);
|
||||
return typeof passkeys === 'object' && passkeys !== null ? passkeys : {};
|
||||
} catch (_error) {
|
||||
console.error('Failed to read passkey file:', _error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private static async saveAllPasskeys (allPasskeys: Record<string, PasskeyCredential[]>): Promise<void> {
|
||||
await this.ensurePasskeyFile();
|
||||
const passkeyFile = this.getPasskeyFilePath();
|
||||
await fs.writeFile(passkeyFile, JSON.stringify(allPasskeys, null, 2));
|
||||
}
|
||||
|
||||
private static async getUserPasskeys (userId: string): Promise<PasskeyCredential[]> {
|
||||
const allPasskeys = await this.getAllPasskeys();
|
||||
return allPasskeys[userId] || [];
|
||||
}
|
||||
|
||||
// 持久性存储用户的passkey到统一配置文件
|
||||
private static async setUserPasskeys (userId: string, passkeys: PasskeyCredential[]): Promise<void> {
|
||||
const allPasskeys = await this.getAllPasskeys();
|
||||
if (passkeys.length > 0) {
|
||||
allPasskeys[userId] = passkeys;
|
||||
} else {
|
||||
delete allPasskeys[userId];
|
||||
}
|
||||
await this.saveAllPasskeys(allPasskeys);
|
||||
}
|
||||
|
||||
static async generateRegistrationOptions (userId: string, userName: string, rpId: string) {
|
||||
const userPasskeys = await this.getUserPasskeys(userId);
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: RP_NAME,
|
||||
rpID: rpId,
|
||||
userID: new TextEncoder().encode(userId) as Uint8Array<ArrayBuffer>,
|
||||
userName,
|
||||
attestationType: 'none',
|
||||
excludeCredentials: userPasskeys.map(passkey => ({
|
||||
id: passkey.id,
|
||||
type: 'public-key' as const,
|
||||
transports: passkey.transports,
|
||||
})),
|
||||
// Temporarily simplify authenticatorSelection - remove residentKey to avoid conflicts
|
||||
authenticatorSelection: {
|
||||
userVerification: 'preferred',
|
||||
},
|
||||
});
|
||||
|
||||
// Store challenge temporarily in memory
|
||||
this.challenges.set(`reg_${userId}`, options.challenge);
|
||||
// Auto cleanup after 5 minutes
|
||||
setTimeout(() => {
|
||||
this.challenges.delete(`reg_${userId}`);
|
||||
}, 300000);
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
static async verifyRegistration (userId: string, response: any, origin: string, rpId: string) {
|
||||
const expectedChallenge = this.challenges.get(`reg_${userId}`);
|
||||
if (!expectedChallenge) {
|
||||
throw new Error('Challenge not found or expired');
|
||||
}
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpId,
|
||||
});
|
||||
|
||||
if (verification.verified && verification.registrationInfo) {
|
||||
const { registrationInfo } = verification;
|
||||
|
||||
const newPasskey: PasskeyCredential = {
|
||||
id: registrationInfo.credential.id,
|
||||
publicKey: isoBase64URL.fromBuffer(registrationInfo.credential.publicKey),
|
||||
counter: registrationInfo.credential.counter || 0,
|
||||
transports: response.response.transports,
|
||||
};
|
||||
|
||||
const userPasskeys = await this.getUserPasskeys(userId);
|
||||
userPasskeys.push(newPasskey);
|
||||
await this.setUserPasskeys(userId, userPasskeys);
|
||||
|
||||
// Clean up challenge
|
||||
this.challenges.delete(`reg_${userId}`);
|
||||
}
|
||||
|
||||
return verification;
|
||||
}
|
||||
|
||||
static async generateAuthenticationOptions (userId: string, rpId: string) {
|
||||
const userPasskeys = await this.getUserPasskeys(userId);
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: rpId,
|
||||
allowCredentials: userPasskeys.map(passkey => ({
|
||||
id: passkey.id,
|
||||
type: 'public-key' as const,
|
||||
transports: passkey.transports,
|
||||
})),
|
||||
userVerification: 'preferred',
|
||||
});
|
||||
|
||||
// Store challenge temporarily in memory
|
||||
this.challenges.set(`auth_${userId}`, options.challenge);
|
||||
// Auto cleanup after 5 minutes
|
||||
setTimeout(() => {
|
||||
this.challenges.delete(`auth_${userId}`);
|
||||
}, 300000);
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
static async verifyAuthentication (userId: string, response: any, origin: string, rpId: string) {
|
||||
const expectedChallenge = this.challenges.get(`auth_${userId}`);
|
||||
if (!expectedChallenge) {
|
||||
throw new Error('Challenge not found or expired');
|
||||
}
|
||||
|
||||
const userPasskeys = await this.getUserPasskeys(userId);
|
||||
const passkey = userPasskeys.find(p => p.id === response.id);
|
||||
if (!passkey) {
|
||||
throw new Error('Passkey not found');
|
||||
}
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpId,
|
||||
credential: {
|
||||
id: passkey.id,
|
||||
publicKey: isoBase64URL.toBuffer(passkey.publicKey),
|
||||
counter: passkey.counter,
|
||||
},
|
||||
});
|
||||
|
||||
if (verification.verified && verification.authenticationInfo) {
|
||||
// Update counter
|
||||
passkey.counter = verification.authenticationInfo.newCounter;
|
||||
await this.setUserPasskeys(userId, userPasskeys);
|
||||
|
||||
// Clean up challenge
|
||||
this.challenges.delete(`auth_${userId}`);
|
||||
}
|
||||
|
||||
return verification;
|
||||
}
|
||||
|
||||
static async hasPasskeys (userId: string): Promise<boolean> {
|
||||
const userPasskeys = await this.getUserPasskeys(userId);
|
||||
return userPasskeys.length > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import crypto from 'crypto';
|
||||
import store from 'napcat-common/src/store';
|
||||
import type { WebUiCredentialJson, WebUiCredentialInnerJson } from '@/napcat-webui-backend/src/types';
|
||||
export class AuthHelper {
|
||||
private static readonly secretKey = process.env['NAPCAT_WEBUI_JWT_SECRET_KEY'] || Math.random().toString(36).slice(2);
|
||||
private static readonly MAX_CREDENTIAL_VALID_SECONDS = 3600;
|
||||
|
||||
public static getSecretKey (): string {
|
||||
return AuthHelper.secretKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 签名凭证方法。
|
||||
* @param hash 待签名的凭证字符串。
|
||||
* @returns 签名后的凭证对象。
|
||||
*/
|
||||
public static signCredential (hash: string): WebUiCredentialJson {
|
||||
const innerJson: WebUiCredentialInnerJson = {
|
||||
// 统一使用秒级时间戳,避免与校验逻辑出现单位不一致
|
||||
CreatedTime: Math.floor(Date.now() / 1000),
|
||||
HashEncoded: hash,
|
||||
};
|
||||
const jsonString = JSON.stringify(innerJson);
|
||||
const hmac = crypto.createHmac('sha256', AuthHelper.secretKey).update(jsonString, 'utf8').digest('hex');
|
||||
return { Data: innerJson, Hmac: hmac };
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查凭证是否被篡改的方法。
|
||||
* @param credentialJson 凭证的JSON对象。
|
||||
* @returns 布尔值,表示凭证是否有效。
|
||||
*/
|
||||
public static checkCredential (credentialJson: WebUiCredentialJson): boolean {
|
||||
try {
|
||||
const jsonString = JSON.stringify(credentialJson.Data);
|
||||
const calculatedHmac = crypto
|
||||
.createHmac('sha256', AuthHelper.secretKey)
|
||||
.update(jsonString, 'utf8')
|
||||
.digest('hex');
|
||||
return calculatedHmac === credentialJson.Hmac;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证凭证在1小时内有效且token与原始token相同。
|
||||
* @param token 待验证的原始token。
|
||||
* @param credentialJson 已签名的凭证JSON对象。
|
||||
* @returns 布尔值,表示凭证是否有效且token匹配。
|
||||
*/
|
||||
public static validateCredentialWithinOneHour (token: string, credentialJson: WebUiCredentialJson): boolean {
|
||||
// 首先检查凭证是否被篡改
|
||||
const isValid = AuthHelper.checkCredential(credentialJson);
|
||||
if (!isValid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查凭证是否在黑名单中
|
||||
if (AuthHelper.isCredentialRevoked(credentialJson)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentTimeSeconds = Math.floor(Date.now() / 1000);
|
||||
const createdTimeSeconds = Math.floor(Number(credentialJson.Data.CreatedTime));
|
||||
const timeDifferenceSeconds = currentTimeSeconds - createdTimeSeconds;
|
||||
const isWithinOneHour = timeDifferenceSeconds >= 0 && timeDifferenceSeconds <= AuthHelper.MAX_CREDENTIAL_VALID_SECONDS;
|
||||
return isWithinOneHour && credentialJson.Data.HashEncoded === AuthHelper.generatePasswordHash(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销指定的Token凭证
|
||||
* @param credentialJson 凭证JSON对象
|
||||
* @returns void
|
||||
*/
|
||||
public static revokeCredential (credentialJson: WebUiCredentialJson): void {
|
||||
const jsonString = JSON.stringify(credentialJson.Data);
|
||||
const hmac = crypto.createHmac('sha256', AuthHelper.secretKey).update(jsonString, 'utf8').digest('hex');
|
||||
|
||||
// 将已注销的凭证添加到黑名单中,有效期1小时
|
||||
store.set(`revoked:${hmac}`, true, 3600);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查凭证是否已被注销
|
||||
* @param credentialJson 凭证JSON对象
|
||||
* @returns 布尔值,表示凭证是否已被注销
|
||||
*/
|
||||
public static isCredentialRevoked (credentialJson: WebUiCredentialJson): boolean {
|
||||
const jsonString = JSON.stringify(credentialJson.Data);
|
||||
const hmac = crypto.createHmac('sha256', AuthHelper.secretKey).update(jsonString, 'utf8').digest('hex');
|
||||
|
||||
return store.exists(`revoked:${hmac}`) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成密码Hash
|
||||
* @param password 密码
|
||||
* @returns 生成的Hash值
|
||||
*/
|
||||
public static generatePasswordHash (password: string): string {
|
||||
return crypto.createHash('sha256').update(password + '.napcat').digest().toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 对比密码和Hash值
|
||||
* @param password 密码
|
||||
* @param hash Hash值
|
||||
* @returns 布尔值,表示密码是否匹配Hash值
|
||||
*/
|
||||
public static comparePasswordHash (password: string, hash: string): boolean {
|
||||
return this.generatePasswordHash(password) === hash;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* TOTP (Time-based One-Time Password) Helper
|
||||
* 用于生成和验证基于时间的一次性密码
|
||||
*/
|
||||
export class TotpHelper {
|
||||
/**
|
||||
* 生成随机密钥(Base32格式)
|
||||
* @param length 密钥长度(字节),默认16字节
|
||||
* @returns Base32编码的密钥
|
||||
*/
|
||||
static generateSecret (length: number = 16): string {
|
||||
const buffer = crypto.randomBytes(length);
|
||||
return this.toBase32(buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证一次性密码
|
||||
* @param secret Base32编码的密钥
|
||||
* @param code 用户输入的验证码
|
||||
* @param timeStep 时间步长(秒),默认30秒
|
||||
* @param window 允许的时间窗口(前后各多少个时间步长),默认1
|
||||
* @returns 是否验证通过
|
||||
*/
|
||||
static verifyTotp (
|
||||
secret: string,
|
||||
code: string,
|
||||
timeStep: number = 30,
|
||||
window: number = 1
|
||||
): boolean {
|
||||
const time = Math.floor(Date.now() / 1000 / timeStep);
|
||||
|
||||
// 检查前后 window 个时间步长
|
||||
for (let i = -window; i <= window; i++) {
|
||||
const candidateCode = this.generateHotp(secret, time + i);
|
||||
if (candidateCode === code) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 HOTP (HMAC-based One-Time Password)
|
||||
* @param secret Base32编码的密钥
|
||||
* @param counter 计数器
|
||||
* @returns 6位数字验证码
|
||||
*/
|
||||
private static generateHotp (secret: string, counter: number): string {
|
||||
// 将 Base32 密钥转换为字节
|
||||
const key = this.base32ToBytes(secret);
|
||||
|
||||
// 将计数器转换为 8 字节大端序
|
||||
const counterBytes = Buffer.alloc(8);
|
||||
let tempCounter = counter;
|
||||
for (let i = 7; i >= 0; i--) {
|
||||
counterBytes[i] = tempCounter & 0xff;
|
||||
tempCounter = Math.floor(tempCounter / 256);
|
||||
}
|
||||
|
||||
// 计算 HMAC-SHA1
|
||||
const hmac = crypto.createHmac('sha1', key);
|
||||
hmac.update(counterBytes);
|
||||
const hash = hmac.digest();
|
||||
|
||||
// 动态截断
|
||||
const hashLength = hash.length;
|
||||
const lastByte = hash[hashLength - 1];
|
||||
if (lastByte === undefined) {
|
||||
return '000000';
|
||||
}
|
||||
const offset = lastByte & 0x0f;
|
||||
const byte0 = hash[offset] ?? 0;
|
||||
const byte1 = hash[offset + 1] ?? 0;
|
||||
const byte2 = hash[offset + 2] ?? 0;
|
||||
const byte3 = hash[offset + 3] ?? 0;
|
||||
const binary =
|
||||
((byte0 & 0x7f) << 24) |
|
||||
((byte1 & 0xff) << 16) |
|
||||
((byte2 & 0xff) << 8) |
|
||||
(byte3 & 0xff);
|
||||
|
||||
// 生成 6 位数字
|
||||
const otp = binary % 1000000;
|
||||
return otp.toString().padStart(6, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 QR Code URL
|
||||
* @param secret Base32编码的密钥
|
||||
* @param account 账户名称
|
||||
* @param issuer 发行方名称
|
||||
* @returns otpauth:// URL
|
||||
*/
|
||||
static generateQrCodeUrl (
|
||||
secret: string,
|
||||
account: string,
|
||||
issuer: string
|
||||
): string {
|
||||
const encodedIssuer = encodeURIComponent(issuer);
|
||||
const encodedAccount = encodeURIComponent(`${issuer}:${account}`);
|
||||
return `otpauth://totp/${encodedAccount}?secret=${secret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=6&period=30`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将字节数组转换为 Base32 编码
|
||||
* @param buffer 字节数组
|
||||
* @returns Base32编码字符串
|
||||
*/
|
||||
private static toBase32 (buffer: Buffer): string {
|
||||
const base32Alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
let output = '';
|
||||
|
||||
for (let i = 0; i < buffer.length; i += 5) {
|
||||
const b0 = buffer[i] ?? 0;
|
||||
const b1 = buffer[i + 1] ?? 0;
|
||||
const b2 = buffer[i + 2] ?? 0;
|
||||
const b3 = buffer[i + 3] ?? 0;
|
||||
const b4 = buffer[i + 4] ?? 0;
|
||||
|
||||
output += base32Alphabet[(b0 >> 3) & 0x1f];
|
||||
output += base32Alphabet[((b0 << 2) | (b1 >> 6)) & 0x1f];
|
||||
output += base32Alphabet[(b1 >> 1) & 0x1f];
|
||||
output += base32Alphabet[((b1 << 4) | (b2 >> 4)) & 0x1f];
|
||||
output += base32Alphabet[((b2 << 1) | (b3 >> 7)) & 0x1f];
|
||||
output += base32Alphabet[(b3 >> 2) & 0x1f];
|
||||
output += base32Alphabet[((b3 << 3) | (b4 >> 5)) & 0x1f];
|
||||
output += base32Alphabet[b4 & 0x1f];
|
||||
}
|
||||
|
||||
// 移除填充的等号
|
||||
return output.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Base32 字符串转换为字节数组
|
||||
* @param base32 Base32编码字符串
|
||||
* @returns 字节数组
|
||||
*/
|
||||
private static base32ToBytes (base32: string): Buffer {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
const base32Clean = base32.toUpperCase().replace(/=+$/, '');
|
||||
|
||||
const bytes: number[] = [];
|
||||
let buffer = 0;
|
||||
let bitsLeft = 0;
|
||||
|
||||
for (const char of base32Clean) {
|
||||
const value = alphabet.indexOf(char);
|
||||
if (value === -1) continue;
|
||||
|
||||
buffer = (buffer << 5) | value;
|
||||
bitsLeft += 5;
|
||||
|
||||
if (bitsLeft >= 8) {
|
||||
bytes.push((buffer >> (bitsLeft - 8)) & 0xff);
|
||||
bitsLeft -= 8;
|
||||
}
|
||||
}
|
||||
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { webUiPathWrapper, getInitialWebUiToken } from '@/napcat-webui-backend/index';
|
||||
import { Type, Static } from '@sinclair/typebox';
|
||||
import { TypeCompiler } from '@sinclair/typebox/compiler';
|
||||
import { Value } from '@sinclair/typebox/value';
|
||||
import fs, { constants } from 'node:fs/promises';
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { deepMerge } from '../utils/object';
|
||||
import { themeType } from '../types/theme';
|
||||
import { getRandomToken } from '../utils/url';
|
||||
|
||||
// 限制尝试端口的次数,避免死循环
|
||||
// 定义配置的类型
|
||||
const WebUiConfigSchema = Type.Object({
|
||||
host: Type.String({ default: '::' }),
|
||||
port: Type.Number({ default: 6099 }),
|
||||
token: Type.String({ default: getRandomToken(12) }),
|
||||
loginRate: Type.Number({ default: 10 }),
|
||||
autoLoginAccount: Type.String({ default: '' }),
|
||||
theme: themeType,
|
||||
// 是否关闭WebUI
|
||||
disableWebUI: Type.Boolean({ default: false }),
|
||||
// 网络访问控制模式: 'none' | 'whitelist' | 'blacklist'
|
||||
accessControlMode: Type.Union([
|
||||
Type.Literal('none'),
|
||||
Type.Literal('whitelist'),
|
||||
Type.Literal('blacklist'),
|
||||
], { default: 'none' }),
|
||||
// IP白名单列表
|
||||
ipWhitelist: Type.Array(Type.String(), { default: [] }),
|
||||
// IP黑名单列表
|
||||
ipBlacklist: Type.Array(Type.String(), { default: [] }),
|
||||
// 是否启用 X-Forwarded-For 获取真实IP
|
||||
enableXForwardedFor: Type.Boolean({ default: false }),
|
||||
// 是否启用双因素认证(2FA)
|
||||
enable2FA: Type.Boolean({ default: false }),
|
||||
// TOTP 密钥
|
||||
totpSecret: Type.String({ default: '' }),
|
||||
});
|
||||
|
||||
export type WebUiConfigType = Static<typeof WebUiConfigSchema>;
|
||||
|
||||
// 读取当前目录下名为 webui.json 的配置文件,如果不存在则创建初始化配置文件
|
||||
export class WebUiConfigWrapper {
|
||||
WebUiConfigData: WebUiConfigType | undefined = undefined;
|
||||
|
||||
private validateAndApplyDefaults (config: Partial<WebUiConfigType>): WebUiConfigType {
|
||||
let data = config;
|
||||
data = Value.Parse(WebUiConfigSchema, data) as Partial<WebUiConfigType>;
|
||||
const validate = TypeCompiler.Compile(WebUiConfigSchema);
|
||||
if (!validate.Check(data)) {
|
||||
// Return original if validation failed, or throw
|
||||
}
|
||||
return data as WebUiConfigType;
|
||||
}
|
||||
|
||||
private async ensureConfigFileExists (configPath: string): Promise<void> {
|
||||
const configExists = await fs
|
||||
.access(configPath, constants.F_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (!configExists) {
|
||||
await fs.writeFile(configPath, JSON.stringify(this.validateAndApplyDefaults({}), null, 4));
|
||||
}
|
||||
}
|
||||
|
||||
private async readAndValidateConfig (configPath: string): Promise<WebUiConfigType> {
|
||||
const fileContent = await fs.readFile(configPath, 'utf-8');
|
||||
return this.validateAndApplyDefaults(JSON.parse(fileContent));
|
||||
}
|
||||
|
||||
private async writeConfig (configPath: string, config: WebUiConfigType): Promise<void> {
|
||||
const hasWritePermission = await fs
|
||||
.access(configPath, constants.W_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (hasWritePermission) {
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 4));
|
||||
} else {
|
||||
console.warn(`文件: ${configPath} 没有写入权限, 配置的更改部分可能会在重启后还原.`);
|
||||
}
|
||||
}
|
||||
|
||||
async GetWebUIConfig (): Promise<WebUiConfigType> {
|
||||
if (this.WebUiConfigData) {
|
||||
return this.WebUiConfigData;
|
||||
}
|
||||
|
||||
try {
|
||||
const configPath = resolve(webUiPathWrapper.configPath, './webui.json');
|
||||
await this.ensureConfigFileExists(configPath);
|
||||
const parsedConfig = await this.readAndValidateConfig(configPath);
|
||||
// 使用内存中缓存的token进行覆盖,确保强兼容性
|
||||
this.WebUiConfigData = {
|
||||
...parsedConfig,
|
||||
// 首次读取内存中是没有token的,需要进行一层兜底
|
||||
token: getInitialWebUiToken() || parsedConfig.token,
|
||||
};
|
||||
return this.WebUiConfigData;
|
||||
} catch (e) {
|
||||
console.log('读取配置文件失败', e);
|
||||
const defaultConfig = this.validateAndApplyDefaults({});
|
||||
this.WebUiConfigData = {
|
||||
...defaultConfig,
|
||||
token: getInitialWebUiToken() || defaultConfig.token,
|
||||
};
|
||||
return this.WebUiConfigData;
|
||||
}
|
||||
}
|
||||
|
||||
async UpdateWebUIConfig (newConfig: Partial<WebUiConfigType>): Promise<void> {
|
||||
const configPath = resolve(webUiPathWrapper.configPath, './webui.json');
|
||||
// 使用原始配置进行合并,避免内存token覆盖影响配置更新
|
||||
const currentConfig = await this.GetRawWebUIConfig();
|
||||
const mergedConfig = deepMerge({ ...currentConfig }, newConfig);
|
||||
const updatedConfig = this.validateAndApplyDefaults(mergedConfig);
|
||||
await this.writeConfig(configPath, updatedConfig);
|
||||
this.WebUiConfigData = updatedConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置文件中实际存储的配置(不被内存token覆盖)
|
||||
* 主要用于配置更新和特殊场景
|
||||
*/
|
||||
async GetRawWebUIConfig (): Promise<WebUiConfigType> {
|
||||
if (this.WebUiConfigData) {
|
||||
return this.WebUiConfigData;
|
||||
}
|
||||
try {
|
||||
const configPath = resolve(webUiPathWrapper.configPath, './webui.json');
|
||||
await this.ensureConfigFileExists(configPath);
|
||||
const parsedConfig = await this.readAndValidateConfig(configPath);
|
||||
this.WebUiConfigData = parsedConfig;
|
||||
return this.WebUiConfigData;
|
||||
} catch (e) {
|
||||
console.log('读取配置文件失败', e);
|
||||
return this.validateAndApplyDefaults({});
|
||||
}
|
||||
}
|
||||
|
||||
async UpdateToken (oldToken: string, newToken: string): Promise<void> {
|
||||
// 使用内存中缓存的token进行验证,确保强兼容性
|
||||
const cachedToken = getInitialWebUiToken();
|
||||
const tokenToCheck = cachedToken || (await this.GetWebUIConfig()).token;
|
||||
|
||||
if (tokenToCheck !== oldToken) {
|
||||
throw new Error('旧 token 不匹配');
|
||||
}
|
||||
await this.UpdateWebUIConfig({ token: newToken });
|
||||
}
|
||||
|
||||
// 获取日志文件夹路径
|
||||
async GetLogsPath (): Promise<string> {
|
||||
return resolve(webUiPathWrapper.logsPath);
|
||||
}
|
||||
|
||||
// 获取日志列表
|
||||
async GetLogsList (): Promise<string[]> {
|
||||
const logsPath = resolve(webUiPathWrapper.logsPath);
|
||||
const logsExist = await fs
|
||||
.access(logsPath, constants.F_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (logsExist) {
|
||||
return (await fs.readdir(logsPath))
|
||||
.filter((file) => file.endsWith('.log'))
|
||||
.map((file) => file.replace('.log', ''));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// 获取指定日志文件内容
|
||||
async GetLogContent (filename: string): Promise<string> {
|
||||
const logPath = resolve(webUiPathWrapper.logsPath, `${filename}.log`);
|
||||
const logExists = await fs
|
||||
.access(logPath, constants.R_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (logExists) {
|
||||
return await fs.readFile(logPath, 'utf-8');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// 获取字体文件夹内的字体列表
|
||||
async GetFontList (): Promise<string[]> {
|
||||
const fontsPath = resolve(webUiPathWrapper.configPath, './fonts');
|
||||
const fontsExist = await fs
|
||||
.access(fontsPath, constants.F_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (fontsExist) {
|
||||
return (await fs.readdir(fontsPath)).filter((file) => file.endsWith('.ttf'));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// 判断字体是否存在(支持多种格式)
|
||||
async CheckWebUIFontExist (): Promise<boolean> {
|
||||
const fontPath = await this.GetWebUIFontPath();
|
||||
if (!fontPath) return false;
|
||||
return await fs
|
||||
.access(fontPath, constants.F_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
// 获取webui字体文件路径(支持多种格式)
|
||||
async GetWebUIFontPath (): Promise<string | null> {
|
||||
const fontsPath = resolve(webUiPathWrapper.configPath, './fonts');
|
||||
const extensions = ['.woff', '.woff2', '.ttf', '.otf'];
|
||||
for (const ext of extensions) {
|
||||
const fontPath = resolve(fontsPath, `webui${ext}`);
|
||||
const exists = await fs
|
||||
.access(fontPath, constants.F_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (exists) {
|
||||
return fontPath;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 同步版本,用于 multer 配置
|
||||
GetWebUIFontPathSync (): string {
|
||||
return resolve(webUiPathWrapper.configPath, './fonts/webui.woff');
|
||||
}
|
||||
|
||||
getAutoLoginAccount (): string | undefined {
|
||||
return this.WebUiConfigData?.autoLoginAccount;
|
||||
}
|
||||
|
||||
// 获取自动登录账号
|
||||
async GetAutoLoginAccount (): Promise<string> {
|
||||
return (await this.GetWebUIConfig()).autoLoginAccount;
|
||||
}
|
||||
|
||||
// 更新自动登录账号
|
||||
async UpdateAutoLoginAccount (uin: string): Promise<void> {
|
||||
await this.UpdateWebUIConfig({ autoLoginAccount: uin });
|
||||
}
|
||||
|
||||
// 获取主题内容
|
||||
async GetTheme (): Promise<WebUiConfigType['theme']> {
|
||||
const config = await this.GetWebUIConfig();
|
||||
|
||||
return config.theme;
|
||||
}
|
||||
|
||||
// 更新主题内容
|
||||
async UpdateTheme (theme: WebUiConfigType['theme']): Promise<void> {
|
||||
await this.UpdateWebUIConfig({ theme });
|
||||
}
|
||||
|
||||
// 获取是否禁用WebUI
|
||||
async GetDisableWebUI (): Promise<boolean> {
|
||||
const config = await this.GetWebUIConfig();
|
||||
return config.disableWebUI;
|
||||
}
|
||||
|
||||
// 更新是否禁用WebUI
|
||||
async UpdateDisableWebUI (disable: boolean): Promise<void> {
|
||||
await this.UpdateWebUIConfig({ disableWebUI: disable });
|
||||
}
|
||||
|
||||
// 获取访问控制模式
|
||||
async GetAccessControlMode (): Promise<'none' | 'whitelist' | 'blacklist'> {
|
||||
const config = await this.GetWebUIConfig();
|
||||
return config.accessControlMode;
|
||||
}
|
||||
|
||||
// 更新访问控制模式
|
||||
async UpdateAccessControlMode (mode: 'none' | 'whitelist' | 'blacklist'): Promise<void> {
|
||||
await this.UpdateWebUIConfig({ accessControlMode: mode });
|
||||
}
|
||||
|
||||
// 获取IP白名单
|
||||
async GetIpWhitelist (): Promise<string[]> {
|
||||
const config = await this.GetWebUIConfig();
|
||||
return config.ipWhitelist;
|
||||
}
|
||||
|
||||
// 更新IP白名单
|
||||
async UpdateIpWhitelist (whitelist: string[]): Promise<void> {
|
||||
await this.UpdateWebUIConfig({ ipWhitelist: whitelist });
|
||||
}
|
||||
|
||||
// 获取IP黑名单
|
||||
async GetIpBlacklist (): Promise<string[]> {
|
||||
const config = await this.GetWebUIConfig();
|
||||
return config.ipBlacklist;
|
||||
}
|
||||
|
||||
// 更新IP黑名单
|
||||
async UpdateIpBlacklist (blacklist: string[]): Promise<void> {
|
||||
await this.UpdateWebUIConfig({ ipBlacklist: blacklist });
|
||||
}
|
||||
|
||||
// 获取是否启用 X-Forwarded-For
|
||||
async GetEnableXForwardedFor (): Promise<boolean> {
|
||||
const config = await this.GetWebUIConfig();
|
||||
return config.enableXForwardedFor || false;
|
||||
}
|
||||
|
||||
// 更新是否启用 X-Forwarded-For
|
||||
async UpdateEnableXForwardedFor (enable: boolean): Promise<void> {
|
||||
await this.UpdateWebUIConfig({ enableXForwardedFor: enable });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
|
||||
import { getInitialWebUiToken } from '@/napcat-webui-backend/index';
|
||||
|
||||
import { AuthHelper } from '@/napcat-webui-backend/src/helper/SignToken';
|
||||
import { sendError } from '@/napcat-webui-backend/src/utils/response';
|
||||
import type { WebUiCredentialJson } from '@/napcat-webui-backend/src/types';
|
||||
|
||||
// 鉴权中间件
|
||||
export async function auth (req: Request, res: Response, next: NextFunction) {
|
||||
// 判断当前url是否为/login 如果是跳过鉴权
|
||||
if (req.url === '/auth/login') {
|
||||
return next();
|
||||
}
|
||||
if (req.url === '/auth/passkey/generate-authentication-options' ||
|
||||
req.url === '/auth/passkey/verify-authentication') {
|
||||
return next();
|
||||
}
|
||||
let hash: string | undefined;
|
||||
if (req.headers?.authorization) {
|
||||
// 切割参数以获取token
|
||||
const authorization = req.headers.authorization.split(' ');
|
||||
// 当Bearer后面没有参数时
|
||||
if (authorization.length < 2) {
|
||||
return sendError(res, 'Unauthorized');
|
||||
}
|
||||
// 获取token
|
||||
hash = authorization[1];
|
||||
} else if (req.query['webui_token'] && typeof req.query['webui_token'] === 'string') {
|
||||
// 支持通过query参数传递token
|
||||
hash = req.query['webui_token'];
|
||||
}
|
||||
// 判断是否有Authorization头
|
||||
if (hash) {
|
||||
// if (!hash) return sendError(res, 'Unauthorized');
|
||||
// 解析token
|
||||
let Credential: WebUiCredentialJson;
|
||||
try {
|
||||
Credential = JSON.parse(Buffer.from(hash, 'base64').toString('utf-8'));
|
||||
} catch (_e) {
|
||||
return sendError(res, 'Unauthorized');
|
||||
}
|
||||
// 使用启动时缓存的token进行验证,而不是动态读取配置文件 因为有可能运行时手动修改了密码
|
||||
const initialToken = getInitialWebUiToken();
|
||||
if (!initialToken) {
|
||||
return sendError(res, 'Server token not initialized');
|
||||
}
|
||||
// 验证凭证在1小时内有效
|
||||
const credentialJson = AuthHelper.validateCredentialWithinOneHour(initialToken, Credential);
|
||||
if (credentialJson) {
|
||||
// 通过验证
|
||||
return next();
|
||||
}
|
||||
// 验证失败
|
||||
return sendError(res, 'Unauthorized');
|
||||
}
|
||||
|
||||
// 没有Authorization头
|
||||
return sendError(res, 'Unauthorized');
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import type { RequestHandler } from 'express';
|
||||
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
||||
|
||||
// 标准化 IP 地址(移除 IPv6 前缀,但保留完整的 IPv6 地址)
|
||||
function normalizeIP (ip: string): string {
|
||||
// 移除 IPv4-mapped IPv6 前缀
|
||||
if (ip.startsWith('::ffff:')) {
|
||||
const ipv4 = ip.substring(7);
|
||||
// 检查是否是有效的 IPv4
|
||||
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(ipv4)) {
|
||||
return ipv4;
|
||||
}
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
|
||||
// 检查是否是 IPv6 地址
|
||||
function isIPv6 (ip: string): boolean {
|
||||
return ip.includes(':');
|
||||
}
|
||||
|
||||
// 检查 IP 是否匹配规则(支持 IPv4 和 IPv6)
|
||||
function matchIPRule (ip: string, rule: string): boolean {
|
||||
const cleanIP = normalizeIP(ip);
|
||||
const cleanRule = normalizeIP(rule);
|
||||
|
||||
// 精确匹配
|
||||
if (cleanIP === cleanRule) return true;
|
||||
|
||||
// IPv6 地址不支持通配符,只支持 CIDR
|
||||
if (isIPv6(cleanIP) || isIPv6(cleanRule)) {
|
||||
if (cleanRule.includes('/')) {
|
||||
return matchIPv6CIDR(cleanIP, cleanRule);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// IPv4 通配符匹配 (例如: 192.168.*.* 或 192.168.1.*)
|
||||
if (cleanRule.includes('*')) {
|
||||
const ruleRegex = new RegExp('^' + cleanRule.replace(/\./g, '\\.').replace(/\*/g, '\\d+') + '$');
|
||||
return ruleRegex.test(cleanIP);
|
||||
}
|
||||
|
||||
// IPv4 CIDR 匹配 (例如: 192.168.1.0/24)
|
||||
if (cleanRule.includes('/')) {
|
||||
return matchIPv4CIDR(cleanIP, cleanRule);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// IPv4 CIDR 匹配
|
||||
function matchIPv4CIDR (ip: string, cidr: string): boolean {
|
||||
const parts = cidr.split('/');
|
||||
if (parts.length !== 2) return false;
|
||||
|
||||
const range = parts[0];
|
||||
const bits = parts[1];
|
||||
if (!range || !bits) return false;
|
||||
|
||||
const prefixLength = parseInt(bits);
|
||||
if (isNaN(prefixLength) || prefixLength < 0 || prefixLength > 32) return false;
|
||||
|
||||
const mask = prefixLength === 0 ? 0 : ~(2 ** (32 - prefixLength) - 1);
|
||||
|
||||
const ipNum = ipv4ToNumber(ip);
|
||||
const rangeNum = ipv4ToNumber(range);
|
||||
|
||||
if (ipNum === null || rangeNum === null) return false;
|
||||
|
||||
return (ipNum & mask) === (rangeNum & mask);
|
||||
}
|
||||
|
||||
// IPv4 转数字
|
||||
function ipv4ToNumber (ip: string): number | null {
|
||||
const parts = ip.split('.');
|
||||
if (parts.length !== 4) return null;
|
||||
|
||||
const nums = parts.map(p => parseInt(p));
|
||||
if (nums.some(n => isNaN(n) || n < 0 || n > 255)) return null;
|
||||
|
||||
return ((nums[0] || 0) << 24) + ((nums[1] || 0) << 16) + ((nums[2] || 0) << 8) + (nums[3] || 0);
|
||||
}
|
||||
|
||||
// IPv6 CIDR 匹配
|
||||
function matchIPv6CIDR (ip: string, cidr: string): boolean {
|
||||
const parts = cidr.split('/');
|
||||
if (parts.length !== 2) return false;
|
||||
|
||||
const range = parts[0];
|
||||
const bits = parts[1];
|
||||
if (!range || !bits) return false;
|
||||
|
||||
const prefixLength = parseInt(bits);
|
||||
if (isNaN(prefixLength) || prefixLength < 0 || prefixLength > 128) return false;
|
||||
|
||||
try {
|
||||
const ipSegments = expandIPv6(ip);
|
||||
const rangeSegments = expandIPv6(range);
|
||||
|
||||
if (!ipSegments || !rangeSegments) return false;
|
||||
|
||||
// 按位比较
|
||||
let bitsToCompare = prefixLength;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (bitsToCompare <= 0) break;
|
||||
|
||||
const ipSeg = ipSegments[i] ?? 0;
|
||||
const rangeSeg = rangeSegments[i] ?? 0;
|
||||
|
||||
if (bitsToCompare >= 16) {
|
||||
// 完整比较这个段
|
||||
if (ipSeg !== rangeSeg) return false;
|
||||
bitsToCompare -= 16;
|
||||
} else {
|
||||
// 部分比较这个段
|
||||
const mask = (0xFFFF << (16 - bitsToCompare)) & 0xFFFF;
|
||||
if ((ipSeg & mask) !== (rangeSeg & mask)) return false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 展开 IPv6 地址为 8 个 16 位段
|
||||
function expandIPv6 (ip: string): number[] | null {
|
||||
try {
|
||||
// 移除可能的 IPv4 映射部分
|
||||
let addr = ip;
|
||||
|
||||
// 处理 IPv4-mapped IPv6 (如 ::ffff:192.168.1.1)
|
||||
const ipv4Match = addr.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
||||
if (ipv4Match && ipv4Match[1]) {
|
||||
const ipv4 = ipv4Match[1];
|
||||
const ipv4Parts = ipv4.split('.').map(p => parseInt(p));
|
||||
if (ipv4Parts.length === 4 && ipv4Parts.every(n => n >= 0 && n <= 255)) {
|
||||
// 转换为 IPv6 格式
|
||||
const part1 = (ipv4Parts[0] ?? 0) << 8 | (ipv4Parts[1] ?? 0);
|
||||
const part2 = (ipv4Parts[2] ?? 0) << 8 | (ipv4Parts[3] ?? 0);
|
||||
addr = `::ffff:${part1.toString(16)}:${part2.toString(16)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 :: 缩写
|
||||
if (addr.includes('::')) {
|
||||
const sides = addr.split('::');
|
||||
if (sides.length > 2) return null;
|
||||
|
||||
const left = sides[0] ? sides[0].split(':') : [];
|
||||
const right = sides[1] ? sides[1].split(':') : [];
|
||||
const missing = 8 - left.length - right.length;
|
||||
|
||||
if (missing < 0) return null;
|
||||
|
||||
const segments = [
|
||||
...left,
|
||||
...Array(missing).fill('0'),
|
||||
...right,
|
||||
];
|
||||
|
||||
return segments.map(s => parseInt(s || '0', 16));
|
||||
}
|
||||
|
||||
// 标准格式
|
||||
const segments = addr.split(':');
|
||||
if (segments.length !== 8) return null;
|
||||
|
||||
return segments.map(s => parseInt(s || '0', 16));
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否在 IP 列表中
|
||||
function isIPInList (ip: string, ipList: string[]): boolean {
|
||||
return ipList.some(rule => matchIPRule(ip, rule));
|
||||
}
|
||||
|
||||
// CORS 中间件,跨域用
|
||||
export const cors: RequestHandler = async (req, res, next) => {
|
||||
const config = await WebUiConfig.GetWebUIConfig();
|
||||
|
||||
// 根据配置决定如何获取客户端IP
|
||||
let clientIP: string;
|
||||
if (config.enableXForwardedFor) {
|
||||
// 启用 X-Forwarded-For 时,优先从该头部获取真实IP
|
||||
const forwardedFor = req.headers['x-forwarded-for'];
|
||||
if (typeof forwardedFor === 'string') {
|
||||
// X-Forwarded-For 可能包含多个IP,取第一个
|
||||
clientIP = forwardedFor.split(',')[0]?.trim() || '';
|
||||
} else if (Array.isArray(forwardedFor) && forwardedFor.length > 0) {
|
||||
clientIP = forwardedFor[0] || '';
|
||||
} else {
|
||||
// 如果没有 X-Forwarded-For,回退到 socket IP
|
||||
clientIP = req.ip || req.socket.remoteAddress || '';
|
||||
}
|
||||
} else {
|
||||
// 默认使用 socket IP
|
||||
clientIP = req.ip || req.socket.remoteAddress || '';
|
||||
}
|
||||
|
||||
// 检查访问控制模式
|
||||
if (config.accessControlMode === 'whitelist') {
|
||||
// 白名单模式:只允许白名单中的IP访问
|
||||
if (!isIPInList(clientIP, config.ipWhitelist || [])) {
|
||||
res.status(403).json({ error: '访问被拒绝:IP不在白名单中' });
|
||||
return;
|
||||
}
|
||||
} else if (config.accessControlMode === 'blacklist') {
|
||||
// 黑名单模式:拒绝黑名单中的IP访问
|
||||
if (isIPInList(clientIP, config.ipBlacklist || [])) {
|
||||
res.status(403).json({ error: '访问被拒绝:IP在黑名单中' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 'none' 模式:不进行任何限制
|
||||
|
||||
const origin = req.headers.origin || '*';
|
||||
res.header('Access-Control-Allow-Origin', origin);
|
||||
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization');
|
||||
res.header('Access-Control-Allow-Credentials', 'true');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.sendStatus(204);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Type, Static } from '@sinclair/typebox';
|
||||
import { TypeCompiler } from '@sinclair/typebox/compiler';
|
||||
import { Value } from '@sinclair/typebox/value';
|
||||
const HttpServerConfigSchema = Type.Object({
|
||||
name: Type.String({ default: 'http-server' }),
|
||||
enable: Type.Boolean({ default: false }),
|
||||
port: Type.Number({ default: 3000 }),
|
||||
host: Type.String({ default: '127.0.0.1' }),
|
||||
enableCors: Type.Boolean({ default: true }),
|
||||
enableWebsocket: Type.Boolean({ default: false }),
|
||||
messagePostFormat: Type.String({ default: 'array' }),
|
||||
token: Type.String({ default: '' }),
|
||||
debug: Type.Boolean({ default: false }),
|
||||
});
|
||||
|
||||
const HttpSseServerConfigSchema = Type.Object({
|
||||
name: Type.String({ default: 'http-sse-server' }),
|
||||
enable: Type.Boolean({ default: false }),
|
||||
port: Type.Number({ default: 3000 }),
|
||||
host: Type.String({ default: '127.0.0.1' }),
|
||||
enableCors: Type.Boolean({ default: true }),
|
||||
enableWebsocket: Type.Boolean({ default: false }),
|
||||
messagePostFormat: Type.String({ default: 'array' }),
|
||||
token: Type.String({ default: '' }),
|
||||
debug: Type.Boolean({ default: false }),
|
||||
reportSelfMessage: Type.Boolean({ default: false }),
|
||||
});
|
||||
|
||||
const HttpClientConfigSchema = Type.Object({
|
||||
name: Type.String({ default: 'http-client' }),
|
||||
enable: Type.Boolean({ default: false }),
|
||||
url: Type.String({ default: 'http://localhost:8080' }),
|
||||
messagePostFormat: Type.String({ default: 'array' }),
|
||||
reportSelfMessage: Type.Boolean({ default: false }),
|
||||
token: Type.String({ default: '' }),
|
||||
debug: Type.Boolean({ default: false }),
|
||||
});
|
||||
|
||||
const WebsocketServerConfigSchema = Type.Object({
|
||||
name: Type.String({ default: 'websocket-server' }),
|
||||
enable: Type.Boolean({ default: false }),
|
||||
host: Type.String({ default: '127.0.0.1' }),
|
||||
port: Type.Number({ default: 3001 }),
|
||||
messagePostFormat: Type.String({ default: 'array' }),
|
||||
reportSelfMessage: Type.Boolean({ default: false }),
|
||||
token: Type.String({ default: '' }),
|
||||
enableForcePushEvent: Type.Boolean({ default: true }),
|
||||
debug: Type.Boolean({ default: false }),
|
||||
heartInterval: Type.Number({ default: 30000 }),
|
||||
});
|
||||
|
||||
const WebsocketClientConfigSchema = Type.Object({
|
||||
name: Type.String({ default: 'websocket-client' }),
|
||||
enable: Type.Boolean({ default: false }),
|
||||
url: Type.String({ default: 'ws://localhost:8082' }),
|
||||
messagePostFormat: Type.String({ default: 'array' }),
|
||||
reportSelfMessage: Type.Boolean({ default: false }),
|
||||
reconnectInterval: Type.Number({ default: 5000 }),
|
||||
token: Type.String({ default: '' }),
|
||||
debug: Type.Boolean({ default: false }),
|
||||
heartInterval: Type.Number({ default: 30000 }),
|
||||
});
|
||||
|
||||
const PluginConfigSchema = Type.Object({
|
||||
name: Type.String({ default: 'plugin' }),
|
||||
enable: Type.Boolean({ default: false }),
|
||||
messagePostFormat: Type.String({ default: 'array' }),
|
||||
reportSelfMessage: Type.Boolean({ default: false }),
|
||||
debug: Type.Boolean({ default: false }),
|
||||
});
|
||||
|
||||
const NetworkConfigSchema = Type.Object({
|
||||
httpServers: Type.Array(HttpServerConfigSchema, { default: [] }),
|
||||
httpSseServers: Type.Array(HttpSseServerConfigSchema, { default: [] }),
|
||||
httpClients: Type.Array(HttpClientConfigSchema, { default: [] }),
|
||||
websocketServers: Type.Array(WebsocketServerConfigSchema, { default: [] }),
|
||||
websocketClients: Type.Array(WebsocketClientConfigSchema, { default: [] }),
|
||||
plugins: Type.Array(PluginConfigSchema, { default: [] }),
|
||||
}, { default: {} });
|
||||
|
||||
const TimeoutConfigSchema = Type.Object({
|
||||
baseTimeout: Type.Number({ default: 10000 }),
|
||||
uploadSpeedKBps: Type.Number({ default: 256 }),
|
||||
downloadSpeedKBps: Type.Number({ default: 256 }),
|
||||
maxTimeout: Type.Number({ default: 1800000 }),
|
||||
}, { default: {} });
|
||||
|
||||
export const OneBotConfigSchema = Type.Object({
|
||||
network: NetworkConfigSchema,
|
||||
musicSignUrl: Type.String({ default: '' }),
|
||||
enableLocalFile2Url: Type.Boolean({ default: false }),
|
||||
parseMultMsg: Type.Boolean({ default: false }),
|
||||
imageDownloadProxy: Type.String({ default: '' }),
|
||||
timeout: TimeoutConfigSchema,
|
||||
});
|
||||
|
||||
export type OneBotConfig = Static<typeof OneBotConfigSchema>;
|
||||
export type HttpServerConfig = Static<typeof HttpServerConfigSchema>;
|
||||
export type HttpSseServerConfig = Static<typeof HttpSseServerConfigSchema>;
|
||||
export type HttpClientConfig = Static<typeof HttpClientConfigSchema>;
|
||||
export type WebsocketServerConfig = Static<typeof WebsocketServerConfigSchema>;
|
||||
export type WebsocketClientConfig = Static<typeof WebsocketClientConfigSchema>;
|
||||
export type PluginConfig = Static<typeof PluginConfigSchema>;
|
||||
|
||||
export type NetworkAdapterConfig = HttpServerConfig | HttpSseServerConfig | HttpClientConfig | WebsocketServerConfig | WebsocketClientConfig | PluginConfig;
|
||||
export type NetworkConfigKey = keyof OneBotConfig['network'];
|
||||
|
||||
export function loadConfig (config: Partial<OneBotConfig>): OneBotConfig {
|
||||
let data = config;
|
||||
data = Value.Parse(OneBotConfigSchema, data) as Partial<OneBotConfig>;
|
||||
const validate = TypeCompiler.Compile(OneBotConfigSchema);
|
||||
const valid = validate.Check(data);
|
||||
if (!valid) {
|
||||
const errorMsg = [...validate.Errors(data)].map(e => e.message).join(', ');
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
return data as OneBotConfig;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Router } from 'express';
|
||||
import { GetThemeConfigHandler, GetNapCatVersion, QQVersionHandler, SetThemeConfigHandler, getLatestTagHandler, getAllReleasesHandler, GetMirrorsHandler, GetNapCatFileHashHandler } from '../api/BaseInfo';
|
||||
import { StatusRealTimeHandler } from '@/napcat-webui-backend/src/api/Status';
|
||||
import { GetProxyHandler } from '../api/Proxy';
|
||||
|
||||
const router: Router = Router();
|
||||
// router: 获取nc的package.json信息
|
||||
router.get('/QQVersion', QQVersionHandler);
|
||||
router.get('/GetNapCatVersion', GetNapCatVersion);
|
||||
router.get('/getLatestTag', getLatestTagHandler);
|
||||
router.get('/getAllReleases', getAllReleasesHandler);
|
||||
router.get('/getMirrors', GetMirrorsHandler);
|
||||
router.get('/GetSysStatusRealTime', StatusRealTimeHandler);
|
||||
router.get('/proxy', GetProxyHandler);
|
||||
router.get('/Theme', GetThemeConfigHandler);
|
||||
router.post('/SetTheme', SetThemeConfigHandler);
|
||||
router.get('/GetNapCatFileHash', GetNapCatFileHashHandler);
|
||||
|
||||
export { router as BaseRouter };
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Router, RequestHandler } from 'express';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import {
|
||||
ListFilesHandler,
|
||||
CreateDirHandler,
|
||||
DeleteHandler,
|
||||
ReadFileHandler,
|
||||
WriteFileHandler,
|
||||
CreateFileHandler,
|
||||
BatchDeleteHandler, // 添加这一行
|
||||
RenameHandler,
|
||||
MoveHandler,
|
||||
BatchMoveHandler,
|
||||
DownloadHandler,
|
||||
BatchDownloadHandler, // 新增下载处理方法
|
||||
UploadHandler,
|
||||
UploadWebUIFontHandler,
|
||||
DeleteWebUIFontHandler, // 添加上传处理器
|
||||
CheckWebUIFontExistHandler, // Add this
|
||||
} from '../api/File';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 1 * 60 * 1000, // 1分钟内
|
||||
max: 60, // 最大60个请求
|
||||
validate: {
|
||||
xForwardedForHeader: false,
|
||||
},
|
||||
});
|
||||
|
||||
router.use(apiLimiter as unknown as RequestHandler);
|
||||
|
||||
router.get('/list', ListFilesHandler);
|
||||
router.post('/mkdir', CreateDirHandler);
|
||||
router.post('/delete', DeleteHandler);
|
||||
router.get('/read', ReadFileHandler);
|
||||
router.post('/write', WriteFileHandler);
|
||||
router.post('/create', CreateFileHandler);
|
||||
router.post('/batchDelete', BatchDeleteHandler);
|
||||
router.post('/rename', RenameHandler);
|
||||
router.post('/move', MoveHandler);
|
||||
router.post('/batchMove', BatchMoveHandler);
|
||||
router.post('/download', DownloadHandler);
|
||||
router.post('/batchDownload', BatchDownloadHandler);
|
||||
router.post('/upload', UploadHandler);
|
||||
|
||||
router.post('/font/upload/webui', UploadWebUIFontHandler);
|
||||
router.post('/font/delete/webui', DeleteWebUIFontHandler);
|
||||
router.get('/font/exists/webui', CheckWebUIFontExistHandler); // Add this
|
||||
export { router as FileRouter };
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
LogHandler,
|
||||
LogListHandler,
|
||||
LogRealTimeHandler,
|
||||
CreateTerminalHandler,
|
||||
GetTerminalListHandler,
|
||||
CloseTerminalHandler,
|
||||
} from '../api/Log';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
// 日志相关路由
|
||||
router.get('/GetLog', LogHandler);
|
||||
router.get('/GetLogList', LogListHandler);
|
||||
router.get('/GetLogRealTime', LogRealTimeHandler);
|
||||
|
||||
// 终端相关路由
|
||||
router.get('/terminal/list', GetTerminalListHandler);
|
||||
router.post('/terminal/create', CreateTerminalHandler);
|
||||
router.post('/terminal/:id/close', CloseTerminalHandler);
|
||||
|
||||
export { router as LogRouter };
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
GetMirrorListHandler,
|
||||
SetCustomMirrorHandler,
|
||||
TestMirrorsSSEHandler,
|
||||
TestSingleMirrorHandler,
|
||||
} from '@/napcat-webui-backend/src/api/Mirror';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
// 获取镜像列表
|
||||
router.get('/List', GetMirrorListHandler);
|
||||
|
||||
// 设置自定义镜像
|
||||
router.post('/SetCustom', SetCustomMirrorHandler);
|
||||
|
||||
// SSE 实时测速
|
||||
router.get('/Test/SSE', TestMirrorsSSEHandler);
|
||||
|
||||
// 测试单个镜像
|
||||
router.post('/Test', TestSingleMirrorHandler);
|
||||
|
||||
export { router as MirrorRouter };
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Router } from 'express';
|
||||
|
||||
import { NapCatGetConfigHandler, NapCatSetConfigHandler, NapCatGetUinConfigHandler, NapCatSetUinConfigHandler } from '@/napcat-webui-backend/src/api/NapCatConfig';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
// router:获取 NapCat 配置(napcat.json 登录前配置)
|
||||
router.get('/GetConfig', NapCatGetConfigHandler);
|
||||
// router:设置 NapCat 配置(napcat.json 登录前配置)
|
||||
router.post('/SetConfig', NapCatSetConfigHandler);
|
||||
// router:获取 NapCat per-uin 配置(napcat_{uin}.json 登录后配置)
|
||||
router.get('/GetUinConfig', NapCatGetUinConfigHandler);
|
||||
// router:设置 NapCat per-uin 配置(napcat_{uin}.json 登录后配置)
|
||||
router.post('/SetUinConfig', NapCatSetUinConfigHandler);
|
||||
|
||||
export { router as NapCatConfigRouter };
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
|
||||
import { OB11GetConfigHandler, OB11SetConfigHandler } from '@/napcat-webui-backend/src/api/OB11Config';
|
||||
import { BackupExportConfigHandler, BackupImportConfigHandler } from '@/napcat-webui-backend/src/api/BackupConfig';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
// 使用内存存储,配合流式处理
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
});
|
||||
|
||||
// router:读取配置
|
||||
router.post('/GetConfig', OB11GetConfigHandler);
|
||||
// router:写入配置
|
||||
router.post('/SetConfig', OB11SetConfigHandler);
|
||||
// router:导出配置
|
||||
router.get('/ExportConfig', BackupExportConfigHandler);
|
||||
// router:导入配置
|
||||
router.post('/ImportConfig', upload.single('configFile'), BackupImportConfigHandler);
|
||||
|
||||
export { router as OB11ConfigRouter };
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Router } from 'express';
|
||||
// import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import {
|
||||
GetPluginListHandler,
|
||||
SetPluginStatusHandler,
|
||||
UninstallPluginHandler,
|
||||
GetPluginConfigHandler,
|
||||
SetPluginConfigHandler,
|
||||
RegisterPluginManagerHandler,
|
||||
PluginConfigSSEHandler,
|
||||
PluginConfigChangeHandler,
|
||||
// ImportLocalPluginHandler,
|
||||
GetPluginIconHandler,
|
||||
} from '@/napcat-webui-backend/src/api/Plugin';
|
||||
import {
|
||||
GetPluginStoreListHandler,
|
||||
GetPluginStoreDetailHandler,
|
||||
InstallPluginFromStoreHandler,
|
||||
InstallPluginFromStoreSSEHandler,
|
||||
} from '@/napcat-webui-backend/src/api/PluginStore';
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
|
||||
import { OB11PluginMangerAdapter } from '@/napcat-onebot/network/plugin-manger';
|
||||
|
||||
// 配置 multer 用于文件上传
|
||||
const uploadDir = path.join(os.tmpdir(), 'napcat-plugin-uploads');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
// const storage = multer.diskStorage({
|
||||
// destination: (_req, _file, cb) => {
|
||||
// cb(null, uploadDir);
|
||||
// },
|
||||
// filename: (_req, file, cb) => {
|
||||
// const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
// cb(null, uniqueSuffix + '-' + file.originalname);
|
||||
// }
|
||||
// });
|
||||
|
||||
// const upload = multer({
|
||||
// storage,
|
||||
// limits: {
|
||||
// fileSize: 50 * 1024 * 1024, // 50MB 限制
|
||||
// },
|
||||
// fileFilter: (_req, file, cb) => {
|
||||
// // 只允许 .zip 文件
|
||||
// if (file.mimetype === 'application/zip' ||
|
||||
// file.mimetype === 'application/x-zip-compressed' ||
|
||||
// file.originalname.endsWith('.zip')) {
|
||||
// cb(null, true);
|
||||
// } else {
|
||||
// cb(new Error('Only .zip files are allowed'));
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
router.get('/List', GetPluginListHandler);
|
||||
router.post('/SetStatus', SetPluginStatusHandler);
|
||||
router.post('/Uninstall', UninstallPluginHandler);
|
||||
router.get('/Config', GetPluginConfigHandler);
|
||||
router.post('/Config', SetPluginConfigHandler);
|
||||
router.get('/Config/SSE', PluginConfigSSEHandler);
|
||||
router.post('/Config/Change', PluginConfigChangeHandler);
|
||||
router.post('/RegisterManager', RegisterPluginManagerHandler);
|
||||
// router.post('/Import', upload.single('plugin'), ImportLocalPluginHandler); // 禁用插件上传
|
||||
router.get('/Icon/:pluginId', GetPluginIconHandler);
|
||||
|
||||
// 插件商店相关路由
|
||||
router.get('/Store/List', GetPluginStoreListHandler);
|
||||
router.get('/Store/Detail/:id', GetPluginStoreDetailHandler);
|
||||
router.post('/Store/Install', InstallPluginFromStoreHandler);
|
||||
router.get('/Store/Install/SSE', InstallPluginFromStoreSSEHandler);
|
||||
|
||||
// 插件扩展路由 - 动态挂载插件注册的 API 路由
|
||||
router.use('/ext/:pluginId', (req, res, next): void => {
|
||||
const { pluginId } = req.params;
|
||||
|
||||
if (!pluginId) {
|
||||
res.status(400).json({ code: -1, message: 'Plugin ID is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取插件管理器
|
||||
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter;
|
||||
if (!ob11) {
|
||||
res.status(503).json({ code: -1, message: 'OneBot context not available' });
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter;
|
||||
if (!pluginManager) {
|
||||
res.status(503).json({ code: -1, message: 'Plugin manager not available' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取插件路由
|
||||
const routerRegistry = pluginManager.getPluginRouter(pluginId);
|
||||
if (!routerRegistry || !routerRegistry.hasApiRoutes()) {
|
||||
res.status(404).json({ code: -1, message: `Plugin '${pluginId}' has no registered API routes` });
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建并执行插件路由
|
||||
const pluginRouter = routerRegistry.buildApiRouter();
|
||||
pluginRouter(req, res, next);
|
||||
});
|
||||
|
||||
// 插件页面路由 - 服务插件注册的 HTML 页面
|
||||
router.get('/page/:pluginId/:pagePath', (req, res): void => {
|
||||
const { pluginId, pagePath } = req.params;
|
||||
|
||||
if (!pluginId) {
|
||||
res.status(400).json({ code: -1, message: 'Plugin ID is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取插件管理器
|
||||
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter;
|
||||
if (!ob11) {
|
||||
res.status(503).json({ code: -1, message: 'OneBot context not available' });
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter;
|
||||
if (!pluginManager) {
|
||||
res.status(503).json({ code: -1, message: 'Plugin manager not available' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取插件路由
|
||||
const routerRegistry = pluginManager.getPluginRouter(pluginId);
|
||||
if (!routerRegistry || !routerRegistry.hasPages()) {
|
||||
res.status(404).json({ code: -1, message: `Plugin '${pluginId}' has no registered pages` });
|
||||
return;
|
||||
}
|
||||
|
||||
// 查找匹配的页面
|
||||
const pages = routerRegistry.getPages();
|
||||
const page = pages.find(p => p.path === '/' + pagePath || p.path === pagePath);
|
||||
if (!page) {
|
||||
res.status(404).json({ code: -1, message: `Page '${pagePath}' not found in plugin '${pluginId}'` });
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取插件路径
|
||||
const pluginPath = routerRegistry.getPluginPath();
|
||||
if (!pluginPath) {
|
||||
res.status(500).json({ code: -1, message: 'Plugin path not available' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建 HTML 文件路径并发送
|
||||
const htmlFilePath = path.join(pluginPath, page.htmlFile);
|
||||
if (!fs.existsSync(htmlFilePath)) {
|
||||
res.status(404).json({ code: -1, message: `HTML file not found: ${page.htmlFile}` });
|
||||
return;
|
||||
}
|
||||
|
||||
res.sendFile(htmlFilePath);
|
||||
});
|
||||
|
||||
export { router as PluginRouter };
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Router } from 'express';
|
||||
import { RestartProcessHandler } from '../api/Process';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
// POST /api/Process/Restart - 重启进程
|
||||
router.post('/Restart', RestartProcessHandler);
|
||||
|
||||
export { router as ProcessRouter };
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Router } from 'express';
|
||||
|
||||
import {
|
||||
QQCheckLoginStatusHandler,
|
||||
QQGetQRcodeHandler,
|
||||
QQGetQuickLoginListHandler,
|
||||
QQSetQuickLoginHandler,
|
||||
QQGetLoginListNewHandler,
|
||||
getQQLoginInfoHandler,
|
||||
getAutoLoginAccountHandler,
|
||||
setAutoLoginAccountHandler,
|
||||
QQRefreshQRcodeHandler,
|
||||
QQPasswordLoginHandler,
|
||||
QQCaptchaLoginHandler,
|
||||
QQNewDeviceLoginHandler,
|
||||
QQGetNewDeviceQRCodeHandler,
|
||||
QQPollNewDeviceQRHandler,
|
||||
QQResetDeviceIDHandler,
|
||||
QQRestartNapCatHandler,
|
||||
QQGetDeviceGUIDHandler,
|
||||
QQSetDeviceGUIDHandler,
|
||||
QQGetGUIDBackupsHandler,
|
||||
QQRestoreGUIDBackupHandler,
|
||||
QQCreateGUIDBackupHandler,
|
||||
QQGetPlatformInfoHandler,
|
||||
QQGetLinuxMACHandler,
|
||||
QQSetLinuxMACHandler,
|
||||
QQGetLinuxMachineIdHandler,
|
||||
QQComputeLinuxGUIDHandler,
|
||||
QQGetLinuxMachineInfoBackupsHandler,
|
||||
QQCreateLinuxMachineInfoBackupHandler,
|
||||
QQRestoreLinuxMachineInfoBackupHandler,
|
||||
QQResetLinuxDeviceIDHandler,
|
||||
QQGetMacGUIDInfoHandler,
|
||||
QQGetMacHardwareInfoHandler,
|
||||
QQSetMacMachineIdHandler,
|
||||
QQGetMacInfoBackupsHandler,
|
||||
QQCreateMacInfoBackupHandler,
|
||||
QQRestoreMacInfoBackupHandler,
|
||||
QQResetMacDeviceIDHandler,
|
||||
} from '@/napcat-webui-backend/src/api/QQLogin';
|
||||
|
||||
const router: Router = Router();
|
||||
// router:获取快速登录列表
|
||||
router.all('/GetQuickLoginList', QQGetQuickLoginListHandler);
|
||||
// router:获取快速登录列表(新)
|
||||
router.all('/GetQuickLoginListNew', QQGetLoginListNewHandler);
|
||||
// router:检查QQ登录状态
|
||||
router.post('/CheckLoginStatus', QQCheckLoginStatusHandler);
|
||||
// router:获取QQ登录二维码
|
||||
router.post('/GetQQLoginQrcode', QQGetQRcodeHandler);
|
||||
// router:设置QQ快速登录
|
||||
router.post('/SetQuickLogin', QQSetQuickLoginHandler);
|
||||
// router:获取QQ登录信息
|
||||
router.post('/GetQQLoginInfo', getQQLoginInfoHandler);
|
||||
// router:获取快速登录QQ账号
|
||||
router.post('/GetQuickLoginQQ', getAutoLoginAccountHandler);
|
||||
// router:设置自动登录QQ账号
|
||||
router.post('/SetQuickLoginQQ', setAutoLoginAccountHandler);
|
||||
// router:刷新QQ登录二维码
|
||||
router.post('/RefreshQRcode', QQRefreshQRcodeHandler);
|
||||
// router:密码登录
|
||||
router.post('/PasswordLogin', QQPasswordLoginHandler);
|
||||
// router:验证码登录(密码登录需要验证码时的第二步)
|
||||
router.post('/CaptchaLogin', QQCaptchaLoginHandler);
|
||||
// router:新设备验证登录(密码登录需要新设备验证时的第二步)
|
||||
router.post('/NewDeviceLogin', QQNewDeviceLoginHandler);
|
||||
// router:获取新设备验证二维码 (OIDB)
|
||||
router.post('/GetNewDeviceQRCode', QQGetNewDeviceQRCodeHandler);
|
||||
// router:轮询新设备验证二维码状态 (OIDB)
|
||||
router.post('/PollNewDeviceQR', QQPollNewDeviceQRHandler);
|
||||
// router:重置设备信息
|
||||
router.post('/ResetDeviceID', QQResetDeviceIDHandler);
|
||||
// router:重启NapCat
|
||||
router.post('/RestartNapCat', QQRestartNapCatHandler);
|
||||
// router:获取设备GUID
|
||||
router.post('/GetDeviceGUID', QQGetDeviceGUIDHandler);
|
||||
// router:设置设备GUID
|
||||
router.post('/SetDeviceGUID', QQSetDeviceGUIDHandler);
|
||||
// router:获取GUID备份列表
|
||||
router.post('/GetGUIDBackups', QQGetGUIDBackupsHandler);
|
||||
// router:恢复GUID备份
|
||||
router.post('/RestoreGUIDBackup', QQRestoreGUIDBackupHandler);
|
||||
// router:创建GUID备份
|
||||
router.post('/CreateGUIDBackup', QQCreateGUIDBackupHandler);
|
||||
|
||||
// ============================================================
|
||||
// 平台信息 & Linux GUID 管理
|
||||
// ============================================================
|
||||
// router:获取平台信息
|
||||
router.post('/GetPlatformInfo', QQGetPlatformInfoHandler);
|
||||
// router:获取Linux MAC地址
|
||||
router.post('/GetLinuxMAC', QQGetLinuxMACHandler);
|
||||
// router:设置Linux MAC地址
|
||||
router.post('/SetLinuxMAC', QQSetLinuxMACHandler);
|
||||
// router:获取Linux machine-id
|
||||
router.post('/GetLinuxMachineId', QQGetLinuxMachineIdHandler);
|
||||
// router:计算Linux GUID
|
||||
router.post('/ComputeLinuxGUID', QQComputeLinuxGUIDHandler);
|
||||
// router:获取Linux machine-info备份列表
|
||||
router.post('/GetLinuxMachineInfoBackups', QQGetLinuxMachineInfoBackupsHandler);
|
||||
// router:创建Linux machine-info备份
|
||||
router.post('/CreateLinuxMachineInfoBackup', QQCreateLinuxMachineInfoBackupHandler);
|
||||
// router:恢复Linux machine-info备份
|
||||
router.post('/RestoreLinuxMachineInfoBackup', QQRestoreLinuxMachineInfoBackupHandler);
|
||||
// router:重置Linux设备信息
|
||||
router.post('/ResetLinuxDeviceID', QQResetLinuxDeviceIDHandler);
|
||||
|
||||
// ============================================================
|
||||
// macOS GUID 管理
|
||||
// ============================================================
|
||||
// router:获取macOS设备GUID信息
|
||||
router.post('/GetMacGUIDInfo', QQGetMacGUIDInfoHandler);
|
||||
// router:获取macOS硬件信息
|
||||
router.post('/GetMacHardwareInfo', QQGetMacHardwareInfoHandler);
|
||||
// router:设置macOS machine_id
|
||||
router.post('/SetMacMachineId', QQSetMacMachineIdHandler);
|
||||
// router:获取macOS machineid-info备份列表
|
||||
router.post('/GetMacInfoBackups', QQGetMacInfoBackupsHandler);
|
||||
// router:创建macOS machineid-info备份
|
||||
router.post('/CreateMacInfoBackup', QQCreateMacInfoBackupHandler);
|
||||
// router:恢复macOS machineid-info备份
|
||||
router.post('/RestoreMacInfoBackup', QQRestoreMacInfoBackupHandler);
|
||||
// router:重置macOS设备信息
|
||||
router.post('/ResetMacDeviceID', QQResetMacDeviceIDHandler);
|
||||
|
||||
export { router as QQLoginRouter };
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* @file UpdateNapCat路由
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { UpdateNapCatHandler } from '@/napcat-webui-backend/src/api/UpdateNapCat';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
// POST /api/UpdateNapCat/update - 更新NapCat
|
||||
router.post('/update', UpdateNapCatHandler);
|
||||
|
||||
export { router as UpdateNapCatRouter };
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
GetWebUIConfigHandler,
|
||||
GetDisableWebUIHandler,
|
||||
UpdateDisableWebUIHandler,
|
||||
UpdateWebUIConfigHandler,
|
||||
GetClientIPHandler,
|
||||
GetSSLStatusHandler,
|
||||
UploadSSLCertHandler,
|
||||
DeleteSSLCertHandler,
|
||||
} from '@/napcat-webui-backend/src/api/WebUIConfig';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
// 获取WebUI基础配置
|
||||
router.get('/GetConfig', GetWebUIConfigHandler);
|
||||
|
||||
// 更新WebUI基础配置
|
||||
router.post('/UpdateConfig', UpdateWebUIConfigHandler);
|
||||
|
||||
// 获取是否禁用WebUI
|
||||
router.get('/GetDisableWebUI', GetDisableWebUIHandler);
|
||||
|
||||
// 更新是否禁用WebUI
|
||||
router.post('/UpdateDisableWebUI', UpdateDisableWebUIHandler);
|
||||
|
||||
// 获取当前客户端IP
|
||||
router.get('/GetClientIP', GetClientIPHandler);
|
||||
|
||||
// 获取SSL证书状态
|
||||
router.get('/GetSSLStatus', GetSSLStatusHandler);
|
||||
|
||||
// 上传SSL证书
|
||||
router.post('/UploadSSLCert', UploadSSLCertHandler);
|
||||
|
||||
// 删除SSL证书
|
||||
router.post('/DeleteSSLCert', DeleteSSLCertHandler);
|
||||
|
||||
export { router as WebUIConfigRouter };
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Router } from 'express';
|
||||
|
||||
import {
|
||||
checkHandler,
|
||||
LoginHandler,
|
||||
LogoutHandler,
|
||||
UpdateTokenHandler,
|
||||
GeneratePasskeyRegistrationOptionsHandler,
|
||||
VerifyPasskeyRegistrationHandler,
|
||||
GeneratePasskeyAuthenticationOptionsHandler,
|
||||
VerifyPasskeyAuthenticationHandler,
|
||||
Get2FAStatusHandler,
|
||||
Generate2FASecretHandler,
|
||||
Enable2FAHandler,
|
||||
Disable2FAHandler,
|
||||
} from '@/napcat-webui-backend/src/api/Auth';
|
||||
|
||||
const router: Router = Router();
|
||||
// router:登录
|
||||
router.post('/login', LoginHandler);
|
||||
// router:检查登录状态
|
||||
router.post('/check', checkHandler);
|
||||
// router:注销
|
||||
router.post('/logout', LogoutHandler);
|
||||
// router:更新token
|
||||
router.post('/update_token', UpdateTokenHandler);
|
||||
// router:生成Passkey注册选项
|
||||
router.post('/passkey/generate-registration-options', GeneratePasskeyRegistrationOptionsHandler);
|
||||
// router:验证Passkey注册
|
||||
router.post('/passkey/verify-registration', VerifyPasskeyRegistrationHandler);
|
||||
// router:生成Passkey认证选项
|
||||
router.post('/passkey/generate-authentication-options', GeneratePasskeyAuthenticationOptionsHandler);
|
||||
// router:验证Passkey认证
|
||||
router.post('/passkey/verify-authentication', VerifyPasskeyAuthenticationHandler);
|
||||
// router:获取2FA状态
|
||||
router.get('/2fa/status', Get2FAStatusHandler);
|
||||
// router:生成2FA密钥
|
||||
router.post('/2fa/generate-secret', Generate2FASecretHandler);
|
||||
// router:启用2FA
|
||||
router.post('/2fa/enable', Enable2FAHandler);
|
||||
// router:禁用2FA
|
||||
router.post('/2fa/disable', Disable2FAHandler);
|
||||
|
||||
export { router as AuthRouter };
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @file 所有路由的入口文件
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
|
||||
import { OB11ConfigRouter } from '@/napcat-webui-backend/src/router/OB11Config';
|
||||
import { auth } from '@/napcat-webui-backend/src/middleware/auth';
|
||||
import { sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||
|
||||
import { QQLoginRouter } from '@/napcat-webui-backend/src/router/QQLogin';
|
||||
import { AuthRouter } from '@/napcat-webui-backend/src/router/auth';
|
||||
import { LogRouter } from '@/napcat-webui-backend/src/router/Log';
|
||||
import { BaseRouter } from '@/napcat-webui-backend/src/router/Base';
|
||||
import { FileRouter } from './File';
|
||||
import { WebUIConfigRouter } from './WebUIConfig';
|
||||
import { UpdateNapCatRouter } from './UpdateNapCat';
|
||||
import DebugRouter from '@/napcat-webui-backend/src/api/Debug';
|
||||
import { ProcessRouter } from './Process';
|
||||
import { PluginRouter } from './Plugin';
|
||||
import { MirrorRouter } from './Mirror';
|
||||
import { NapCatConfigRouter } from './NapCatConfig';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
// 鉴权中间件
|
||||
router.use(auth);
|
||||
|
||||
// router:测试用
|
||||
router.all('/test', (_, res) => {
|
||||
return sendSuccess(res);
|
||||
});
|
||||
// router:基础信息相关路由
|
||||
router.use('/base', BaseRouter);
|
||||
// router:WebUI登录相关路由
|
||||
router.use('/auth', AuthRouter);
|
||||
// router:QQ登录相关路由
|
||||
router.use('/QQLogin', QQLoginRouter);
|
||||
// router:OB11配置相关路由
|
||||
router.use('/OB11Config', OB11ConfigRouter);
|
||||
// router:日志相关路由
|
||||
router.use('/Log', LogRouter);
|
||||
// file:文件相关路由
|
||||
router.use('/File', FileRouter);
|
||||
// router:WebUI配置相关路由
|
||||
router.use('/WebUIConfig', WebUIConfigRouter);
|
||||
// router:更新NapCat相关路由
|
||||
router.use('/UpdateNapCat', UpdateNapCatRouter);
|
||||
// router:调试相关路由
|
||||
router.use('/Debug', DebugRouter);
|
||||
// router:进程管理相关路由
|
||||
router.use('/Process', ProcessRouter);
|
||||
// router:插件管理相关路由
|
||||
router.use('/Plugin', PluginRouter);
|
||||
// router:镜像管理相关路由
|
||||
router.use('/Mirror', MirrorRouter);
|
||||
// router:NapCat配置相关路由
|
||||
router.use('/NapCatConfig', NapCatConfigRouter);
|
||||
|
||||
export { router as ALLRouter };
|
||||
@@ -0,0 +1,31 @@
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
export function callsites () {
|
||||
const _prepareStackTrace = Error.prepareStackTrace;
|
||||
try {
|
||||
let result: NodeJS.CallSite[] = [];
|
||||
Error.prepareStackTrace = (_, callSites) => {
|
||||
const callSitesWithoutCurrent = callSites.slice(1);
|
||||
result = callSitesWithoutCurrent;
|
||||
return callSitesWithoutCurrent;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
new Error().stack;
|
||||
return result;
|
||||
} finally {
|
||||
Error.prepareStackTrace = _prepareStackTrace;
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(global, '__dirname', {
|
||||
get () {
|
||||
const sites = callsites();
|
||||
const file = sites?.[1]?.getFileName();
|
||||
if (file) {
|
||||
return path.dirname(fileURLToPath(file));
|
||||
}
|
||||
return '';
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
// import './init-dynamic-dirname';
|
||||
import { WebUiConfig } from '../../index';
|
||||
import { AuthHelper } from '../helper/SignToken';
|
||||
import type { WebUiCredentialJson } from '@/napcat-webui-backend/src/types';
|
||||
import { WebSocket, WebSocketServer } from 'ws';
|
||||
import os from 'os';
|
||||
import { IPty, spawn as ptySpawn } from 'napcat-pty';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { ILogWrapper } from 'napcat-common/src/log-interface';
|
||||
|
||||
interface TerminalInstance {
|
||||
pty: IPty; // 改用 PTY 实例
|
||||
lastAccess: number;
|
||||
sockets: Set<WebSocket>;
|
||||
// 新增标识,用于防止重复关闭
|
||||
isClosing: boolean;
|
||||
// 新增:存储终端历史输出
|
||||
buffer: string;
|
||||
}
|
||||
|
||||
class TerminalManager {
|
||||
private terminals: Map<string, TerminalInstance> = new Map();
|
||||
private wss: WebSocketServer | null = null;
|
||||
|
||||
initialize (req: any, socket: any, head: any, logger?: ILogWrapper) {
|
||||
logger?.log('[NapCat] [WebUi] terminal websocket initialized');
|
||||
this.wss = new WebSocketServer({
|
||||
noServer: true,
|
||||
verifyClient: async (info, cb) => {
|
||||
// 验证 token
|
||||
const url = new URL(info.req.url || '', 'ws://localhost');
|
||||
const token = url.searchParams.get('token');
|
||||
const terminalId = url.searchParams.get('id');
|
||||
|
||||
if (!token || !terminalId) {
|
||||
// eslint-disable-next-line n/no-callback-literal
|
||||
cb(false, 401, 'Unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析token
|
||||
let Credential: WebUiCredentialJson;
|
||||
try {
|
||||
Credential = JSON.parse(Buffer.from(token, 'base64').toString('utf-8'));
|
||||
} catch (_e) {
|
||||
// eslint-disable-next-line n/no-callback-literal
|
||||
cb(false, 401, 'Unauthorized');
|
||||
return;
|
||||
}
|
||||
const config = await WebUiConfig.GetWebUIConfig();
|
||||
const validate = AuthHelper.validateCredentialWithinOneHour(config.token, Credential);
|
||||
if (!validate) {
|
||||
// eslint-disable-next-line n/no-callback-literal
|
||||
cb(false, 401, 'Unauthorized');
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line n/no-callback-literal
|
||||
cb(true);
|
||||
},
|
||||
});
|
||||
this.wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
this.wss?.emit('connection', ws, req);
|
||||
});
|
||||
this.wss.on('connection', async (ws, req) => {
|
||||
logger?.log('建立终端连接');
|
||||
try {
|
||||
const url = new URL(req.url || '', 'ws://localhost');
|
||||
const terminalId = url.searchParams.get('id')!;
|
||||
|
||||
const instance = this.terminals.get(terminalId);
|
||||
|
||||
if (!instance) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
instance.sockets.add(ws);
|
||||
instance.lastAccess = Date.now();
|
||||
|
||||
// 新增:发送当前终端内容给新连接
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'output', data: instance.buffer }));
|
||||
}
|
||||
|
||||
ws.on('message', (data) => {
|
||||
if (instance) {
|
||||
let text: string;
|
||||
if (Array.isArray(data)) {
|
||||
text = Buffer.concat(data).toString();
|
||||
} else {
|
||||
text = data.toString();
|
||||
}
|
||||
const result = JSON.parse(text);
|
||||
if (result.type === 'input') {
|
||||
instance.pty.write(result.data);
|
||||
}
|
||||
// 新增:处理 resize 消息
|
||||
if (result.type === 'resize') {
|
||||
instance.pty.resize(result.cols, result.rows);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
instance.sockets.delete(ws);
|
||||
if (instance.sockets.size === 0 && !instance.isClosing) {
|
||||
instance.isClosing = true;
|
||||
if (os.platform() === 'win32') {
|
||||
process.kill(instance.pty.pid);
|
||||
} else {
|
||||
instance.pty.kill();
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('WebSocket authentication failed:', err);
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 修改:新增 cols 和 rows 参数,同步 xterm 尺寸,防止错位
|
||||
createTerminal (cols: number, rows: number) {
|
||||
const id = randomUUID();
|
||||
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
|
||||
const pty = ptySpawn(shell, [], {
|
||||
name: 'xterm-256color',
|
||||
cols, // 使用客户端传入的 cols
|
||||
rows, // 使用客户端传入的 rows
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
LANG: os.platform() === 'win32' ? 'chcp 65001' : 'zh_CN.UTF-8',
|
||||
TERM: 'xterm-256color',
|
||||
},
|
||||
});
|
||||
|
||||
const instance: TerminalInstance = {
|
||||
pty,
|
||||
lastAccess: Date.now(),
|
||||
sockets: new Set(),
|
||||
isClosing: false,
|
||||
buffer: '', // 初始化终端内容缓存
|
||||
};
|
||||
pty.onData((data: any) => {
|
||||
// 追加数据到 buffer
|
||||
instance.buffer += data;
|
||||
// 发送数据给已连接的 websocket
|
||||
instance.sockets.forEach((ws) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'output', data }));
|
||||
}
|
||||
});
|
||||
});
|
||||
pty.onExit(() => {
|
||||
this.closeTerminal(id);
|
||||
});
|
||||
|
||||
this.terminals.set(id, instance);
|
||||
// 返回生成的 id 及对应实例,方便后续通知客户端使用该 id
|
||||
return { id, instance };
|
||||
}
|
||||
|
||||
closeTerminal (id: string) {
|
||||
const instance = this.terminals.get(id);
|
||||
if (instance) {
|
||||
if (!instance.isClosing) {
|
||||
instance.isClosing = true;
|
||||
if (os.platform() === 'win32') {
|
||||
process.kill(instance.pty.pid);
|
||||
} else {
|
||||
instance.pty.kill();
|
||||
}
|
||||
}
|
||||
instance.sockets.forEach((ws) => ws.close());
|
||||
this.terminals.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
getTerminal (id: string) {
|
||||
return this.terminals.get(id);
|
||||
}
|
||||
|
||||
getTerminalList () {
|
||||
return Array.from(this.terminals.keys()).map((id) => ({
|
||||
id,
|
||||
lastAccess: this.terminals.get(id)!.lastAccess,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export const terminalManager = new TerminalManager();
|
||||
@@ -0,0 +1,19 @@
|
||||
// 插件商店相关类型定义
|
||||
|
||||
export interface PluginStoreItem {
|
||||
id: string; // 插件唯一标识
|
||||
name: string; // 插件名称
|
||||
version: string; // 最新版本
|
||||
description: string; // 插件描述
|
||||
author: string; // 作者
|
||||
homepage?: string; // 主页链接
|
||||
downloadUrl: string; // 下载地址
|
||||
tags?: string[]; // 标签
|
||||
minVersion?: string; // 最低版本要求
|
||||
}
|
||||
|
||||
export interface PluginStoreList {
|
||||
version: string; // 索引版本
|
||||
updateTime: string; // 更新时间
|
||||
plugins: PluginStoreItem[]; // 插件列表
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { OneBotConfig } from '@/napcat-webui-backend/src/onebot/config';
|
||||
export interface LoginListItem {
|
||||
uin: string;
|
||||
uid: string;
|
||||
nickName: string;
|
||||
faceUrl: string;
|
||||
facePath: string;
|
||||
loginType: 1; // 1是二维码登录?
|
||||
isQuickLogin: boolean; // 是否可以快速登录
|
||||
isAutoLogin: boolean; // 是否可以自动登录
|
||||
}
|
||||
|
||||
interface SelfInfo {
|
||||
uid: string;
|
||||
uin: string;
|
||||
nick: string;
|
||||
}
|
||||
export interface WebUiConfigType {
|
||||
host: string;
|
||||
port: number;
|
||||
token: string;
|
||||
loginRate: number;
|
||||
accessControlMode?: 'none' | 'whitelist' | 'blacklist';
|
||||
ipWhitelist?: string[];
|
||||
ipBlacklist?: string[];
|
||||
enableXForwardedFor?: boolean;
|
||||
}
|
||||
export interface WebUiCredentialInnerJson {
|
||||
CreatedTime: number;
|
||||
HashEncoded: string;
|
||||
}
|
||||
|
||||
export interface WebUiCredentialJson {
|
||||
Data: WebUiCredentialInnerJson;
|
||||
Hmac: string;
|
||||
}
|
||||
export enum NapCatCoreWorkingEnv {
|
||||
Unknown = 0,
|
||||
Shell = 1,
|
||||
Framework = 2,
|
||||
}
|
||||
|
||||
export type QQLoginPhase =
|
||||
| 'waiting_qrcode'
|
||||
| 'generating_qrcode'
|
||||
| 'qrcode_scanned'
|
||||
| 'initializing'
|
||||
| 'ready'
|
||||
| 'offline'
|
||||
| 'reconnecting';
|
||||
|
||||
export interface LoginRuntimeType {
|
||||
workingEnv: NapCatCoreWorkingEnv;
|
||||
LoginCurrentTime: number;
|
||||
LoginCurrentRate: number;
|
||||
QQLoginStatus: boolean;
|
||||
QQLoginPhase: QQLoginPhase;
|
||||
QQQRCodeURL: string;
|
||||
QQLoginUin: string;
|
||||
QQLoginInfo: SelfInfo;
|
||||
QQLoginError: string;
|
||||
QQVersion: string;
|
||||
QQDataPath: string;
|
||||
onQQLoginStatusChange: (status: boolean) => Promise<void>;
|
||||
onWebUiTokenChange: (token: string) => Promise<void>;
|
||||
onRefreshQRCode: () => Promise<void>;
|
||||
WebUiConfigQuickFunction: () => Promise<void>;
|
||||
OneBotContext: any | null; // OneBot 上下文,用于调试功能
|
||||
NapCatHelper: {
|
||||
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>;
|
||||
onPasswordLoginRequested: (uin: string, passwordMd5: string) => Promise<{ result: boolean; message: string; needCaptcha?: boolean; proofWaterUrl?: string; needNewDevice?: boolean; jumpUrl?: string; newDevicePullQrCodeSig?: string; }>;
|
||||
onCaptchaLoginRequested: (uin: string, passwordMd5: string, ticket: string, randstr: string, sid: string) => Promise<{ result: boolean; message: string; needNewDevice?: boolean; jumpUrl?: string; newDevicePullQrCodeSig?: string; }>;
|
||||
onNewDeviceLoginRequested: (uin: string, passwordMd5: string, newDevicePullQrCodeSig: string) => Promise<{ result: boolean; message: string; needNewDevice?: boolean; jumpUrl?: string; newDevicePullQrCodeSig?: string; }>;
|
||||
onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;
|
||||
onRestartProcessRequested: () => Promise<{ result: boolean; message: string; }>;
|
||||
QQLoginList: string[];
|
||||
NewQQLoginList: LoginListItem[];
|
||||
};
|
||||
NapCatVersion: string;
|
||||
}
|
||||
|
||||
export default {};
|
||||
@@ -0,0 +1,262 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
|
||||
export const themeType = Type.Object(
|
||||
{
|
||||
dark: Type.Record(Type.String(), Type.String()),
|
||||
light: Type.Record(Type.String(), Type.String()),
|
||||
fontMode: Type.String({ default: 'system' }),
|
||||
},
|
||||
{
|
||||
default: {
|
||||
fontMode: 'system',
|
||||
dark: {
|
||||
'--heroui-background': '0 0% 0%',
|
||||
'--heroui-foreground-50': '240 5.88% 10%',
|
||||
'--heroui-foreground-100': '240 3.7% 15.88%',
|
||||
'--heroui-foreground-200': '240 5.26% 26.08%',
|
||||
'--heroui-foreground-300': '240 5.2% 33.92%',
|
||||
'--heroui-foreground-400': '240 3.83% 46.08%',
|
||||
'--heroui-foreground-500': '240 5.03% 64.9%',
|
||||
'--heroui-foreground-600': '240 4.88% 83.92%',
|
||||
'--heroui-foreground-700': '240 5.88% 90%',
|
||||
'--heroui-foreground-800': '240 4.76% 95.88%',
|
||||
'--heroui-foreground-900': '0 0% 98.04%',
|
||||
'--heroui-foreground': '210 5.56% 92.94%',
|
||||
'--heroui-focus': '212.01999999999998 100% 46.67%',
|
||||
'--heroui-overlay': '0 0% 0%',
|
||||
'--heroui-divider': '0 0% 100%',
|
||||
'--heroui-divider-opacity': '0.15',
|
||||
'--heroui-content1': '240 5.88% 10%',
|
||||
'--heroui-content1-foreground': '0 0% 98.04%',
|
||||
'--heroui-content2': '240 3.7% 15.88%',
|
||||
'--heroui-content2-foreground': '240 4.76% 95.88%',
|
||||
'--heroui-content3': '240 5.26% 26.08%',
|
||||
'--heroui-content3-foreground': '240 5.88% 90%',
|
||||
'--heroui-content4': '240 5.2% 33.92%',
|
||||
'--heroui-content4-foreground': '240 4.88% 83.92%',
|
||||
'--heroui-default-50': '240 5.88% 10%',
|
||||
'--heroui-default-100': '240 3.7% 15.88%',
|
||||
'--heroui-default-200': '240 5.26% 26.08%',
|
||||
'--heroui-default-300': '240 5.2% 33.92%',
|
||||
'--heroui-default-400': '240 3.83% 46.08%',
|
||||
'--heroui-default-500': '240 5.03% 64.9%',
|
||||
'--heroui-default-600': '240 4.88% 83.92%',
|
||||
'--heroui-default-700': '240 5.88% 90%',
|
||||
'--heroui-default-800': '240 4.76% 95.88%',
|
||||
'--heroui-default-900': '0 0% 98.04%',
|
||||
'--heroui-default-foreground': '0 0% 100%',
|
||||
'--heroui-default': '240 5.26% 26.08%',
|
||||
'--heroui-danger-50': '301.89 82.61% 22.55%',
|
||||
'--heroui-danger-100': '308.18 76.39% 28.24%',
|
||||
'--heroui-danger-200': '313.85 70.65% 36.08%',
|
||||
'--heroui-danger-300': '319.73 65.64% 44.51%',
|
||||
'--heroui-danger-400': '325.82 69.62% 53.53%',
|
||||
'--heroui-danger-500': '331.82 75% 65.49%',
|
||||
'--heroui-danger-600': '337.84 83.46% 73.92%',
|
||||
'--heroui-danger-700': '343.42 90.48% 83.53%',
|
||||
'--heroui-danger-800': '350.53 90.48% 91.76%',
|
||||
'--heroui-danger-900': '324 90.91% 95.69%',
|
||||
'--heroui-danger-foreground': '0 0% 100%',
|
||||
'--heroui-danger': '325.82 69.62% 53.53%',
|
||||
'--heroui-primary-50': '340 84.91% 10.39%',
|
||||
'--heroui-primary-100': '339.33 86.54% 20.39%',
|
||||
'--heroui-primary-200': '339.11 85.99% 30.78%',
|
||||
'--heroui-primary-300': '339 86.54% 40.78%',
|
||||
'--heroui-primary-400': '339.2 90.36% 51.18%',
|
||||
'--heroui-primary-500': '339 90% 60.78%',
|
||||
'--heroui-primary-600': '339.11 90.6% 70.78%',
|
||||
'--heroui-primary-700': '339.33 90% 80.39%',
|
||||
'--heroui-primary-800': '340 91.84% 90.39%',
|
||||
'--heroui-primary-900': '339.13 92% 95.1%',
|
||||
'--heroui-primary-foreground': '0 0% 100%',
|
||||
'--heroui-primary': '339.2 90.36% 51.18%',
|
||||
'--heroui-secondary-50': '270 66.67% 9.41%',
|
||||
'--heroui-secondary-100': '270 66.67% 18.82%',
|
||||
'--heroui-secondary-200': '270 66.67% 28.24%',
|
||||
'--heroui-secondary-300': '270 66.67% 37.65%',
|
||||
'--heroui-secondary-400': '270 66.67% 47.06%',
|
||||
'--heroui-secondary-500': '270 59.26% 57.65%',
|
||||
'--heroui-secondary-600': '270 59.26% 68.24%',
|
||||
'--heroui-secondary-700': '270 59.26% 78.82%',
|
||||
'--heroui-secondary-800': '270 59.26% 89.41%',
|
||||
'--heroui-secondary-900': '270 61.54% 94.9%',
|
||||
'--heroui-secondary-foreground': '0 0% 100%',
|
||||
'--heroui-secondary': '270 59.26% 57.65%',
|
||||
'--heroui-success-50': '145.71 77.78% 8.82%',
|
||||
'--heroui-success-100': '146.2 79.78% 17.45%',
|
||||
'--heroui-success-200': '145.79 79.26% 26.47%',
|
||||
'--heroui-success-300': '146.01 79.89% 35.1%',
|
||||
'--heroui-success-400': '145.96 79.46% 43.92%',
|
||||
'--heroui-success-500': '146.01 62.45% 55.1%',
|
||||
'--heroui-success-600': '145.79 62.57% 66.47%',
|
||||
'--heroui-success-700': '146.2 61.74% 77.45%',
|
||||
'--heroui-success-800': '145.71 61.4% 88.82%',
|
||||
'--heroui-success-900': '146.67 64.29% 94.51%',
|
||||
'--heroui-success-foreground': '0 0% 0%',
|
||||
'--heroui-success': '145.96 79.46% 43.92%',
|
||||
'--heroui-warning-50': '37.14 75% 10.98%',
|
||||
'--heroui-warning-100': '37.14 75% 21.96%',
|
||||
'--heroui-warning-200': '36.96 73.96% 33.14%',
|
||||
'--heroui-warning-300': '37.01 74.22% 44.12%',
|
||||
'--heroui-warning-400': '37.03 91.27% 55.1%',
|
||||
'--heroui-warning-500': '37.01 91.26% 64.12%',
|
||||
'--heroui-warning-600': '36.96 91.24% 73.14%',
|
||||
'--heroui-warning-700': '37.14 91.3% 81.96%',
|
||||
'--heroui-warning-800': '37.14 91.3% 90.98%',
|
||||
'--heroui-warning-900': '54.55 91.67% 95.29%',
|
||||
'--heroui-warning-foreground': '0 0% 0%',
|
||||
'--heroui-warning': '37.03 91.27% 55.1%',
|
||||
'--heroui-code-background': '240 5.56% 7.06%',
|
||||
'--heroui-strong': '190.14 94.67% 44.12%',
|
||||
'--heroui-code-mdx': '190.14 94.67% 44.12%',
|
||||
'--heroui-divider-weight': '1px',
|
||||
'--heroui-disabled-opacity': '.5',
|
||||
'--heroui-font-size-tiny': '0.75rem',
|
||||
'--heroui-font-size-small': '0.875rem',
|
||||
'--heroui-font-size-medium': '1rem',
|
||||
'--heroui-font-size-large': '1.125rem',
|
||||
'--heroui-line-height-tiny': '1rem',
|
||||
'--heroui-line-height-small': '1.25rem',
|
||||
'--heroui-line-height-medium': '1.5rem',
|
||||
'--heroui-line-height-large': '1.75rem',
|
||||
'--heroui-radius-small': '8px',
|
||||
'--heroui-radius-medium': '12px',
|
||||
'--heroui-radius-large': '14px',
|
||||
'--heroui-border-width-small': '1px',
|
||||
'--heroui-border-width-medium': '2px',
|
||||
'--heroui-border-width-large': '3px',
|
||||
'--heroui-box-shadow-small':
|
||||
'0px 0px 5px 0px rgba(0, 0, 0, .05), 0px 2px 10px 0px rgba(0, 0, 0, .2), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
|
||||
'--heroui-box-shadow-medium':
|
||||
'0px 0px 15px 0px rgba(0, 0, 0, .06), 0px 2px 30px 0px rgba(0, 0, 0, .22), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
|
||||
'--heroui-box-shadow-large':
|
||||
'0px 0px 30px 0px rgba(0, 0, 0, .07), 0px 30px 60px 0px rgba(0, 0, 0, .26), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
|
||||
'--heroui-hover-opacity': '.9',
|
||||
},
|
||||
light: {
|
||||
'--heroui-background': '0 0% 100%',
|
||||
'--heroui-foreground-50': '240 5.88% 95%',
|
||||
'--heroui-foreground-100': '240 3.7% 90%',
|
||||
'--heroui-foreground-200': '240 5.26% 80%',
|
||||
'--heroui-foreground-300': '240 5.2% 70%',
|
||||
'--heroui-foreground-400': '240 3.83% 60%',
|
||||
'--heroui-foreground-500': '240 5.03% 50%',
|
||||
'--heroui-foreground-600': '240 4.88% 40%',
|
||||
'--heroui-foreground-700': '240 5.88% 30%',
|
||||
'--heroui-foreground-800': '240 4.76% 20%',
|
||||
'--heroui-foreground-900': '0 0% 10%',
|
||||
'--heroui-foreground': '210 5.56% 7.06%',
|
||||
'--heroui-focus': '212.01999999999998 100% 53.33%',
|
||||
'--heroui-overlay': '0 0% 100%',
|
||||
'--heroui-divider': '0 0% 0%',
|
||||
'--heroui-divider-opacity': '0.85',
|
||||
'--heroui-content1': '240 5.88% 95%',
|
||||
'--heroui-content1-foreground': '0 0% 10%',
|
||||
'--heroui-content2': '240 3.7% 90%',
|
||||
'--heroui-content2-foreground': '240 4.76% 20%',
|
||||
'--heroui-content3': '240 5.26% 80%',
|
||||
'--heroui-content3-foreground': '240 5.88% 30%',
|
||||
'--heroui-content4': '240 5.2% 70%',
|
||||
'--heroui-content4-foreground': '240 4.88% 40%',
|
||||
'--heroui-default-50': '240 5.88% 95%',
|
||||
'--heroui-default-100': '240 3.7% 90%',
|
||||
'--heroui-default-200': '240 5.26% 80%',
|
||||
'--heroui-default-300': '240 5.2% 70%',
|
||||
'--heroui-default-400': '240 3.83% 60%',
|
||||
'--heroui-default-500': '240 5.03% 50%',
|
||||
'--heroui-default-600': '240 4.88% 40%',
|
||||
'--heroui-default-700': '240 5.88% 30%',
|
||||
'--heroui-default-800': '240 4.76% 20%',
|
||||
'--heroui-default-900': '0 0% 10%',
|
||||
'--heroui-default-foreground': '0 0% 0%',
|
||||
'--heroui-default': '240 5.26% 80%',
|
||||
'--heroui-danger-50': '324 90.91% 95.69%',
|
||||
'--heroui-danger-100': '350.53 90.48% 91.76%',
|
||||
'--heroui-danger-200': '343.42 90.48% 83.53%',
|
||||
'--heroui-danger-300': '337.84 83.46% 73.92%',
|
||||
'--heroui-danger-400': '331.82 75% 65.49%',
|
||||
'--heroui-danger-500': '325.82 69.62% 53.53%',
|
||||
'--heroui-danger-600': '319.73 65.64% 44.51%',
|
||||
'--heroui-danger-700': '313.85 70.65% 36.08%',
|
||||
'--heroui-danger-800': '308.18 76.39% 28.24%',
|
||||
'--heroui-danger-900': '301.89 82.61% 22.55%',
|
||||
'--heroui-danger-foreground': '0 0% 100%',
|
||||
'--heroui-danger': '325.82 69.62% 53.53%',
|
||||
'--heroui-primary-50': '339.13 92% 95.1%',
|
||||
'--heroui-primary-100': '340 91.84% 90.39%',
|
||||
'--heroui-primary-200': '339.33 90% 80.39%',
|
||||
'--heroui-primary-300': '339.11 90.6% 70.78%',
|
||||
'--heroui-primary-400': '339 90% 60.78%',
|
||||
'--heroui-primary-500': '339.2 90.36% 51.18%',
|
||||
'--heroui-primary-600': '339 86.54% 40.78%',
|
||||
'--heroui-primary-700': '339.11 85.99% 30.78%',
|
||||
'--heroui-primary-800': '339.33 86.54% 20.39%',
|
||||
'--heroui-primary-900': '340 84.91% 10.39%',
|
||||
'--heroui-primary-foreground': '0 0% 100%',
|
||||
'--heroui-primary': '339.2 90.36% 51.18%',
|
||||
'--heroui-secondary-50': '270 61.54% 94.9%',
|
||||
'--heroui-secondary-100': '270 59.26% 89.41%',
|
||||
'--heroui-secondary-200': '270 59.26% 78.82%',
|
||||
'--heroui-secondary-300': '270 59.26% 68.24%',
|
||||
'--heroui-secondary-400': '270 59.26% 57.65%',
|
||||
'--heroui-secondary-500': '270 66.67% 47.06%',
|
||||
'--heroui-secondary-600': '270 66.67% 37.65%',
|
||||
'--heroui-secondary-700': '270 66.67% 28.24%',
|
||||
'--heroui-secondary-800': '270 66.67% 18.82%',
|
||||
'--heroui-secondary-900': '270 66.67% 9.41%',
|
||||
'--heroui-secondary-foreground': '0 0% 100%',
|
||||
'--heroui-secondary': '270 66.67% 47.06%',
|
||||
'--heroui-success-50': '146.67 64.29% 94.51%',
|
||||
'--heroui-success-100': '145.71 61.4% 88.82%',
|
||||
'--heroui-success-200': '146.2 61.74% 77.45%',
|
||||
'--heroui-success-300': '145.79 62.57% 66.47%',
|
||||
'--heroui-success-400': '146.01 62.45% 55.1%',
|
||||
'--heroui-success-500': '145.96 79.46% 43.92%',
|
||||
'--heroui-success-600': '146.01 79.89% 35.1%',
|
||||
'--heroui-success-700': '145.79 79.26% 26.47%',
|
||||
'--heroui-success-800': '146.2 79.78% 17.45%',
|
||||
'--heroui-success-900': '145.71 77.78% 8.82%',
|
||||
'--heroui-success-foreground': '0 0% 0%',
|
||||
'--heroui-success': '145.96 79.46% 43.92%',
|
||||
'--heroui-warning-50': '54.55 91.67% 95.29%',
|
||||
'--heroui-warning-100': '37.14 91.3% 90.98%',
|
||||
'--heroui-warning-200': '37.14 91.3% 81.96%',
|
||||
'--heroui-warning-300': '36.96 91.24% 73.14%',
|
||||
'--heroui-warning-400': '37.01 91.26% 64.12%',
|
||||
'--heroui-warning-500': '37.03 91.27% 55.1%',
|
||||
'--heroui-warning-600': '37.01 74.22% 44.12%',
|
||||
'--heroui-warning-700': '36.96 73.96% 33.14%',
|
||||
'--heroui-warning-800': '37.14 75% 21.96%',
|
||||
'--heroui-warning-900': '37.14 75% 10.98%',
|
||||
'--heroui-warning-foreground': '0 0% 0%',
|
||||
'--heroui-warning': '37.03 91.27% 55.1%',
|
||||
'--heroui-code-background': '221.25 17.39% 18.04%',
|
||||
'--heroui-strong': '316.95 100% 65.29%',
|
||||
'--heroui-code-mdx': '316.95 100% 65.29%',
|
||||
'--heroui-divider-weight': '1px',
|
||||
'--heroui-disabled-opacity': '.5',
|
||||
'--heroui-font-size-tiny': '0.75rem',
|
||||
'--heroui-font-size-small': '0.875rem',
|
||||
'--heroui-font-size-medium': '1rem',
|
||||
'--heroui-font-size-large': '1.125rem',
|
||||
'--heroui-line-height-tiny': '1rem',
|
||||
'--heroui-line-height-small': '1.25rem',
|
||||
'--heroui-line-height-medium': '1.5rem',
|
||||
'--heroui-line-height-large': '1.75rem',
|
||||
'--heroui-radius-small': '8px',
|
||||
'--heroui-radius-medium': '12px',
|
||||
'--heroui-radius-large': '14px',
|
||||
'--heroui-border-width-small': '1px',
|
||||
'--heroui-border-width-medium': '2px',
|
||||
'--heroui-border-width-large': '3px',
|
||||
'--heroui-box-shadow-small':
|
||||
'0px 0px 5px 0px rgba(0, 0, 0, .02), 0px 2px 10px 0px rgba(0, 0, 0, .06), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
|
||||
'--heroui-box-shadow-medium':
|
||||
'0px 0px 15px 0px rgba(0, 0, 0, .03), 0px 2px 30px 0px rgba(0, 0, 0, .08), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
|
||||
'--heroui-box-shadow-large':
|
||||
'0px 0px 30px 0px rgba(0, 0, 0, .04), 0px 30px 60px 0px rgba(0, 0, 0, .12), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
|
||||
'--heroui-hover-opacity': '.8',
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,105 @@
|
||||
import multer from 'multer';
|
||||
import { Request, Response, RequestHandler } from 'express';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
// 修改:使用 Buffer 转码文件名,解决文件上传时乱码问题
|
||||
const decodeFileName = (fileName: string): string => {
|
||||
try {
|
||||
return Buffer.from(fileName, 'binary').toString('utf8');
|
||||
} catch {
|
||||
return fileName;
|
||||
}
|
||||
};
|
||||
|
||||
export const createDiskStorage = (uploadPath: string) => {
|
||||
return multer.diskStorage({
|
||||
destination: (
|
||||
_: Request,
|
||||
file: Express.Multer.File,
|
||||
cb: (error: Error | null, destination: string) => void
|
||||
) => {
|
||||
try {
|
||||
const decodedName = decodeFileName(file.originalname);
|
||||
|
||||
if (!uploadPath) {
|
||||
return cb(new Error('上传路径不能为空'), '');
|
||||
}
|
||||
|
||||
if (isWindows && uploadPath === '\\') {
|
||||
return cb(new Error('根目录不允许上传文件'), '');
|
||||
}
|
||||
|
||||
// 处理文件夹上传的情况
|
||||
if (decodedName.includes('/') || decodedName.includes('\\')) {
|
||||
const fullPath = path.join(uploadPath, path.dirname(decodedName));
|
||||
fs.mkdirSync(fullPath, { recursive: true });
|
||||
cb(null, fullPath);
|
||||
} else {
|
||||
cb(null, uploadPath);
|
||||
}
|
||||
} catch (error) {
|
||||
cb(error as Error, '');
|
||||
}
|
||||
},
|
||||
filename: (_: Request, file: Express.Multer.File, cb: (error: Error | null, filename: string) => void) => {
|
||||
try {
|
||||
const decodedName = decodeFileName(file.originalname);
|
||||
const fileName = path.basename(decodedName);
|
||||
|
||||
// 检查文件是否存在
|
||||
const fullPath = path.join(uploadPath, decodedName);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
const ext = path.extname(fileName);
|
||||
const name = path.basename(fileName, ext);
|
||||
cb(null, `${name}-${randomUUID()}${ext}`);
|
||||
} else {
|
||||
cb(null, fileName);
|
||||
}
|
||||
} catch (error) {
|
||||
cb(error as Error, '');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const createDiskUpload = (uploadPath: string): RequestHandler => {
|
||||
const upload = multer({
|
||||
storage: createDiskStorage(uploadPath),
|
||||
limits: {
|
||||
fileSize: 100 * 1024 * 1024, // 100MB 文件大小限制
|
||||
files: 20, // 最多同时上传20个文件
|
||||
fieldSize: 1024 * 1024, // 1MB 字段大小限制
|
||||
fields: 10, // 最多10个字段
|
||||
},
|
||||
}).array('files');
|
||||
return upload;
|
||||
};
|
||||
|
||||
const diskUploader = (req: Request, res: Response) => {
|
||||
const uploadPath = (req.query['path'] || '') as string;
|
||||
return new Promise((resolve, reject) => {
|
||||
createDiskUpload(uploadPath)(req, res, (error) => {
|
||||
if (error) {
|
||||
// 错误处理
|
||||
if (error.code === 'LIMIT_FILE_SIZE') {
|
||||
return reject(new Error('文件大小超过限制(最大100MB)'));
|
||||
}
|
||||
if (error.code === 'LIMIT_FILE_COUNT') {
|
||||
return reject(new Error('文件数量超过限制(最多20个文件)'));
|
||||
}
|
||||
if (error.code === 'LIMIT_FIELD_VALUE') {
|
||||
return reject(new Error('字段值大小超过限制'));
|
||||
}
|
||||
if (error.code === 'LIMIT_FIELD_COUNT') {
|
||||
return reject(new Error('字段数量超过限制'));
|
||||
}
|
||||
return reject(error);
|
||||
}
|
||||
return resolve(true);
|
||||
});
|
||||
});
|
||||
};
|
||||
export default diskUploader;
|
||||
@@ -0,0 +1,86 @@
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import type { Request, Response, RequestHandler } from 'express';
|
||||
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
||||
|
||||
// 支持的字体格式
|
||||
const SUPPORTED_FONT_EXTENSIONS = ['.woff', '.woff2', '.ttf', '.otf'];
|
||||
|
||||
// 清理旧的字体文件
|
||||
const cleanOldFontFiles = (fontsPath: string) => {
|
||||
try {
|
||||
// 确保字体目录存在
|
||||
if (!fs.existsSync(fontsPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 遍历目录下所有文件
|
||||
const files = fs.readdirSync(fontsPath);
|
||||
|
||||
for (const file of files) {
|
||||
// 检查文件名是否以 webui 或 CustomFont 开头,且是支持的字体扩展名
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
const name = path.basename(file, ext);
|
||||
|
||||
if (SUPPORTED_FONT_EXTENSIONS.includes(ext) && (name === 'webui' || name === 'CustomFont')) {
|
||||
try {
|
||||
fs.unlinkSync(path.join(fontsPath, file));
|
||||
} catch (e) {
|
||||
console.error(`Failed to delete old font file ${file}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to clean old font files:', err);
|
||||
}
|
||||
};
|
||||
|
||||
export const webUIFontStorage = multer.diskStorage({
|
||||
destination: (_, __, cb) => {
|
||||
try {
|
||||
const fontsPath = path.dirname(WebUiConfig.GetWebUIFontPathSync());
|
||||
// 确保字体目录存在
|
||||
fs.mkdirSync(fontsPath, { recursive: true });
|
||||
// 清理旧的字体文件
|
||||
cleanOldFontFiles(fontsPath);
|
||||
cb(null, fontsPath);
|
||||
} catch (error) {
|
||||
// 确保错误信息被正确传递
|
||||
cb(new Error(`创建字体目录失败:${(error as Error).message}`), '');
|
||||
}
|
||||
},
|
||||
filename: (_, file, cb) => {
|
||||
// 强制文件名为 CustomFont,保留原始扩展名
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
cb(null, `CustomFont${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
export const webUIFontUpload: RequestHandler = multer({
|
||||
storage: webUIFontStorage,
|
||||
fileFilter: (_, file, cb) => {
|
||||
// 验证文件类型
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (!SUPPORTED_FONT_EXTENSIONS.includes(ext)) {
|
||||
cb(new Error('只支持 WOFF/WOFF2/TTF/OTF 格式的字体文件'));
|
||||
return;
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
limits: {
|
||||
fileSize: 40 * 1024 * 1024, // 限制40MB
|
||||
},
|
||||
}).single('file');
|
||||
|
||||
const webUIFontUploader = (req: Request, res: Response) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
webUIFontUpload(req, res, (error) => {
|
||||
if (error) {
|
||||
return reject(error);
|
||||
}
|
||||
return resolve(true);
|
||||
});
|
||||
});
|
||||
};
|
||||
export default webUIFontUploader;
|
||||
@@ -0,0 +1 @@
|
||||
export const isEmpty = <T>(data: T) => data === undefined || data === null || data === '';
|
||||
@@ -0,0 +1,862 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import crypto from 'node:crypto';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { protectData, unprotectData } from 'napcat-dpapi';
|
||||
|
||||
const GUID_HEADER = Buffer.from([0x00, 0x00, 0x00, 0x14]);
|
||||
const XOR_KEY = 0x10;
|
||||
|
||||
/** 备份文件统一后缀,形如 `<原文件名>.bak.20251010031234567-1a2b3c4d` */
|
||||
const BACKUP_SUFFIX = '.bak.';
|
||||
/** 兼容历史命名(秒级、无随机后缀)以及新命名(毫秒级+随机后缀) */
|
||||
const BACKUP_SUFFIX_PATTERN = /^\.bak\.\d{8,20}(?:-[0-9a-f]{1,32})?$/i;
|
||||
|
||||
/**
|
||||
* 由 QQ 数据根目录推导 global 目录。
|
||||
* Windows: <dataPath>/nt_qq/global
|
||||
* Linux : <dataPath>/global
|
||||
* macOS : <dataPath>/global
|
||||
*/
|
||||
export function getGlobalDataPath (dataPath: string): string {
|
||||
if (os.platform() === 'win32') {
|
||||
return path.join(dataPath, 'nt_qq', 'global');
|
||||
}
|
||||
return path.join(dataPath, 'global');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成毫秒级(UTC)时间戳,避免同一秒内的连续备份互相覆盖。
|
||||
*/
|
||||
function backupTimestamp (): string {
|
||||
const now = new Date();
|
||||
const pad = (value: number, width = 2) => String(value).padStart(width, '0');
|
||||
return [
|
||||
now.getUTCFullYear(),
|
||||
pad(now.getUTCMonth() + 1),
|
||||
pad(now.getUTCDate()),
|
||||
pad(now.getUTCHours()),
|
||||
pad(now.getUTCMinutes()),
|
||||
pad(now.getUTCSeconds()),
|
||||
pad(now.getUTCMilliseconds(), 3),
|
||||
].join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建备份文件。使用 `COPYFILE_EXCL` 落盘,杜绝同秒/连击时的无感知覆盖。
|
||||
*/
|
||||
function createBackupFile (filePath: string, missingMessage: string): string {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(missingMessage);
|
||||
}
|
||||
const timestamp = backupTimestamp();
|
||||
for (let attempt = 0; attempt < 8; attempt++) {
|
||||
const randomSuffix = crypto.randomBytes(4).toString('hex');
|
||||
const backupPath = `${filePath}${BACKUP_SUFFIX}${timestamp}-${randomSuffix}`;
|
||||
try {
|
||||
fs.copyFileSync(filePath, backupPath, fs.constants.COPYFILE_EXCL);
|
||||
return backupPath;
|
||||
} catch (e) {
|
||||
// 仅在同名冲突时重试,其余错误(权限/磁盘等)直接抛出
|
||||
if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e;
|
||||
}
|
||||
}
|
||||
throw new Error('Failed to create backup: too many name collisions');
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并解析备份文件名,返回可安全读取的绝对路径。
|
||||
*
|
||||
* 安全约束(防止目录穿越与任意文件覆盖):
|
||||
* - 必须是纯文件名,不能包含路径分隔符或 `..`;
|
||||
* - 必须匹配 `<原文件名>.bak.<时间戳>[-随机后缀]`;
|
||||
* - 必须存在于调用方提供的备份白名单中(来自 `readdirSync`)。
|
||||
*/
|
||||
export function resolveBackupPath (filePath: string, backupFileName: string, backupList: string[]): string {
|
||||
if (typeof backupFileName !== 'string' || backupFileName.length === 0) {
|
||||
throw new Error('Backup name is required');
|
||||
}
|
||||
if (
|
||||
backupFileName !== path.basename(backupFileName) ||
|
||||
backupFileName.includes('/') ||
|
||||
backupFileName.includes('\\') ||
|
||||
backupFileName.includes('..')
|
||||
) {
|
||||
throw new Error('Invalid backup name');
|
||||
}
|
||||
|
||||
const baseName = path.basename(filePath);
|
||||
if (!backupFileName.startsWith(baseName)) {
|
||||
throw new Error('Invalid backup name');
|
||||
}
|
||||
const suffix = backupFileName.slice(baseName.length);
|
||||
if (!BACKUP_SUFFIX_PATTERN.test(suffix)) {
|
||||
throw new Error('Invalid backup name');
|
||||
}
|
||||
if (!backupList.includes(backupFileName)) {
|
||||
throw new Error('Backup file not found');
|
||||
}
|
||||
|
||||
return path.join(path.dirname(filePath), backupFileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unprotects data using Windows DPAPI via napcat-dpapi.
|
||||
*/
|
||||
function dpapiUnprotect (filePath: string): Buffer {
|
||||
const encrypted = fs.readFileSync(filePath);
|
||||
return Buffer.from(unprotectData(encrypted, null, 'CurrentUser'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Protects data using Windows DPAPI and writes to file.
|
||||
*/
|
||||
function dpapiProtectAndWrite (filePath: string, data: Buffer): void {
|
||||
const encrypted = protectData(data, null, 'CurrentUser');
|
||||
fs.writeFileSync(filePath, Buffer.from(encrypted));
|
||||
}
|
||||
|
||||
export class Registry20Utils {
|
||||
static getRegistryPath (dataPath: string): string {
|
||||
return path.join(getGlobalDataPath(dataPath), 'nt_data', 'msf', 'Registry20');
|
||||
}
|
||||
|
||||
static readGuid (registryPath: string): string {
|
||||
if (!fs.existsSync(registryPath)) {
|
||||
throw new Error('Registry20 file not found');
|
||||
}
|
||||
if (os.platform() !== 'win32') {
|
||||
throw new Error('Registry20 decryption is only supported on Windows');
|
||||
}
|
||||
|
||||
const decrypted = dpapiUnprotect(registryPath);
|
||||
|
||||
if (decrypted.length < 20) {
|
||||
throw new Error(`Decrypted data too short (got ${decrypted.length} bytes, need 20)`);
|
||||
}
|
||||
|
||||
// Decode payload: header(4) + obfuscated_guid(16)
|
||||
const payload = decrypted.subarray(4, 20);
|
||||
const guidBuf = Buffer.alloc(16);
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const payloadByte = payload[i] ?? 0;
|
||||
guidBuf[i] = (~(payloadByte ^ XOR_KEY)) & 0xFF;
|
||||
}
|
||||
|
||||
return guidBuf.toString('hex');
|
||||
}
|
||||
|
||||
static writeGuid (registryPath: string, guidHex: string): void {
|
||||
if (guidHex.length !== 32) {
|
||||
throw new Error('Invalid GUID length, must be 32 hex chars');
|
||||
}
|
||||
if (os.platform() !== 'win32') {
|
||||
throw new Error('Registry20 encryption is only supported on Windows');
|
||||
}
|
||||
|
||||
const guidBytes = Buffer.from(guidHex, 'hex');
|
||||
const payload = Buffer.alloc(16);
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const guidByte = guidBytes[i] ?? 0;
|
||||
payload[i] = XOR_KEY ^ (~guidByte & 0xFF);
|
||||
}
|
||||
|
||||
const data = Buffer.concat([GUID_HEADER, payload]);
|
||||
|
||||
// Create directory if not exists
|
||||
const dir = path.dirname(registryPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
dpapiProtectAndWrite(registryPath, data);
|
||||
}
|
||||
|
||||
static getBackups (registryPath: string): string[] {
|
||||
const dir = path.dirname(registryPath);
|
||||
const baseName = path.basename(registryPath);
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
|
||||
return fs.readdirSync(dir)
|
||||
.filter(f => f.startsWith(`${baseName}.bak.`))
|
||||
.sort()
|
||||
.reverse();
|
||||
}
|
||||
|
||||
static backup (registryPath: string): string {
|
||||
return createBackupFile(registryPath, 'Registry20 does not exist');
|
||||
}
|
||||
|
||||
static restore (registryPath: string, backupFileName: string): void {
|
||||
const backupPath = resolveBackupPath(registryPath, backupFileName, Registry20Utils.getBackups(registryPath));
|
||||
// 内容校验: DPAPI 密文不可能为空
|
||||
if (fs.statSync(backupPath).size === 0) {
|
||||
throw new Error('Invalid backup content');
|
||||
}
|
||||
fs.copyFileSync(backupPath, registryPath);
|
||||
}
|
||||
|
||||
static delete (registryPath: string): void {
|
||||
if (fs.existsSync(registryPath)) {
|
||||
fs.unlinkSync(registryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Linux machine-info 工具类
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* ROT13 编解码 (自逆运算)
|
||||
* 字母偏移13位,数字和符号不变
|
||||
*/
|
||||
function rot13 (s: string): string {
|
||||
return s.replace(/[a-zA-Z]/g, (c) => {
|
||||
const base = c <= 'Z' ? 65 : 97;
|
||||
return String.fromCharCode(((c.charCodeAt(0) - base + 13) % 26) + base);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Linux 平台 machine-info 文件工具类
|
||||
*
|
||||
* 文件格式 (逆向自 machine_guid_util.cc):
|
||||
* [4字节 BE uint32 长度 N] [N字节 ROT13 编码的 MAC 字符串]
|
||||
* - MAC 格式: xx-xx-xx-xx-xx-xx (17 字符)
|
||||
* - ROT13: 字母偏移13位, 数字和 '-' 不变
|
||||
*
|
||||
* GUID 生成算法:
|
||||
* GUID = MD5( /etc/machine-id + MAC地址 )
|
||||
*/
|
||||
export class MachineInfoUtils {
|
||||
/**
|
||||
* 获取 machine-info 文件路径
|
||||
*/
|
||||
static getMachineInfoPath (dataPath: string): string {
|
||||
return path.join(getGlobalDataPath(dataPath), 'nt_data', 'msf', 'machine-info');
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 machine-info 文件读取 MAC 地址
|
||||
*/
|
||||
static readMac (machineInfoPath: string): string {
|
||||
if (!fs.existsSync(machineInfoPath)) {
|
||||
throw new Error('machine-info file not found');
|
||||
}
|
||||
|
||||
const data = fs.readFileSync(machineInfoPath);
|
||||
|
||||
if (data.length < 4) {
|
||||
throw new Error(`machine-info data too short: ${data.length} < 4 bytes`);
|
||||
}
|
||||
|
||||
const length = data.readUInt32BE(0);
|
||||
|
||||
if (length >= 18) {
|
||||
throw new Error(`MAC string length abnormal: ${length} >= 18`);
|
||||
}
|
||||
|
||||
if (data.length < 4 + length) {
|
||||
throw new Error(`machine-info data incomplete: need ${4 + length} bytes, got ${data.length}`);
|
||||
}
|
||||
|
||||
const rot13Str = data.subarray(4, 4 + length).toString('ascii');
|
||||
return rot13(rot13Str);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 MAC 地址写入 machine-info 文件
|
||||
*/
|
||||
static writeMac (machineInfoPath: string, mac: string): void {
|
||||
mac = mac.trim().toLowerCase();
|
||||
|
||||
// 验证 MAC 格式: xx-xx-xx-xx-xx-xx
|
||||
if (!/^[0-9a-f]{2}(-[0-9a-f]{2}){5}$/.test(mac)) {
|
||||
throw new Error('Invalid MAC format, must be xx-xx-xx-xx-xx-xx');
|
||||
}
|
||||
|
||||
const encoded = rot13(mac);
|
||||
const length = encoded.length;
|
||||
const buf = Buffer.alloc(4 + length);
|
||||
buf.writeUInt32BE(length, 0);
|
||||
buf.write(encoded, 4, 'ascii');
|
||||
|
||||
// 确保目录存在
|
||||
const dir = path.dirname(machineInfoPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(machineInfoPath, buf);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 /etc/machine-id
|
||||
*/
|
||||
static readMachineId (): string {
|
||||
const machineIdPath = '/etc/machine-id';
|
||||
if (!fs.existsSync(machineIdPath)) {
|
||||
throw new Error('/etc/machine-id not found');
|
||||
}
|
||||
return fs.readFileSync(machineIdPath, 'utf-8').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 Linux GUID = MD5(machine-id + MAC)
|
||||
*/
|
||||
static computeGuid (machineId: string, mac: string): string {
|
||||
const md5 = crypto.createHash('md5');
|
||||
md5.update(machineId, 'ascii');
|
||||
md5.update(mac, 'ascii');
|
||||
return md5.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取备份列表
|
||||
*/
|
||||
static getBackups (machineInfoPath: string): string[] {
|
||||
const dir = path.dirname(machineInfoPath);
|
||||
const baseName = path.basename(machineInfoPath);
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
|
||||
return fs.readdirSync(dir)
|
||||
.filter(f => f.startsWith(`${baseName}.bak.`))
|
||||
.sort()
|
||||
.reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建备份
|
||||
*/
|
||||
static backup (machineInfoPath: string): string {
|
||||
return createBackupFile(machineInfoPath, 'machine-info file does not exist');
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复备份
|
||||
*/
|
||||
static restore (machineInfoPath: string, backupFileName: string): void {
|
||||
const backupPath = resolveBackupPath(
|
||||
machineInfoPath,
|
||||
backupFileName,
|
||||
MachineInfoUtils.getBackups(machineInfoPath)
|
||||
);
|
||||
// 内容校验: 必须符合 machine-info 的 be32 长度 + ROT13 结构
|
||||
MachineInfoUtils.readMac(backupPath);
|
||||
fs.copyFileSync(backupPath, machineInfoPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 machine-info
|
||||
*/
|
||||
static delete (machineInfoPath: string): void {
|
||||
if (fs.existsSync(machineInfoPath)) {
|
||||
fs.unlinkSync(machineInfoPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// macOS TEA 容器 / machineid-info 工具类
|
||||
// ============================================================
|
||||
|
||||
const TEA_DELTA = 0x9E3779B9;
|
||||
|
||||
function teaKeyWords (key: Buffer): [number, number, number, number] {
|
||||
const padded = Buffer.concat([key, Buffer.alloc(Math.max(0, 16 - key.length))]).subarray(0, 16);
|
||||
return [
|
||||
padded.readUInt32BE(0),
|
||||
padded.readUInt32BE(4),
|
||||
padded.readUInt32BE(8),
|
||||
padded.readUInt32BE(12),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 单块 TEA 加密 (16 轮, delta 0x9E3779B9, 密钥与数据均按大端处理)
|
||||
* 逆向自 macOS wrapper.node sub_4127C1C
|
||||
*/
|
||||
export function teaEncryptBlock (block: Buffer, key: Buffer): Buffer {
|
||||
if (block.length !== 8) {
|
||||
throw new Error('TEA block must be 8 bytes');
|
||||
}
|
||||
let v0 = block.readUInt32BE(0);
|
||||
let v1 = block.readUInt32BE(4);
|
||||
const [k0, k1, k2, k3] = teaKeyWords(key);
|
||||
let sum = TEA_DELTA;
|
||||
for (let i = 0; i < 16; i++) {
|
||||
v0 = (v0 + ((((v1 << 4) + k0) ^ (v1 + sum) ^ ((v1 >>> 5) + k1)) >>> 0)) >>> 0;
|
||||
v1 = (v1 + ((((v0 << 4) + k2) ^ (v0 + sum) ^ ((v0 >>> 5) + k3)) >>> 0)) >>> 0;
|
||||
sum = (sum + TEA_DELTA) >>> 0;
|
||||
}
|
||||
const out = Buffer.alloc(8);
|
||||
out.writeUInt32BE(v0, 0);
|
||||
out.writeUInt32BE(v1, 4);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单块 TEA 解密 -> sub_4127CE5
|
||||
*/
|
||||
export function teaDecryptBlock (block: Buffer, key: Buffer): Buffer {
|
||||
if (block.length !== 8) {
|
||||
throw new Error('TEA block must be 8 bytes');
|
||||
}
|
||||
let v0 = block.readUInt32BE(0);
|
||||
let v1 = block.readUInt32BE(4);
|
||||
const [k0, k1, k2, k3] = teaKeyWords(key);
|
||||
let sum = (TEA_DELTA * 16) >>> 0;
|
||||
for (let i = 0; i < 16; i++) {
|
||||
v1 = (v1 - ((((v0 << 4) + k2) ^ (v0 + sum) ^ ((v0 >>> 5) + k3)) >>> 0)) >>> 0;
|
||||
v0 = (v0 - ((((v1 << 4) + k0) ^ (v1 + sum) ^ ((v1 >>> 5) + k1)) >>> 0)) >>> 0;
|
||||
sum = (sum - TEA_DELTA) >>> 0;
|
||||
}
|
||||
const out = Buffer.alloc(8);
|
||||
out.writeUInt32BE(v0, 0);
|
||||
out.writeUInt32BE(v1, 4);
|
||||
return out;
|
||||
}
|
||||
|
||||
function xor8 (a: Buffer, b: Buffer): Buffer {
|
||||
const out = Buffer.alloc(8);
|
||||
for (let i = 0; i < 8; i++) out[i] = a[i]! ^ b[i]!;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* QQ 加密容器封装 -> sub_414B658
|
||||
* P_i = X_i XOR C_(i-1)
|
||||
* C_i = E(P_i) XOR P_(i-1)
|
||||
* X = [head][v8 random][2 random] + payload + 00*7
|
||||
*/
|
||||
export function containerEncrypt (payload: Buffer, key: Buffer, rng: (n: number) => Buffer = crypto.randomBytes): Buffer {
|
||||
const n = payload.length;
|
||||
// 注意: Python 的 % 结果非负, JS 对负数会返回负数, 这里手动取模
|
||||
const v8 = ((8 - ((n + 10) % 8)) % 8);
|
||||
const head = Buffer.from([(rng(1)[0]! & 0xF8) | v8]);
|
||||
const stream = Buffer.concat([head, rng(v8), rng(2), payload, Buffer.alloc(7)]);
|
||||
const out = Buffer.alloc(stream.length);
|
||||
let pPrev: Buffer = Buffer.alloc(8);
|
||||
let cPrev: Buffer = Buffer.alloc(8);
|
||||
for (let i = 0; i < stream.length; i += 8) {
|
||||
const xI = stream.subarray(i, i + 8);
|
||||
const pI = xor8(xI, cPrev);
|
||||
const cI = xor8(teaEncryptBlock(pI, key), pPrev);
|
||||
cI.copy(out, i);
|
||||
pPrev = pI;
|
||||
cPrev = cI;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* QQ 加密容器解封 -> sub_4128416, 失败返回 null
|
||||
*/
|
||||
export function containerDecrypt (data: Buffer, key: Buffer, checkTail = true): Buffer | null {
|
||||
if (data.length < 16 || data.length % 8 !== 0) return null;
|
||||
const stream = Buffer.alloc(data.length);
|
||||
let pPrev: Buffer = Buffer.alloc(8);
|
||||
let cPrev: Buffer = Buffer.alloc(8);
|
||||
for (let i = 0; i < data.length; i += 8) {
|
||||
const cI = data.subarray(i, i + 8);
|
||||
const pI = i === 0 ? teaDecryptBlock(cI, key) : teaDecryptBlock(xor8(pPrev, cI), key);
|
||||
xor8(pI, cPrev).copy(stream, i);
|
||||
pPrev = pI;
|
||||
cPrev = cI;
|
||||
}
|
||||
const v8 = stream[0]! & 0x07;
|
||||
const n = data.length - v8 - 10;
|
||||
if (n < 0) return null;
|
||||
const start = v8 + 3;
|
||||
const payload = stream.subarray(start, start + n);
|
||||
const tail = stream.subarray(start + n);
|
||||
if (checkTail && tail.some((b) => b !== 0)) return null;
|
||||
return Buffer.from(payload);
|
||||
}
|
||||
|
||||
function buildFields (...chunks: Buffer[]): Buffer {
|
||||
const parts: Buffer[] = [];
|
||||
for (const chunk of chunks) {
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(chunk.length, 0);
|
||||
parts.push(len, chunk);
|
||||
}
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
function parseFields (plain: Buffer): Buffer[] {
|
||||
const fields: Buffer[] = [];
|
||||
let off = 0;
|
||||
while (off + 4 <= plain.length) {
|
||||
const len = plain.readUInt32BE(off);
|
||||
off += 4;
|
||||
if (off + len > plain.length) {
|
||||
fields.push(plain.subarray(off));
|
||||
break;
|
||||
}
|
||||
fields.push(plain.subarray(off, off + len));
|
||||
off += len;
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
export interface MacMachineInfo {
|
||||
machineId: Buffer;
|
||||
sn: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* macOS machine-info 工具类
|
||||
*
|
||||
* 文件位置 (相对 QQ global 目录):
|
||||
* nt_data/msf/machineid-info —— TEA 容器, 明文 = be32(8)+machine_id[8] + be32(len)+sn
|
||||
* nt_data/msf/machine-info —— 明文 be32(8) + [MAC(6) + 00 00]
|
||||
* nt_data/msf/machine-guid —— 明文 16 字节 guid
|
||||
*/
|
||||
export class MacMachineInfoUtils {
|
||||
static getMsfDir (dataPathGlobal: string): string {
|
||||
return path.join(dataPathGlobal, 'nt_data', 'msf');
|
||||
}
|
||||
|
||||
static getMachineIdInfoPath (dataPathGlobal: string): string {
|
||||
return path.join(MacMachineInfoUtils.getMsfDir(dataPathGlobal), 'machineid-info');
|
||||
}
|
||||
|
||||
static getMachineInfoPath (dataPathGlobal: string): string {
|
||||
return path.join(MacMachineInfoUtils.getMsfDir(dataPathGlobal), 'machine-info');
|
||||
}
|
||||
|
||||
static getMachineGuidPath (dataPathGlobal: string): string {
|
||||
return path.join(MacMachineInfoUtils.getMsfDir(dataPathGlobal), 'machine-guid');
|
||||
}
|
||||
|
||||
/**
|
||||
* TEA 密钥 = IOPlatformUUID 字符串前 16 字节 (不足补 0)
|
||||
*/
|
||||
static keyFromUuid (uuid: string): Buffer {
|
||||
return Buffer.concat([Buffer.from(uuid, 'utf8').subarray(0, 16), Buffer.alloc(16)]).subarray(0, 16);
|
||||
}
|
||||
|
||||
static guidToUuidStyle (guid: Buffer, upper = true): string {
|
||||
const s = guid.toString('hex');
|
||||
const out = `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20, 32)}`;
|
||||
return upper ? out.toUpperCase() : out;
|
||||
}
|
||||
|
||||
static buildMachineIdInfo (machineId: Buffer, sn: string): Buffer {
|
||||
if (machineId.length !== 8) {
|
||||
throw new Error('machine_id must be 8 bytes');
|
||||
}
|
||||
return buildFields(machineId, Buffer.from(sn, 'utf8'));
|
||||
}
|
||||
|
||||
static parseMachineIdInfo (plain: Buffer): MacMachineInfo {
|
||||
const fields = parseFields(plain);
|
||||
const machineId = fields[0]?.subarray(0, 8) ?? Buffer.alloc(0);
|
||||
const sn = fields[1] ? fields[1].toString('utf8') : '';
|
||||
return { machineId: Buffer.from(machineId), sn };
|
||||
}
|
||||
|
||||
/**
|
||||
* guid = MD5(machine_id[8] || sn) -> sub_414E158
|
||||
*/
|
||||
static computeGuid (machineId: Buffer, sn: string): Buffer {
|
||||
const md5 = crypto.createHash('md5');
|
||||
md5.update(machineId.subarray(0, 8));
|
||||
md5.update(sn, 'utf8');
|
||||
return md5.digest();
|
||||
}
|
||||
|
||||
static readMachineIdInfo (filePath: string, key: Buffer, checkTail = true): MacMachineInfo | null {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
const plain = containerDecrypt(fs.readFileSync(filePath), key, checkTail);
|
||||
if (!plain) return null;
|
||||
return MacMachineInfoUtils.parseMachineIdInfo(plain);
|
||||
}
|
||||
|
||||
static writeMachineIdInfo (filePath: string, machineId: Buffer, sn: string, key: Buffer): void {
|
||||
const plain = MacMachineInfoUtils.buildMachineIdInfo(machineId, sn);
|
||||
const encrypted = containerEncrypt(plain, key);
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(filePath, encrypted);
|
||||
}
|
||||
|
||||
/**
|
||||
* machine-info 是明文 be32(8) + 8 字节 (MAC(6) + 00 00)
|
||||
*/
|
||||
static readMachineId (filePath: string): Buffer | null {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
const data = fs.readFileSync(filePath);
|
||||
if (data.length < 4) return null;
|
||||
const len = data.readUInt32BE(0);
|
||||
if (len < 8 || data.length < 4 + len) return null;
|
||||
return Buffer.from(data.subarray(4, 4 + 8));
|
||||
}
|
||||
|
||||
static readMachineGuid (filePath: string): Buffer | null {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
const data = fs.readFileSync(filePath);
|
||||
if (data.length < 16) return null;
|
||||
return Buffer.from(data.subarray(0, 16));
|
||||
}
|
||||
|
||||
static getBackups (filePath: string): string[] {
|
||||
const dir = path.dirname(filePath);
|
||||
const baseName = path.basename(filePath);
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
|
||||
return fs.readdirSync(dir)
|
||||
.filter(f => f.startsWith(`${baseName}.bak.`))
|
||||
.sort()
|
||||
.reverse();
|
||||
}
|
||||
|
||||
static backup (filePath: string): string {
|
||||
return createBackupFile(filePath, 'machineid-info file does not exist');
|
||||
}
|
||||
|
||||
static restore (filePath: string, backupFileName: string): void {
|
||||
const backupPath = resolveBackupPath(filePath, backupFileName, MacMachineInfoUtils.getBackups(filePath));
|
||||
// 内容校验: TEA 容器按 8 字节分组, 至少 16 字节
|
||||
const size = fs.statSync(backupPath).size;
|
||||
if (size < 16 || size % 8 !== 0) {
|
||||
throw new Error('Invalid backup content');
|
||||
}
|
||||
fs.copyFileSync(backupPath, filePath);
|
||||
}
|
||||
|
||||
static delete (filePath: string): void {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// macOS 本机硬件读取 (与 wrapper.node 的行为保持一致)
|
||||
// ============================================================
|
||||
|
||||
interface HardwareCacheEntry<T> {
|
||||
promise: Promise<T>;
|
||||
}
|
||||
|
||||
const hardwareCache = new Map<string, HardwareCacheEntry<unknown>>();
|
||||
|
||||
/**
|
||||
* 进程内硬件信息缓存。硬件标识在进程生命周期内不会变化,
|
||||
* 缓存 `Promise` 可避免并发请求重复拉起 `ioreg`/`diskutil`。
|
||||
*/
|
||||
function cachedHardware<T> (key: string, factory: () => Promise<T>): Promise<T> {
|
||||
const existing = hardwareCache.get(key);
|
||||
if (existing) return existing.promise as Promise<T>;
|
||||
const entry: HardwareCacheEntry<T> = { promise: factory() };
|
||||
hardwareCache.set(key, entry as HardwareCacheEntry<unknown>);
|
||||
return entry.promise;
|
||||
}
|
||||
|
||||
function runCommand (file: string, args: string[]): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
execFile(file, args, { timeout: 10000, encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 }, (error, stdout) => {
|
||||
if (error) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
resolve(stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 `ioreg` 输出中解析 `"property" = <value>`。
|
||||
*/
|
||||
function parseIoregProperty (out: string, property: string): string | null {
|
||||
for (const line of out.split('\n')) {
|
||||
if (line.includes(`"${property}"`) && line.includes('=')) {
|
||||
const raw = line.split('=').slice(1).join('=').trim();
|
||||
return raw.replace(/^"|"$/g, '');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行 `ioreg -rd1 -c <className>` 并缓存结果。
|
||||
*/
|
||||
function ioregClassOutput (className: string): Promise<string | null> {
|
||||
return cachedHardware(`ioreg:${className}`, () => runCommand('ioreg', ['-rd1', '-c', className]));
|
||||
}
|
||||
|
||||
/**
|
||||
* TEA 密钥来源: IOPlatformUUID
|
||||
*/
|
||||
export function getMacPlatformUuid (): Promise<string | null> {
|
||||
return cachedHardware('mac:platformUuid', async () => {
|
||||
const out = await ioregClassOutput('IOPlatformExpertDevice');
|
||||
return out ? parseIoregProperty(out, 'IOPlatformUUID') : null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 序列号回退: IOPlatformSerialNumber
|
||||
*/
|
||||
export function getMacSerialNumber (): Promise<string | null> {
|
||||
return cachedHardware('mac:serialNumber', async () => {
|
||||
const out = await ioregClassOutput('IOPlatformExpertDevice');
|
||||
return out ? parseIoregProperty(out, 'IOPlatformSerialNumber') : null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 优先 /dev/disk0 的序列号 (对应 wrapper.node sub_414CD1C)
|
||||
*/
|
||||
export function getMacDiskSerial (): Promise<string | null> {
|
||||
return cachedHardware('mac:diskSerial', async () => {
|
||||
const out = await runCommand('diskutil', ['info', '/dev/disk0']);
|
||||
if (!out) return null;
|
||||
for (const line of out.split('\n')) {
|
||||
if (line.includes('Serial Number') || line.includes('设备序列号')) {
|
||||
return line.split(':').slice(1).join(':').trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* macOS 序列号: 优先 /dev/disk0, 失败回退 IOPlatformSerialNumber
|
||||
*/
|
||||
export async function getMacSn (): Promise<string> {
|
||||
return (await getMacDiskSerial()) ?? (await getMacSerialNumber()) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 ioreg 节点行中提取 `"IOMACAddress" = <...>` 的 6 字节 MAC。
|
||||
*/
|
||||
function parseIomacAddress (line: string): Buffer | null {
|
||||
const blob = line.split('=').slice(1).join('=');
|
||||
const hex = (blob.match(/[0-9a-fA-F]{2}/g) ?? []).join('');
|
||||
if (hex.length >= 12) {
|
||||
return Buffer.from(hex.slice(0, 12), 'hex');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 按 `+-o ` 节点行切分 ioreg 输出。 */
|
||||
function splitIoregNodes (out: string): string[] {
|
||||
const nodes: string[] = [];
|
||||
let current: string[] = [];
|
||||
for (const line of out.split('\n')) {
|
||||
if (line.includes('+-o ')) {
|
||||
if (current.length > 0) nodes.push(current.join('\n'));
|
||||
current = [line];
|
||||
} else if (current.length > 0) {
|
||||
current.push(line);
|
||||
}
|
||||
}
|
||||
if (current.length > 0) nodes.push(current.join('\n'));
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/** 从节点文本中取 IOMACAddress。 */
|
||||
function nodeMac (node: string): Buffer | null {
|
||||
const line = node.split('\n').find((l) => l.includes('"IOMACAddress"'));
|
||||
return line ? parseIomacAddress(line) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主网卡 MAC。
|
||||
*
|
||||
* wrapper.node 取的是 IOEthernetInterface 上(或其父控制器上)的 IOMACAddress。
|
||||
* 真实 ioreg 层级里 IOMACAddress 通常挂在父级控制器(如 en0 对应的
|
||||
* IOEthernetController)上,且同一层级下可能有多个接口,因此这里:
|
||||
* 1. 优先匹配 BSD Name 为 en0 的接口;
|
||||
* 2. 其次匹配第一个 USB/以太网控制器上的接口;
|
||||
* 3. 若接口自身没有该属性,则回到 `ioreg -c IOEthernetInterface` 的整个子树中查找,
|
||||
* 兼容 IOMACAddress 挂在父级控制器的情况。
|
||||
*/
|
||||
export function getMacPrimaryMac (): Promise<Buffer | null> {
|
||||
return cachedHardware('mac:primaryMac', async () => {
|
||||
const ifOut = await ioregClassOutput('IOEthernetInterface');
|
||||
const nodes = ifOut ? splitIoregNodes(ifOut) : [];
|
||||
const en0 = nodes.find((node) => node.includes('"BSD Name" = "en0"'));
|
||||
|
||||
// 1. en0 接口自身的 IOMACAddress
|
||||
if (en0) {
|
||||
const mac = nodeMac(en0);
|
||||
if (mac) return mac;
|
||||
}
|
||||
|
||||
// 2. 父级以太网控制器上的 IOMACAddress(真实机器上多数情况下挂在这里)
|
||||
const controllerOut = await ioregClassOutput('IOEthernetController');
|
||||
if (controllerOut) {
|
||||
for (const node of splitIoregNodes(controllerOut)) {
|
||||
const mac = nodeMac(node);
|
||||
if (mac) return mac;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 任意 IOEthernetInterface 自身的 IOMACAddress
|
||||
for (const node of nodes) {
|
||||
const mac = nodeMac(node);
|
||||
if (mac) return mac;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
export interface MacGuidInfo {
|
||||
guid: string;
|
||||
guidUuid: string;
|
||||
machineId: string;
|
||||
sn: string;
|
||||
mac: string;
|
||||
platformUuid: string;
|
||||
source: 'machineid-info' | 'mac-fallback';
|
||||
}
|
||||
|
||||
/**
|
||||
* 主流程 (对应 wrapper.node GetMachineGuid4MacOS):
|
||||
* guid = MD5(machine_id[8] || sn)
|
||||
* machine_id 优先从 machineid-info (TEA) 读取, 否则回退成 MAC + 00 00
|
||||
*/
|
||||
export async function computeMacGuidInfo (dataPathGlobal: string, uuidOverride?: string, snOverride?: string): Promise<MacGuidInfo> {
|
||||
const platformUuid = uuidOverride ?? (await getMacPlatformUuid()) ?? '';
|
||||
const key = MacMachineInfoUtils.keyFromUuid(platformUuid);
|
||||
const sn = snOverride ?? (await getMacSn());
|
||||
const machineIdInfoPath = MacMachineInfoUtils.getMachineIdInfoPath(dataPathGlobal);
|
||||
const primaryMac = await getMacPrimaryMac();
|
||||
|
||||
let machineId: Buffer | null = null;
|
||||
let source: MacGuidInfo['source'] = 'machineid-info';
|
||||
let snValue = sn;
|
||||
|
||||
const fromFile = MacMachineInfoUtils.readMachineIdInfo(machineIdInfoPath, key);
|
||||
if (fromFile && fromFile.machineId.length === 8) {
|
||||
machineId = fromFile.machineId;
|
||||
if (!snOverride && fromFile.sn) snValue = fromFile.sn;
|
||||
} else {
|
||||
const mac = primaryMac;
|
||||
if (!mac) {
|
||||
throw new Error('Unable to determine machine_id (no machineid-info and no MAC)');
|
||||
}
|
||||
machineId = Buffer.concat([mac, Buffer.from([0, 0])]);
|
||||
source = 'mac-fallback';
|
||||
}
|
||||
|
||||
const guid = MacMachineInfoUtils.computeGuid(machineId, snValue);
|
||||
return {
|
||||
guid: guid.toString('hex'),
|
||||
guidUuid: MacMachineInfoUtils.guidToUuidStyle(guid),
|
||||
machineId: machineId.toString('hex'),
|
||||
sn: snValue,
|
||||
mac: primaryMac?.toString('hex') ?? '',
|
||||
platformUuid,
|
||||
source,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export function deepMerge<T extends Record<string, any>> (target: T, source: Partial<T>): T {
|
||||
for (const key in source) {
|
||||
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||
// 如果 source[key] 为 undefined,则跳过(保留 target[key])
|
||||
if (source[key] === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
target[key] !== undefined &&
|
||||
typeof target[key] === 'object' &&
|
||||
!Array.isArray(target[key]) &&
|
||||
typeof source[key] === 'object' &&
|
||||
!Array.isArray(source[key])
|
||||
) {
|
||||
target[key] = deepMerge({ ...target[key] }, source[key]!) as T[Extract<keyof T, string>];
|
||||
} else {
|
||||
target[key] = source[key]! as T[Extract<keyof T, string>];
|
||||
}
|
||||
}
|
||||
}
|
||||
return target;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { ResponseCode, HttpStatusCode } from '@/napcat-webui-backend/src/const/status';
|
||||
|
||||
export const sendResponse = <T>(
|
||||
res: Response,
|
||||
data?: T,
|
||||
code: ResponseCode = 0,
|
||||
message = 'success',
|
||||
useSend: boolean = false
|
||||
) => {
|
||||
const result = {
|
||||
code,
|
||||
message,
|
||||
data,
|
||||
};
|
||||
if (useSend) {
|
||||
res.status(HttpStatusCode.OK).send(JSON.stringify(result));
|
||||
return;
|
||||
}
|
||||
res.status(HttpStatusCode.OK).json(result);
|
||||
};
|
||||
|
||||
export const sendError = (res: Response, message = 'error', useSend: boolean = false) => {
|
||||
const result = {
|
||||
code: ResponseCode.Error,
|
||||
message,
|
||||
};
|
||||
if (useSend) {
|
||||
res.status(HttpStatusCode.OK).send(JSON.stringify(result));
|
||||
return;
|
||||
}
|
||||
res.status(HttpStatusCode.OK).json(result);
|
||||
};
|
||||
|
||||
export const sendSuccess = <T>(res: Response, data?: T, message = 'success', useSend: boolean = false) => {
|
||||
const result = {
|
||||
code: ResponseCode.Success,
|
||||
data,
|
||||
message,
|
||||
};
|
||||
if (useSend) {
|
||||
res.status(HttpStatusCode.OK).send(JSON.stringify(result));
|
||||
return;
|
||||
}
|
||||
res.status(HttpStatusCode.OK).json(result);
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @file URL工具
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import { isIP } from 'node:net';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
type Protocol = 'http' | 'https';
|
||||
|
||||
let isDockerCached: boolean;
|
||||
|
||||
function hasDockerEnv () {
|
||||
try {
|
||||
fs.statSync('/.dockerenv');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function hasDockerCGroup () {
|
||||
try {
|
||||
return fs.readFileSync('/proc/self/cgroup', 'utf8').includes('docker');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const hasContainerEnv = () => {
|
||||
try {
|
||||
fs.statSync('/run/.containerenv');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const isDocker = () => {
|
||||
if (isDockerCached === undefined) {
|
||||
isDockerCached = hasContainerEnv() || hasDockerEnv() || hasDockerCGroup();
|
||||
}
|
||||
|
||||
return isDockerCached;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取默认host地址
|
||||
* @returns 根据环境返回合适的host地址
|
||||
* @example getDefaultHost() => '127.0.0.1' // 非Docker环境
|
||||
* @example getDefaultHost() => '0.0.0.0' // Docker环境
|
||||
*/
|
||||
export const getDefaultHost = (): string => {
|
||||
return isDocker() ? '0.0.0.0' : '127.0.0.1';
|
||||
};
|
||||
|
||||
/**
|
||||
* 将 host(主机地址) 转换为标准格式
|
||||
* @param host 主机地址
|
||||
* @returns 标准格式的IP地址
|
||||
* @example normalizeHost('10.0.3.2') => '10.0.3.2'
|
||||
* @example normalizeHost('0.0.0.0') => '127.0.0.1'
|
||||
* @example normalizeHost('2001:4860:4801:51::27') => '[2001:4860:4801:51::27]'
|
||||
*/
|
||||
export const normalizeHost = (host: string) => {
|
||||
if (isIP(host) === 6) return `[${host}]`;
|
||||
return host;
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建URL
|
||||
* @param host 主机地址
|
||||
* @param port 端口
|
||||
* @param path URL路径
|
||||
* @param search URL参数
|
||||
* @returns 完整URL
|
||||
* @example createUrl('127.0.0.1', '8080', '/api', { token: '123456' }) => 'http://127.0.0.1:8080/api?token=123456'
|
||||
* @example createUrl('baidu.com', '80', void 0, void 0, 'https') => 'https://baidu.com:80/'
|
||||
*/
|
||||
export const createUrl = (
|
||||
host: string,
|
||||
port: string,
|
||||
path = '/',
|
||||
search?: Record<string, any>,
|
||||
protocol: Protocol = 'http'
|
||||
) => {
|
||||
const url = new URL(`${protocol}://${normalizeHost(host)}`);
|
||||
url.port = port;
|
||||
url.pathname = path;
|
||||
if (search) {
|
||||
for (const key in search) {
|
||||
url.searchParams.set(key, search[key]);
|
||||
}
|
||||
}
|
||||
|
||||
/** 进行url解码 对特殊字符进行处理 */
|
||||
return decodeURIComponent(url.toString());
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成随机Token
|
||||
* @param length Token长度 默认8
|
||||
* @returns 随机Token字符串
|
||||
* @example getRandomToken
|
||||
*/
|
||||
export const getRandomToken = (length = 8) => {
|
||||
return randomBytes(36).toString('hex').slice(0, length);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".."
|
||||
},
|
||||
"include": [
|
||||
"*.ts",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"host": "0.0.0.0",
|
||||
"port": 6099,
|
||||
"prefix": "",
|
||||
"token": "random",
|
||||
"loginRate": 3,
|
||||
"accessControlMode": "none",
|
||||
"ipWhitelist": [],
|
||||
"ipBlacklist": [],
|
||||
"enableXForwardedFor": false
|
||||
}
|
||||
Reference in new issue
Block a user