SnowCat 雪猫底层全源码:NapCat 4.18.28 底座 + SnowLuma 能力移植 + V8 通道适配 9.9.37

26 包 monorepo(TS 全源码):
- snowcat-core: NT 内核 API 封装 + listener(含 9.9.37 六回调适配)+ Packet 发包
- snowcat-onebot: OneBot v11 全动作 + MCP Streamable HTTP 适配器 + 稳定线
- snowcat-webui-frontend/backend: 雪猫控制台(React+HeroUI)
- snowcat-shell: 单文件 bundle 出口(vite inlineDynamicImports)
- snowcat-native: napi2native / MoeHoo / ffmpeg / pty 运行时
- 56 项 SnowLuma 独门动作移植(qzone 全家桶/群相册/装扮/闪照/收藏)
- 工具件: 解包器/启动器/便携制作器/doctor

GitHub 关联已全量指向 DandanLLab/SnowCatQQ
This commit is contained in:
天天的鸟蛋蛋 committed 2026-11-12 06:27:36 +08:00
commit b9655a5ab1
1048 files changed
+130851

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
# The Path of NapCatQQ
Tiny WebUi Backend for NapCatQQ
+583
View File
@@ -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 { SnowCatPathWrapper } from 'snowcat-common/src/path';
import { WebUiConfigWrapper } from '@/snowcat-webui-backend/src/helper/config';
import { ALLRouter } from '@/snowcat-webui-backend/src/router';
import { cors } from '@/snowcat-webui-backend/src/middleware/cors';
import { createUrl, getRandomToken } from '@/snowcat-webui-backend/src/utils/url';
import { sendError } from '@/snowcat-webui-backend/src/utils/response';
import { join, dirname, resolve } from 'node:path';
import { terminalManager } from '@/snowcat-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 'snowcat-common/src/log-interface';
import { ISubscription } from 'snowcat-common/src/subscription-interface';
import { IStatusHelperSubscription } from '@/snowcat-common/src/status-interface';
import { handleDebugWebSocket } from '@/snowcat-webui-backend/src/api/Debug';
import compression from 'compression';
import { snowCatVersion } from 'snowcat-common/src/version';
import { fileURLToPath } from 'node:url';
import { NapCatOneBot11Adapter } from '@/snowcat-onebot/index';
import { OB11PluginMangerAdapter } from '@/snowcat-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: SnowCatPathWrapper;
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('[SnowCat] [WebUi] 找到SSL证书,将启用HTTPS模式');
return { cert, key };
}
return null;
} catch (error) {
logger.log('[SnowCat] [WebUi] 检查SSL证书时出错: ' + error);
return null;
}
}
export async function InitWebUi (logger: ILogWrapper, pathWrapper: SnowCatPathWrapper, 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('[SnowCat] [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(`[SnowCat] [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('[SnowCat] [WebUi] 检测到默认密码,已自动更新为安全密码');
// 存储token到全局变量,等待QQ登录成功后发送
setPendingTokenToSend(randomToken);
logger.log('[SnowCat] [WebUi] 新密码将在QQ登录成功后发送给用户');
// 重新获取更新后的配置
config = await WebUiConfig.GetWebUIConfig();
}
// 存储启动时的初始token用于鉴权
setInitialWebUiToken(config.token);
const [host, port, token] = await InitPort(config);
webUiRuntimePort = port;
if (port === 0) {
logger.log('[SnowCat] [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('[SnowCat] [WebUi] NAPCAT_QUICK_PASSWORD_MD5 格式无效(需为 32 位 MD5)');
}
const quickPassword = process.env['NAPCAT_QUICK_PASSWORD'];
if (typeof quickPassword === 'string' && quickPassword.length > 0) {
console.log('[SnowCat] [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(`[SnowCat] [WebUi] 自动快速登录成功: ${autoLoginAccount}`);
return;
}
console.log(`[SnowCat] [WebUi] 自动快速登录失败: ${message || '未知错误'}`);
} catch (error) {
console.log('[SnowCat] [WebUi] 自动快速登录异常:' + error);
}
if (!quickPasswordMd5) {
console.log(`[SnowCat] [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(`[SnowCat] [WebUi] 自动密码回退登录成功: ${autoLoginAccount}`);
return;
}
if (needCaptcha) {
console.log(`[SnowCat] [WebUi] 自动密码回退登录需要验证码,请在登录页面继续完成: ${autoLoginAccount}`);
return;
}
if (needNewDevice) {
console.log(`[SnowCat] [WebUi] 自动密码回退登录需要新设备验证,请在登录页面继续完成: ${autoLoginAccount}`);
return;
}
console.log(`[SnowCat] [WebUi] 自动密码回退登录失败: ${message || '未知错误'}`);
} catch (error) {
console.log('[SnowCat] [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');
// 替换版本号
// 使用 snowCatVersion,如果为 alpha 则尝试加上时间戳或其他标识以避免缓存冲突,或者直接使用
// 用户要求控制 sw.js 版本,snowCatVersion 是核心控制点
swContent = swContent.replace('{{VERSION}}', snowCatVersion);
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('[SnowCat] [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(`[SnowCat] [WebUi] WebUi Token: ${token}`);
logger.log(
`[SnowCat] [WebUi] WebUi User Panel Url: ${createUrl('127.0.0.1', port.toString(), '/webui', searchParams)}`
);
if (host !== '') {
logger.log(
`[SnowCat] [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": "snowcat-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",
"snowcat-common": "workspace:*",
"snowcat-dpapi": "workspace:*",
"snowcat-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 '@/snowcat-webui-backend/src/helper/SignToken';
import { PasskeyHelper } from '@/snowcat-webui-backend/src/helper/PasskeyHelper';
import { TotpHelper } from '@/snowcat-webui-backend/src/helper/TotpHelper';
import { WebUiDataRuntime } from '@/snowcat-webui-backend/src/helper/Data';
import { sendSuccess, sendError } from '@/snowcat-webui-backend/src/utils/response';
import { isEmpty } from '@/snowcat-webui-backend/src/utils/check';
import { WebUiConfig, getInitialWebUiToken, setInitialWebUiToken } from '@/snowcat-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 = 'snowcat-user';
const userName = 'SnowCat 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 = 'snowcat-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 = 'snowcat-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 = 'snowcat-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, 'SnowCat WebUI', 'SnowCat');
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 '@/snowcat-webui-backend/index';
import { WebUiDataRuntime } from '@/snowcat-webui-backend/src/helper/Data';
import { sendError, sendSuccess } from '@/snowcat-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,201 @@
import { RequestHandler } from 'express';
import { WebUiDataRuntime } from '@/snowcat-webui-backend/src/helper/Data';
import { sendSuccess, sendError } from '@/snowcat-webui-backend/src/utils/response';
import { WebUiConfig, webUiPathWrapper } from '@/snowcat-webui-backend/index';
import { getLatestTag, getAllTags, compareSemVer } from 'snowcat-common/src/helper';
import { getLatestActionArtifacts, getMirrorConfig } from '@/snowcat-common/src/mirror';
import { SnowCatCoreWorkingEnv } from '@/snowcat-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 {
// GitHub 不可达 / 被限流 / 无 tag 时不应把接口打成 500:
// 返回 200 + null,前端按“版本未知”降级展示,也不污染控制台。
sendSuccess(res, null, 'latest tag unavailable');
}
};
/**
* 版本信息接口
*/
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('DandanLLab', 'SnowCatQQ', 'build.yml', 'main', 10, mirror);
// 根据当前工作环境自动过滤对应的 artifact 类型
const isFramework = WebUiDataRuntime.getWorkingEnv() === SnowCatCoreWorkingEnv.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');
if (!fs.existsSync(filePath)) {
// 便携版 / 开发环境下可能不存在该产物,属于预期情况,不应报错
sendSuccess(res, { hash: null, file: 'napcat.mjs', algorithm: 'sha512', available: false });
return;
}
const fileBuffer = fs.readFileSync(filePath);
const hash = crypto.createHash('sha512').update(fileBuffer).digest('hex');
sendSuccess(res, { hash, file: 'napcat.mjs', algorithm: 'sha512', available: true });
} 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 '@/snowcat-webui-backend/src/utils/response';
import { WebUiDataRuntime } from '@/snowcat-webui-backend/src/helper/Data';
import { IncomingMessage } from 'http';
import { OB11Response, OneBotAction } from '@/snowcat-onebot/action/OneBotAction';
import { ActionName } from '@/snowcat-onebot/action/router';
import { OB11LifeCycleEvent, LifeCycleSubType } from '@/snowcat-onebot/event/meta/OB11LifeCycleEvent';
import { IOB11NetworkAdapter } from '@/snowcat-onebot/network/adapter';
import { WebsocketServerConfig } from '@/snowcat-onebot/config/config';
import { ActionMap } from '@/snowcat-onebot/action';
import { SnowCatCore } from '@/snowcat-core/index';
import { NapCatOneBot11Adapter } from '@/snowcat-onebot/index';
import { OB11EmitEventContent, OB11NetworkReloadType } from '@/snowcat-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: SnowCatCore, 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 '@/snowcat-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 '@/snowcat-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 '@/snowcat-webui-backend/src/utils/response';
import {
GITHUB_FILE_MIRRORS,
GITHUB_RAW_MIRRORS,
buildMirrorUrl,
getMirrorConfig,
setCustomMirror,
clearMirrorCache,
} from 'snowcat-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/DandanLLab/SnowCatQQ/main/README.md';
} else {
mirrors = GITHUB_FILE_MIRRORS.filter(m => m);
testUrl = 'https://github.com/DandanLLab/SnowCatQQ/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/DandanLLab/SnowCatQQ/main/README.md';
} else {
testUrl = 'https://github.com/DandanLLab/SnowCatQQ/releases/latest';
}
const result = await testMirrorLatency(mirror || '', testUrl, 5000);
return sendSuccess(res, result);
} catch (e: any) {
return sendError(res, e.message);
}
};
@@ -0,0 +1,60 @@
import { RequestHandler } from 'express';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { loadConfig, OneBotConfig } from '@/snowcat-webui-backend/src/onebot/config';
import { webUiPathWrapper } from '@/snowcat-webui-backend/index';
import { WebUiDataRuntime } from '@/snowcat-webui-backend/src/helper/Data';
import { sendError, sendSuccess } from '@/snowcat-webui-backend/src/utils/response';
import { isEmpty } from '@/snowcat-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 '@/snowcat-webui-backend/src/helper/Data';
import { sendError, sendSuccess } from '@/snowcat-webui-backend/src/utils/response';
import { NapCatOneBot11Adapter } from '@/snowcat-onebot/index';
import { OB11PluginMangerAdapter } from '@/snowcat-onebot/network/plugin-manger';
import { webUiPathWrapper } from '@/snowcat-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 '@/snowcat-webui-backend/src/utils/response';
import { PluginStoreList } from '@/snowcat-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 'snowcat-common/src/mirror';
import { webUiPathWrapper } from '@/snowcat-webui-backend/index';
import { WebUiDataRuntime } from '@/snowcat-webui-backend/src/helper/Data';
import { NapCatOneBot11Adapter } from '@/snowcat-onebot/index';
import { OB11PluginMangerAdapter } from '@/snowcat-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/DandanLLab/snowcat-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 'snowcat-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,577 @@
import { RequestHandler } from 'express';
import https from 'https';
import { WebUiDataRuntime } from '@/snowcat-webui-backend/src/helper/Data';
import { WebUiConfig } from '@/snowcat-webui-backend/index';
import { isEmpty } from '@/snowcat-webui-backend/src/utils/check';
import { sendError, sendSuccess } from '@/snowcat-webui-backend/src/utils/response';
import { Registry20Utils, MachineInfoUtils } from '@/snowcat-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();
});
}
// 获取 Registry20 路径的辅助函数
const getRegistryPath = () => {
// 优先从 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 Registry20Utils.getRegistryPath(dataPath);
};
// 获取 machine-info 路径的辅助函数 (Linux)
const getMachineInfoPath = () => {
let dataPath = WebUiDataRuntime.getQQDataPath();
if (!dataPath) {
const oneBotContext = WebUiDataRuntime.getOneBotContext();
dataPath = oneBotContext?.core?.dataPath;
}
if (!dataPath) {
throw new Error('QQ data path not available yet');
}
return MachineInfoUtils.getMachineInfoPath(dataPath);
};
// 获取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;
// selfInfo.avatarUrl 内核从未赋值,兜底用 qlogo 头像(浏览器端直连)
const avatarUrl = selfInfo?.avatarUrl
?? (selfInfo?.uin
? `https://q1.qlogo.cn/g?b=qq&nk=${selfInfo.uin}&s=100`
: undefined);
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}`);
}
};
// ============================================================
// 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,145 @@
import { RequestHandler } from 'express';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { resolve } from 'node:path';
import { webUiPathWrapper } from '@/snowcat-webui-backend/index';
import { WebUiDataRuntime } from '@/snowcat-webui-backend/src/helper/Data';
import { sendError, sendSuccess } from '@/snowcat-webui-backend/src/utils/response';
import json5 from 'json5';
import { Value } from '@sinclair/typebox/value';
import { SnowCatConfigSchema } from '@/snowcat-core/helper/config';
// 动态获取 NapCat 配置默认值
function getDefaultNapcatConfig (): Record<string, unknown> {
const data = {};
return Value.Parse(SnowCatConfigSchema, 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 SnowCatGetConfigHandler: 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 SnowCatSetConfigHandler: 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 SnowCatGetUinConfigHandler: 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 SnowCatSetUinConfigHandler: 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,19 @@
import { statusHelperSubscription } from '@/snowcat-webui-backend';
import { RequestHandler } from 'express';
import { SystemStatus } from 'snowcat-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 '@/snowcat-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 { SnowCatPathWrapper } from '@/snowcat-common/src/path';
import { WebUiDataRuntime } from '@/snowcat-webui-backend/src/helper/Data';
import { SnowCatCoreWorkingEnv } from '@/snowcat-webui-backend/src/types';
import {
getGitHubRelease,
findAvailableDownloadUrl,
} from '@/snowcat-common/src/mirror';
import { ILogWrapper } from '@/snowcat-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;
}
// 注:镜像配置已迁移到 @/snowcat-common/src/mirror 模块统一管理
/**
* 下载文件(带进度和重试)
*/
async function downloadFile (url: string, dest: string): Promise<void> {
webUiLogger?.log('[SnowCat 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('[SnowCat Update] Response status:', res.statusCode);
webUiLogger?.log('[SnowCat Update] Content-Type:', res.headers['content-type']);
if (res.statusCode === 302 || res.statusCode === 301) {
webUiLogger?.log('[SnowCat 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('[SnowCat Update] Download completed');
resolve();
});
});
request.on('error', (err) => {
webUiLogger?.logError('[SnowCat 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() === SnowCatCoreWorkingEnv.Framework ? 'NapCat.Framework.zip' : 'NapCat.Shell.zip';
// 确定目标版本 tag
// 如果指定了版本,使用指定版本;否则使用 'latest'
const targetTag = targetVersion || 'latest';
webUiLogger?.log(`[SnowCat 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(`[SnowCat 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/DandanLLab/SnowCatQQ/actions/runs/${runId}/${artifactName}.zip`;
actualVersion = targetTag;
webUiLogger?.log(`[SnowCat Update] Action artifact URL: ${baseUrl}`);
// 使用 mirror 模块查找可用的 nightly.link 镜像
try {
downloadUrl = await findAvailableDownloadUrl(baseUrl, {
validateContent: true,
minFileSize: 1024 * 1024,
timeout: 10000,
customMirror: mirror,
});
webUiLogger?.log(`[SnowCat Update] Using download URL: ${downloadUrl}`);
} catch (_error) {
// 如果镜像都不可用,直接使用原始 URL
webUiLogger?.logWarn('[SnowCat Update] All nightly.link mirrors failed, using original URL');
downloadUrl = baseUrl;
}
} else {
// 处理标准 release 版本
// 使用 mirror 模块获取 release 信息(不依赖 API)
// 通过 assetNames 参数直接构建下载 URL,避免调用 GitHub API
const release = await getGitHubRelease('DandanLLab', 'SnowCatQQ', 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(`[SnowCat 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(`[SnowCat Update] Downgrade from ${currentVersion} to ${actualVersion}, force=${force}`);
// 不阻止降级,只是记录日志
}
}
webUiLogger?.log(`[SnowCat Update] Updating to version: ${actualVersion}`);
// 创建临时目录
const tempDir = path.join(webUiPathWrapper.binaryPath, './temp');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
webUiLogger?.log(`[SnowCat Update] Using download URL: ${downloadUrl}`);
// 下载zip
const zipPath = path.join(tempDir, 'snowcat-latest.zip');
webUiLogger?.log('[SnowCat Update] Saving to:', zipPath);
await downloadFile(downloadUrl, zipPath);
// 检查文件大小
const stats = fs.statSync(zipPath);
webUiLogger?.log('[SnowCat Update] Downloaded file size:', stats.size, 'bytes');
// 解压到临时目录
const extractPath = path.join(tempDir, 'snowcat-extract');
webUiLogger?.log('[SnowCat 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(`[SnowCat Update] Skipping suspicious path: ${fileInfo.relativePath}`);
continue;
}
const normalizedRelativePath = path.normalize(fileInfo.relativePath);
// 跳过指定的文件
if (SKIP_UPDATE_FILES.includes(path.basename(fileInfo.relativePath))) {
webUiLogger?.log(`[SnowCat Update] Skipping update for ${fileInfo.relativePath}`);
continue;
}
// 保留已存在的用户配置文件,避免覆盖用户设置
if (PRESERVE_USER_CONFIG_RELATIVE_PATHS.has(normalizedRelativePath) && fs.existsSync(targetFilePath)) {
webUiLogger?.log(`[SnowCat 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(`[SnowCat 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, 'snowcat-update.json');
fs.writeFileSync(configPath, JSON.stringify(updateConfig, null, 2));
webUiLogger?.log(`[SnowCat 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('[SnowCat Update] 更新失败:', error);
sendError(res, '更新失败: ' + (error instanceof Error ? error.message : '未知错误'));
}
} catch (error: any) {
webUiLogger?.logError('[SnowCat Update] 更新失败:', error);
sendError(res, '更新失败: ' + error.message);
}
};
// 注:getLatestRelease 已移除,现在使用 mirror 模块的 getGitHubRelease
/**
* 应用待处理的更新(在应用启动时调用)
*/
export async function applyPendingUpdates (webUiPathWrapper: SnowCatPathWrapper, logger: ILogWrapper): Promise<void> {
const configPath = path.join(webUiPathWrapper.configPath, 'snowcat-update.json');
if (!fs.existsSync(configPath)) {
// logger.log('[SnowCat Update] No pending updates found');
return;
}
try {
logger.log('[SnowCat 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(`[SnowCat 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(`[SnowCat Update] Updated ${path.basename(file.targetPath)} on startup`);
} catch (error) {
logger.logError(`[SnowCat 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(`[SnowCat Update] ${remainingFiles.length} files still pending update`);
} else {
// 所有文件都成功更新,删除配置文件
fs.unlinkSync(configPath);
logger.log('[SnowCat Update] All pending updates applied successfully');
}
} catch (error) {
logger.logError('[SnowCat Update] Failed to apply pending updates:', error);
}
}
@@ -0,0 +1,245 @@
import { RequestHandler } from 'express';
import { WebUiConfig, webUiPathWrapper } from '@/snowcat-webui-backend/index';
import { sendError, sendSuccess } from '@/snowcat-webui-backend/src/utils/response';
import { isEmpty } from '@/snowcat-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 = 'snowcat-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('snowcat-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 'snowcat-common/src/store';
import { snowCatVersion } from 'snowcat-common/src/version';
import { SnowCatCoreWorkingEnv, type LoginRuntimeType, type QQLoginPhase } from '../types';
const LoginRuntime: LoginRuntimeType = {
workingEnv: SnowCatCoreWorkingEnv.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 注册真实回调
},
SnowCatHelper: {
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: [],
},
SnowCatVersion: snowCatVersion,
WebUiConfigQuickFunction: async () => {
},
};
export const WebUiDataRuntime = {
setWorkingEnv (env: SnowCatCoreWorkingEnv): void {
LoginRuntime.workingEnv = env;
},
getWorkingEnv (): SnowCatCoreWorkingEnv {
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['SnowCatHelper']['QQLoginList'] {
return LoginRuntime.SnowCatHelper.QQLoginList;
},
setQQQuickLoginList (list: LoginRuntimeType['SnowCatHelper']['QQLoginList']): void {
LoginRuntime.SnowCatHelper.QQLoginList = list;
},
getQQNewLoginList (): LoginRuntimeType['SnowCatHelper']['NewQQLoginList'] {
return LoginRuntime.SnowCatHelper.NewQQLoginList;
},
setQQNewLoginList (list: LoginRuntimeType['SnowCatHelper']['NewQQLoginList']): void {
LoginRuntime.SnowCatHelper.NewQQLoginList = list;
},
setQuickLoginCall (func: LoginRuntimeType['SnowCatHelper']['onQuickLoginRequested']): void {
LoginRuntime.SnowCatHelper.onQuickLoginRequested = func;
},
requestQuickLogin: function (uin) {
return LoginRuntime.SnowCatHelper.onQuickLoginRequested(uin);
} as LoginRuntimeType['SnowCatHelper']['onQuickLoginRequested'],
setPasswordLoginCall (func: LoginRuntimeType['SnowCatHelper']['onPasswordLoginRequested']): void {
LoginRuntime.SnowCatHelper.onPasswordLoginRequested = func;
},
requestPasswordLogin: function (uin: string, passwordMd5: string) {
return LoginRuntime.SnowCatHelper.onPasswordLoginRequested(uin, passwordMd5);
} as LoginRuntimeType['SnowCatHelper']['onPasswordLoginRequested'],
setCaptchaLoginCall (func: LoginRuntimeType['SnowCatHelper']['onCaptchaLoginRequested']): void {
LoginRuntime.SnowCatHelper.onCaptchaLoginRequested = func;
},
requestCaptchaLogin: function (uin: string, passwordMd5: string, ticket: string, randstr: string, sid: string) {
return LoginRuntime.SnowCatHelper.onCaptchaLoginRequested(uin, passwordMd5, ticket, randstr, sid);
} as LoginRuntimeType['SnowCatHelper']['onCaptchaLoginRequested'],
setNewDeviceLoginCall (func: LoginRuntimeType['SnowCatHelper']['onNewDeviceLoginRequested']): void {
LoginRuntime.SnowCatHelper.onNewDeviceLoginRequested = func;
},
requestNewDeviceLogin: function (uin: string, passwordMd5: string, newDevicePullQrCodeSig: string) {
return LoginRuntime.SnowCatHelper.onNewDeviceLoginRequested(uin, passwordMd5, newDevicePullQrCodeSig);
} as LoginRuntimeType['SnowCatHelper']['onNewDeviceLoginRequested'],
setOnOB11ConfigChanged (func: LoginRuntimeType['SnowCatHelper']['onOB11ConfigChanged']): void {
LoginRuntime.SnowCatHelper.onOB11ConfigChanged = func;
},
setOB11Config: function (ob11) {
return LoginRuntime.SnowCatHelper.onOB11ConfigChanged(ob11);
} as LoginRuntimeType['SnowCatHelper']['onOB11ConfigChanged'],
GetNapCatVersion () {
return LoginRuntime.SnowCatVersion;
},
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.SnowCatHelper.onRestartProcessRequested = func;
},
requestRestartProcess: async function () {
return await LoginRuntime.SnowCatHelper.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 = 'SnowCat 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 'snowcat-common/src/store';
import type { WebUiCredentialJson, WebUiCredentialInnerJson } from '@/snowcat-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 '@/snowcat-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 '@/snowcat-webui-backend/index';
import { AuthHelper } from '@/snowcat-webui-backend/src/helper/SignToken';
import { sendError } from '@/snowcat-webui-backend/src/utils/response';
import type { WebUiCredentialJson } from '@/snowcat-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 '@/snowcat-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,25 @@
import { Router } from 'express';
import { GetThemeConfigHandler, GetNapCatVersion, QQVersionHandler, SetThemeConfigHandler, getLatestTagHandler, getAllReleasesHandler, GetMirrorsHandler, GetNapCatFileHashHandler } from '../api/BaseInfo';
import { StatusRealTimeHandler } from '@/snowcat-webui-backend/src/api/Status';
import { GetProxyHandler } from '../api/Proxy';
const router: Router = Router();
// router: 获取nc的package.json信息
router.get('/QQVersion', QQVersionHandler);
// 品牌命名统一为 SnowCat:新路径优先,旧 NapCat 路径保留兼容(老前端 / 外部脚本)
router.get('/GetSnowCatVersion', GetNapCatVersion);
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('/GetSnowCatFileHash', GetNapCatFileHashHandler);
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 '@/snowcat-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,23 @@
import { Router } from 'express';
import multer from 'multer';
import { OB11GetConfigHandler, OB11SetConfigHandler } from '@/snowcat-webui-backend/src/api/OB11Config';
import { BackupExportConfigHandler, BackupImportConfigHandler } from '@/snowcat-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 '@/snowcat-webui-backend/src/api/Plugin';
import {
GetPluginStoreListHandler,
GetPluginStoreDetailHandler,
InstallPluginFromStoreHandler,
InstallPluginFromStoreSSEHandler,
} from '@/snowcat-webui-backend/src/api/PluginStore';
import { WebUiDataRuntime } from '@/snowcat-webui-backend/src/helper/Data';
import { NapCatOneBot11Adapter } from '@/snowcat-onebot/index';
import { OB11PluginMangerAdapter } from '@/snowcat-onebot/network/plugin-manger';
// 配置 multer 用于文件上传
const uploadDir = path.join(os.tmpdir(), 'snowcat-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,103 @@
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,
} from '@/snowcat-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:重启 SnowCat(新命名),旧 /RestartNapCat 保留兼容
router.post('/RestartSnowCat', QQRestartNapCatHandler);
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);
export { router as QQLoginRouter };
@@ -0,0 +1,16 @@
import { Router } from 'express';
import { SnowCatGetConfigHandler, SnowCatSetConfigHandler, SnowCatGetUinConfigHandler, SnowCatSetUinConfigHandler } from '@/snowcat-webui-backend/src/api/SnowCatConfig';
const router: Router = Router();
// router:获取 NapCat 配置(napcat.json 登录前配置)
router.get('/GetConfig', SnowCatGetConfigHandler);
// router:设置 NapCat 配置(napcat.json 登录前配置)
router.post('/SetConfig', SnowCatSetConfigHandler);
// router:获取 NapCat per-uin 配置(napcat_{uin}.json 登录后配置)
router.get('/GetUinConfig', SnowCatGetUinConfigHandler);
// router:设置 NapCat per-uin 配置(napcat_{uin}.json 登录后配置)
router.post('/SetUinConfig', SnowCatSetUinConfigHandler);
export { router as SnowCatConfigRouter };
@@ -0,0 +1,13 @@
/**
* @file UpdateNapCat路由
*/
import { Router } from 'express';
import { UpdateNapCatHandler } from '@/snowcat-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 '@/snowcat-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 '@/snowcat-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,62 @@
/**
* @file 所有路由的入口文件
*/
import { Router } from 'express';
import { OB11ConfigRouter } from '@/snowcat-webui-backend/src/router/OB11Config';
import { auth } from '@/snowcat-webui-backend/src/middleware/auth';
import { sendSuccess } from '@/snowcat-webui-backend/src/utils/response';
import { QQLoginRouter } from '@/snowcat-webui-backend/src/router/QQLogin';
import { AuthRouter } from '@/snowcat-webui-backend/src/router/auth';
import { LogRouter } from '@/snowcat-webui-backend/src/router/Log';
import { BaseRouter } from '@/snowcat-webui-backend/src/router/Base';
import { FileRouter } from './File';
import { WebUIConfigRouter } from './WebUIConfig';
import { UpdateNapCatRouter } from './UpdateNapCat';
import DebugRouter from '@/snowcat-webui-backend/src/api/Debug';
import { ProcessRouter } from './Process';
import { PluginRouter } from './Plugin';
import { MirrorRouter } from './Mirror';
import { SnowCatConfigRouter } from './SnowCatConfig';
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:更新相关路由
// 品牌命名统一为 SnowCat:新路径优先,旧 NapCat 路径保留兼容
router.use('/UpdateSnowCat', UpdateNapCatRouter);
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('/SnowCatConfig', SnowCatConfigRouter);
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 '@/snowcat-webui-backend/src/types';
import { WebSocket, WebSocketServer } from 'ws';
import os from 'os';
import { IPty, spawn as ptySpawn } from 'snowcat-pty';
import { randomUUID } from 'crypto';
import { ILogWrapper } from 'snowcat-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('[SnowCat] [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 '@/snowcat-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 SnowCatCoreWorkingEnv {
Unknown = 0,
Shell = 1,
Framework = 2,
}
export type QQLoginPhase =
| 'waiting_qrcode'
| 'generating_qrcode'
| 'qrcode_scanned'
| 'initializing'
| 'ready'
| 'offline'
| 'reconnecting';
export interface LoginRuntimeType {
workingEnv: SnowCatCoreWorkingEnv;
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 上下文,用于调试功能
SnowCatHelper: {
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[];
};
SnowCatVersion: 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 '@/snowcat-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,275 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';
import { protectData, unprotectData } from 'snowcat-dpapi';
const GUID_HEADER = Buffer.from([0x00, 0x00, 0x00, 0x14]);
const XOR_KEY = 0x10;
/**
* Unprotects data using Windows DPAPI via snowcat-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(dataPath, 'nt_qq', 'global', '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 {
if (!fs.existsSync(registryPath)) {
throw new Error('Registry20 does not exist');
}
const timestamp = new Date().toISOString().replace(/[-:T.]/g, '').slice(0, 14);
const backupPath = `${registryPath}.bak.${timestamp}`;
fs.copyFileSync(registryPath, backupPath);
return backupPath;
}
static restore (registryPath: string, backupFileName: string): void {
const dir = path.dirname(registryPath);
const backupPath = path.join(dir, backupFileName);
if (!fs.existsSync(backupPath)) {
throw new Error('Backup file not found');
}
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(dataPath, 'nt_qq', 'global', '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 {
if (!fs.existsSync(machineInfoPath)) {
throw new Error('machine-info file does not exist');
}
const timestamp = new Date().toISOString().replace(/[-:T.]/g, '').slice(0, 14);
const backupPath = `${machineInfoPath}.bak.${timestamp}`;
fs.copyFileSync(machineInfoPath, backupPath);
return backupPath;
}
/**
* 恢复备份
*/
static restore (machineInfoPath: string, backupFileName: string): void {
const dir = path.dirname(machineInfoPath);
const backupPath = path.join(dir, backupFileName);
if (!fs.existsSync(backupPath)) {
throw new Error('Backup file not found');
}
fs.copyFileSync(backupPath, machineInfoPath);
}
/**
* 删除 machine-info
*/
static delete (machineInfoPath: string): void {
if (fs.existsSync(machineInfoPath)) {
fs.unlinkSync(machineInfoPath);
}
}
}
@@ -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 '@/snowcat-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"
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"host": "0.0.0.0",
"port": 6099,
"prefix": "",
"token": "random",
"loginRate": 3,
"accessControlMode": "none",
"ipWhitelist": [],
"ipBlacklist": [],
"enableXForwardedFor": false
}