mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-06 21:10:23 +00:00
refactor: 整体重构 (#1381)
* feat: pnpm new * Refactor build and release workflows, update dependencies Switch build scripts and workflows from npm to pnpm, update build and artifact paths, and simplify release workflow by removing version detection and changelog steps. Add new dependencies (silk-wasm, express, ws, node-pty-prebuilt-multiarch), update exports in package.json files, and add vite config for napcat-framework. Also, rename manifest.json for framework package and fix static asset copying in shell build config.
This commit is contained in:
152
packages/napcat-webui-backend/src/api/Auth.ts
Normal file
152
packages/napcat-webui-backend/src/api/Auth.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { RequestHandler } from 'express';
|
||||
|
||||
import { WebUiConfig, getInitialWebUiToken, setInitialWebUiToken } from '@/napcat-webui-backend/index';
|
||||
|
||||
import { AuthHelper } from '@/napcat-webui-backend/helper/SignToken';
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/helper/Data';
|
||||
import { sendSuccess, sendError } from '@/napcat-webui-backend/utils/response';
|
||||
import { isEmpty } from '@/napcat-webui-backend/utils/check';
|
||||
|
||||
// 登录
|
||||
export const LoginHandler: RequestHandler = async (req, res) => {
|
||||
// 获取WebUI配置
|
||||
const WebUiConfigData = await WebUiConfig.GetWebUIConfig();
|
||||
// 获取请求体中的hash
|
||||
const { hash } = 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');
|
||||
}
|
||||
|
||||
// 签发凭证
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
26
packages/napcat-webui-backend/src/api/BaseInfo.ts
Normal file
26
packages/napcat-webui-backend/src/api/BaseInfo.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/helper/Data';
|
||||
|
||||
import { sendSuccess } from '@/napcat-webui-backend/utils/response';
|
||||
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
||||
|
||||
export const PackageInfoHandler: RequestHandler = (_, res) => {
|
||||
const data = WebUiDataRuntime.getPackageJson();
|
||||
sendSuccess(res, data);
|
||||
};
|
||||
|
||||
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: '更新成功' });
|
||||
};
|
||||
655
packages/napcat-webui-backend/src/api/File.ts
Normal file
655
packages/napcat-webui-backend/src/api/File.ts
Normal file
@@ -0,0 +1,655 @@
|
||||
import type { RequestHandler } from 'express';
|
||||
import { sendError, sendSuccess } from '../utils/response';
|
||||
import fsProm from 'fs/promises';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import compressing from 'compressing';
|
||||
import { PassThrough } from 'stream';
|
||||
import multer from 'multer';
|
||||
import webUIFontUploader from '../uploader/webui_font';
|
||||
import diskUploader from '../uploader/disk';
|
||||
import { WebUiConfig, getInitialWebUiToken, webUiPathWrapper } from '@/napcat-webui-backend/index';
|
||||
|
||||
const isWindows = os.platform() === 'win32';
|
||||
|
||||
// 安全地从查询参数中提取字符串值,防止类型混淆
|
||||
const getQueryStringParam = (param: any): string => {
|
||||
if (Array.isArray(param)) {
|
||||
return String(param[0] || '');
|
||||
}
|
||||
return String(param || '');
|
||||
};
|
||||
|
||||
// 获取系统根目录列表(Windows返回盘符列表,其他系统返回['/'])
|
||||
const getRootDirs = async (): Promise<string[]> => {
|
||||
if (!isWindows) return ['/'];
|
||||
|
||||
// Windows 驱动器字母 (A-Z)
|
||||
const drives: string[] = [];
|
||||
for (let i = 65; i <= 90; i++) {
|
||||
const driveLetter = String.fromCharCode(i);
|
||||
try {
|
||||
await fsProm.access(`${driveLetter}:\\`);
|
||||
drives.push(`${driveLetter}:`);
|
||||
} catch {
|
||||
// 如果驱动器不存在或无法访问,跳过
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return drives.length > 0 ? drives : ['C:'];
|
||||
};
|
||||
|
||||
// 规范化路径并进行安全验证
|
||||
const normalizePath = (inputPath: string): string => {
|
||||
if (!inputPath) {
|
||||
// 对于空路径,Windows返回用户主目录,其他系统返回根目录
|
||||
return isWindows ? process.env['USERPROFILE'] || 'C:\\' : '/';
|
||||
}
|
||||
|
||||
// 对输入路径进行清理,移除潜在的危险字符
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const cleanedPath = inputPath.replace(/[\x01-\x1f\x7f]/g, ''); // 移除控制字符
|
||||
|
||||
// 如果是Windows且输入为纯盘符(可能带或不带斜杠),统一返回 "X:\"
|
||||
if (isWindows && /^[A-Z]:[\\/]*$/i.test(cleanedPath)) {
|
||||
return cleanedPath.slice(0, 2) + '\\';
|
||||
}
|
||||
|
||||
// 安全验证:检查是否包含危险的路径遍历模式(在规范化之前)
|
||||
if (containsPathTraversal(cleanedPath)) {
|
||||
throw new Error('Invalid path: path traversal detected');
|
||||
}
|
||||
|
||||
// 进行路径规范化
|
||||
const normalized = path.resolve(cleanedPath);
|
||||
|
||||
// 再次检查规范化后的路径,确保没有绕过安全检查
|
||||
if (containsPathTraversal(normalized)) {
|
||||
throw new Error('Invalid path: path traversal detected after normalization');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保规范化后的路径不包含连续的路径分隔符
|
||||
const finalPath = normalized.replace(/[\\/]+/g, path.sep);
|
||||
|
||||
return finalPath;
|
||||
};
|
||||
|
||||
// 检查路径是否包含路径遍历攻击模式
|
||||
const containsPathTraversal = (inputPath: string): boolean => {
|
||||
// 对输入进行URL解码,防止编码绕过
|
||||
let decodedPath = inputPath;
|
||||
try {
|
||||
decodedPath = decodeURIComponent(inputPath);
|
||||
} catch {
|
||||
// 如果解码失败,使用原始路径
|
||||
}
|
||||
|
||||
// 将路径统一为正斜杠格式进行检查
|
||||
const normalizedForCheck = decodedPath.replace(/\\/g, '/');
|
||||
|
||||
// 检查危险模式 - 更全面的路径遍历检测
|
||||
const dangerousPatterns = [
|
||||
/\.\.\//, // ../ 模式
|
||||
/\/\.\./, // /.. 模式
|
||||
/^\.\./, // 以.. 开头
|
||||
/\.\.$/, // 以.. 结尾
|
||||
/\.\.\\/, // ..\ 模式(Windows)
|
||||
/\\\.\./, // \.. 模式(Windows)
|
||||
/%2e%2e/i, // URL编码的..
|
||||
/%252e%252e/i, // 双重URL编码的..
|
||||
// eslint-disable-next-line no-control-regex
|
||||
/\.\.\x00/, // null字节攻击
|
||||
/\0/, // null字节
|
||||
];
|
||||
|
||||
return dangerousPatterns.some(pattern => pattern.test(normalizedForCheck));
|
||||
};
|
||||
|
||||
interface FileInfo {
|
||||
name: string;
|
||||
isDirectory: boolean;
|
||||
size: number;
|
||||
mtime: Date;
|
||||
}
|
||||
|
||||
// 添加系统文件黑名单
|
||||
const SYSTEM_FILES = new Set(['pagefile.sys', 'swapfile.sys', 'hiberfil.sys', 'System Volume Information']);
|
||||
|
||||
// 检查是否为WebUI配置文件
|
||||
const isWebUIConfigFile = (filePath: string): boolean => {
|
||||
// 先用字符串快速筛选
|
||||
if (!filePath.includes('webui.json')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 进入更严格的路径判断 - 统一路径分隔符为 /
|
||||
const webUIConfigPath = path.resolve(webUiPathWrapper.configPath, 'webui.json').replace(/\\/g, '/');
|
||||
const targetPath = path.resolve(filePath).replace(/\\/g, '/');
|
||||
|
||||
// 统一分隔符后进行路径比较
|
||||
return targetPath === webUIConfigPath;
|
||||
};
|
||||
|
||||
// WebUI配置文件脱敏处理
|
||||
const sanitizeWebUIConfig = (content: string): string => {
|
||||
try {
|
||||
const config = JSON.parse(content);
|
||||
if (config.token) {
|
||||
config.token = '******';
|
||||
}
|
||||
return JSON.stringify(config, null, 4);
|
||||
} catch {
|
||||
// 如果解析失败,返回原内容
|
||||
return content;
|
||||
}
|
||||
};
|
||||
|
||||
// 检查同类型的文件或目录是否存在
|
||||
const checkSameTypeExists = async (pathToCheck: string, isDirectory: boolean): Promise<boolean> => {
|
||||
try {
|
||||
const stat = await fsProm.stat(pathToCheck);
|
||||
// 只有当类型相同时才认为是冲突
|
||||
return stat.isDirectory() === isDirectory;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取目录内容
|
||||
export const ListFilesHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const requestPath = getQueryStringParam(req.query['path']) || (isWindows ? process.env['USERPROFILE'] || 'C:\\' : '/');
|
||||
|
||||
let normalizedPath: string;
|
||||
try {
|
||||
normalizedPath = normalizePath(requestPath);
|
||||
} catch (_pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
const onlyDirectory = req.query['onlyDirectory'] === 'true';
|
||||
|
||||
// 如果是根路径且在Windows系统上,返回盘符列表
|
||||
if (isWindows && (!requestPath || requestPath === '/' || requestPath === '\\')) {
|
||||
const drives = await getRootDirs();
|
||||
const driveInfos: FileInfo[] = await Promise.all(
|
||||
drives.map(async (drive) => {
|
||||
try {
|
||||
const stat = await fsProm.stat(`${drive}\\`);
|
||||
return {
|
||||
name: drive,
|
||||
isDirectory: true,
|
||||
size: 0,
|
||||
mtime: stat.mtime,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
name: drive,
|
||||
isDirectory: true,
|
||||
size: 0,
|
||||
mtime: new Date(),
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
return sendSuccess(res, driveInfos);
|
||||
}
|
||||
|
||||
const files = await fsProm.readdir(normalizedPath);
|
||||
let fileInfos: FileInfo[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
// 跳过系统文件
|
||||
if (SYSTEM_FILES.has(file)) continue;
|
||||
|
||||
try {
|
||||
const fullPath = path.join(normalizedPath, file);
|
||||
const stat = await fsProm.stat(fullPath);
|
||||
fileInfos.push({
|
||||
name: file,
|
||||
isDirectory: stat.isDirectory(),
|
||||
size: stat.size,
|
||||
mtime: stat.mtime,
|
||||
});
|
||||
} catch (_error) {
|
||||
// 忽略无法访问的文件
|
||||
// console.warn(`无法访问文件 ${file}:`, error);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果请求参数 onlyDirectory 为 true,则只返回目录信息
|
||||
if (onlyDirectory) {
|
||||
fileInfos = fileInfos.filter((info) => info.isDirectory);
|
||||
}
|
||||
|
||||
return sendSuccess(res, fileInfos);
|
||||
} catch (_error) {
|
||||
console.error('读取目录失败:', _error);
|
||||
return sendError(res, '读取目录失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 创建目录
|
||||
export const CreateDirHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { path: dirPath } = req.body;
|
||||
|
||||
let normalizedPath: string;
|
||||
try {
|
||||
normalizedPath = normalizePath(dirPath);
|
||||
} catch (_pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保路径是绝对路径
|
||||
if (!path.isAbsolute(normalizedPath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
// 检查是否已存在同类型(目录)
|
||||
if (await checkSameTypeExists(normalizedPath, true)) {
|
||||
return sendError(res, '同名目录已存在');
|
||||
}
|
||||
|
||||
await fsProm.mkdir(normalizedPath, { recursive: true });
|
||||
return sendSuccess(res, true);
|
||||
} catch (_error) {
|
||||
return sendError(res, '创建目录失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 删除文件/目录
|
||||
export const DeleteHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { path: targetPath } = req.body;
|
||||
|
||||
let normalizedPath: string;
|
||||
try {
|
||||
normalizedPath = normalizePath(targetPath);
|
||||
} catch (_pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保路径是绝对路径
|
||||
if (!path.isAbsolute(normalizedPath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
const stat = await fsProm.stat(normalizedPath);
|
||||
if (stat.isDirectory()) {
|
||||
await fsProm.rm(normalizedPath, { recursive: true });
|
||||
} else {
|
||||
await fsProm.unlink(normalizedPath);
|
||||
}
|
||||
return sendSuccess(res, true);
|
||||
} catch (_error) {
|
||||
return sendError(res, '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 批量删除文件/目录
|
||||
export const BatchDeleteHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { paths } = req.body;
|
||||
for (const targetPath of paths) {
|
||||
let normalizedPath: string;
|
||||
try {
|
||||
normalizedPath = normalizePath(targetPath);
|
||||
} catch (_pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保路径是绝对路径
|
||||
if (!path.isAbsolute(normalizedPath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
const stat = await fsProm.stat(normalizedPath);
|
||||
if (stat.isDirectory()) {
|
||||
await fsProm.rm(normalizedPath, { recursive: true });
|
||||
} else {
|
||||
await fsProm.unlink(normalizedPath);
|
||||
}
|
||||
}
|
||||
return sendSuccess(res, true);
|
||||
} catch (_error) {
|
||||
return sendError(res, '批量删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 读取文件内容
|
||||
export const ReadFileHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
let filePath: string;
|
||||
try {
|
||||
filePath = normalizePath(getQueryStringParam(req.query['path']));
|
||||
} catch (_pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保路径是绝对路径
|
||||
if (!path.isAbsolute(filePath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
let content = await fsProm.readFile(filePath, 'utf-8');
|
||||
|
||||
// 如果是WebUI配置文件,对token进行脱敏处理
|
||||
if (isWebUIConfigFile(filePath)) {
|
||||
content = sanitizeWebUIConfig(content);
|
||||
}
|
||||
|
||||
return sendSuccess(res, content);
|
||||
} catch (_error) {
|
||||
return sendError(res, '读取文件失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 写入文件内容
|
||||
export const WriteFileHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { path: filePath, content } = req.body;
|
||||
|
||||
// 安全的路径规范化,如果检测到路径遍历攻击会抛出异常
|
||||
let normalizedPath: string;
|
||||
try {
|
||||
normalizedPath = normalizePath(filePath);
|
||||
} catch (_pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保路径是绝对路径
|
||||
if (!path.isAbsolute(normalizedPath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
let finalContent = content;
|
||||
|
||||
// 检查是否为WebUI配置文件
|
||||
if (isWebUIConfigFile(normalizedPath)) {
|
||||
try {
|
||||
// 解析要写入的配置
|
||||
const configToWrite = JSON.parse(content);
|
||||
// 获取内存中的token,覆盖前端传来的token
|
||||
const memoryToken = getInitialWebUiToken();
|
||||
if (memoryToken) {
|
||||
configToWrite.token = memoryToken;
|
||||
finalContent = JSON.stringify(configToWrite, null, 4);
|
||||
}
|
||||
} catch (_e) {
|
||||
// 如果解析失败 说明不符合json格式 不允许写入
|
||||
return sendError(res, '写入的WebUI配置文件内容格式错误,必须是合法的JSON');
|
||||
}
|
||||
}
|
||||
|
||||
await fsProm.writeFile(normalizedPath, finalContent, 'utf-8');
|
||||
return sendSuccess(res, true);
|
||||
} catch (_error) {
|
||||
return sendError(res, '写入文件失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 创建新文件
|
||||
export const CreateFileHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { path: filePath } = req.body;
|
||||
|
||||
let normalizedPath: string;
|
||||
try {
|
||||
normalizedPath = normalizePath(filePath);
|
||||
} catch (_pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保路径是绝对路径
|
||||
if (!path.isAbsolute(normalizedPath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
// 检查是否已存在同类型(文件)
|
||||
if (await checkSameTypeExists(normalizedPath, false)) {
|
||||
return sendError(res, '同名文件已存在');
|
||||
}
|
||||
|
||||
await fsProm.writeFile(normalizedPath, '', 'utf-8');
|
||||
return sendSuccess(res, true);
|
||||
} catch (_error) {
|
||||
return sendError(res, '创建文件失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 重命名文件/目录
|
||||
export const RenameHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { oldPath, newPath } = req.body;
|
||||
|
||||
let normalizedOldPath: string;
|
||||
let normalizedNewPath: string;
|
||||
try {
|
||||
normalizedOldPath = normalizePath(oldPath);
|
||||
normalizedNewPath = normalizePath(newPath);
|
||||
} catch (_pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保路径是绝对路径
|
||||
if (!path.isAbsolute(normalizedOldPath) || !path.isAbsolute(normalizedNewPath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
await fsProm.rename(normalizedOldPath, normalizedNewPath);
|
||||
return sendSuccess(res, true);
|
||||
} catch (_error) {
|
||||
return sendError(res, '重命名失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 移动文件/目录
|
||||
export const MoveHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { sourcePath, targetPath } = req.body;
|
||||
|
||||
let normalizedSourcePath: string;
|
||||
let normalizedTargetPath: string;
|
||||
try {
|
||||
normalizedSourcePath = normalizePath(sourcePath);
|
||||
normalizedTargetPath = normalizePath(targetPath);
|
||||
} catch (_pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保路径是绝对路径
|
||||
if (!path.isAbsolute(normalizedSourcePath) || !path.isAbsolute(normalizedTargetPath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
await fsProm.rename(normalizedSourcePath, normalizedTargetPath);
|
||||
return sendSuccess(res, true);
|
||||
} catch (_error) {
|
||||
return sendError(res, '移动失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 批量移动
|
||||
export const BatchMoveHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { items } = req.body;
|
||||
for (const { sourcePath, targetPath } of items) {
|
||||
let normalizedSourcePath: string;
|
||||
let normalizedTargetPath: string;
|
||||
try {
|
||||
normalizedSourcePath = normalizePath(sourcePath);
|
||||
normalizedTargetPath = normalizePath(targetPath);
|
||||
} catch (_pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保路径是绝对路径
|
||||
if (!path.isAbsolute(normalizedSourcePath) || !path.isAbsolute(normalizedTargetPath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
await fsProm.rename(normalizedSourcePath, normalizedTargetPath);
|
||||
}
|
||||
return sendSuccess(res, true);
|
||||
} catch (_error) {
|
||||
return sendError(res, '批量移动失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 新增:文件下载处理方法(注意流式传输,不将整个文件读入内存)
|
||||
export const DownloadHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
let filePath: string;
|
||||
try {
|
||||
filePath = normalizePath(getQueryStringParam(req.query['path']));
|
||||
} catch (_pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
return sendError(res, '参数错误');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保路径是绝对路径
|
||||
if (!path.isAbsolute(filePath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
const stat = await fsProm.stat(filePath);
|
||||
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
let filename = path.basename(filePath);
|
||||
if (stat.isDirectory()) {
|
||||
filename = path.basename(filePath) + '.zip';
|
||||
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`);
|
||||
const zipStream = new PassThrough();
|
||||
compressing.zip.compressDir(filePath, zipStream as unknown as fs.WriteStream).catch((err) => {
|
||||
console.error('压缩目录失败:', err);
|
||||
res.end();
|
||||
});
|
||||
zipStream.pipe(res);
|
||||
return;
|
||||
}
|
||||
res.setHeader('Content-Length', stat.size);
|
||||
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`);
|
||||
const stream = fs.createReadStream(filePath);
|
||||
stream.pipe(res);
|
||||
} catch (_error) {
|
||||
return sendError(res, '下载失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 批量下载:将多个文件/目录打包为 zip 文件下载
|
||||
export const BatchDownloadHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { paths } = req.body as { paths: string[]; };
|
||||
if (!paths || !Array.isArray(paths) || paths.length === 0) {
|
||||
return sendError(res, '参数错误');
|
||||
}
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename=files.zip');
|
||||
|
||||
const zipStream = new compressing.zip.Stream();
|
||||
// 修改:根据文件类型设置 relativePath
|
||||
for (const filePath of paths) {
|
||||
let normalizedPath: string;
|
||||
try {
|
||||
normalizedPath = normalizePath(filePath);
|
||||
} catch (_pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保规范化后的路径是绝对路径
|
||||
if (!path.isAbsolute(normalizedPath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
const stat = await fsProm.stat(normalizedPath);
|
||||
if (stat.isDirectory()) {
|
||||
zipStream.addEntry(normalizedPath, { relativePath: '' });
|
||||
} else {
|
||||
// 确保相对路径只使用文件名,防止路径遍历
|
||||
const safeName = path.basename(normalizedPath);
|
||||
zipStream.addEntry(normalizedPath, { relativePath: safeName });
|
||||
}
|
||||
}
|
||||
zipStream.pipe(res);
|
||||
res.on('finish', () => {
|
||||
zipStream.destroy();
|
||||
});
|
||||
} catch (_error) {
|
||||
return sendError(res, '下载失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 修改上传处理方法
|
||||
export const UploadHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
await diskUploader(req, res);
|
||||
return sendSuccess(res, true, '文件上传成功', true);
|
||||
} catch (_error) {
|
||||
let errorMessage = '文件上传失败';
|
||||
|
||||
if (_error instanceof multer.MulterError) {
|
||||
switch (_error.code) {
|
||||
case 'LIMIT_FILE_SIZE':
|
||||
errorMessage = '文件大小超过限制(40MB)';
|
||||
break;
|
||||
case 'LIMIT_UNEXPECTED_FILE':
|
||||
errorMessage = '无效的文件上传字段';
|
||||
break;
|
||||
default:
|
||||
errorMessage = `上传错误: ${_error.message}`;
|
||||
}
|
||||
} else if (_error instanceof Error) {
|
||||
errorMessage = _error.message;
|
||||
}
|
||||
return sendError(res, errorMessage, true);
|
||||
}
|
||||
};
|
||||
|
||||
// 上传WebUI字体文件处理方法
|
||||
export const UploadWebUIFontHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
await webUIFontUploader(req, res);
|
||||
return sendSuccess(res, true, '字体文件上传成功', true);
|
||||
} catch (error) {
|
||||
let errorMessage = '字体文件上传失败';
|
||||
|
||||
if (error instanceof multer.MulterError) {
|
||||
switch (error.code) {
|
||||
case 'LIMIT_FILE_SIZE':
|
||||
errorMessage = '字体文件大小超过限制(40MB)';
|
||||
break;
|
||||
case 'LIMIT_UNEXPECTED_FILE':
|
||||
errorMessage = '无效的文件上传字段';
|
||||
break;
|
||||
default:
|
||||
errorMessage = `上传错误: ${error.message}`;
|
||||
}
|
||||
} else if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
return sendError(res, errorMessage, true);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除WebUI字体文件处理方法
|
||||
export const DeleteWebUIFontHandler: RequestHandler = async (_req, res) => {
|
||||
try {
|
||||
const fontPath = WebUiConfig.GetWebUIFontPath();
|
||||
const exists = await WebUiConfig.CheckWebUIFontExist();
|
||||
|
||||
if (!exists) {
|
||||
return sendSuccess(res, true);
|
||||
}
|
||||
|
||||
await fsProm.unlink(fontPath);
|
||||
return sendSuccess(res, true);
|
||||
} catch (_error) {
|
||||
return sendError(res, '删除字体文件失败');
|
||||
}
|
||||
};
|
||||
80
packages/napcat-webui-backend/src/api/Log.ts
Normal file
80
packages/napcat-webui-backend/src/api/Log.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { RequestHandler } from 'express';
|
||||
import { sendError, sendSuccess } from '../utils/response';
|
||||
import { logSubscription } from 'napcat-common/src/log';
|
||||
import { terminalManager } from '../terminal/terminal_manager';
|
||||
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
||||
// 判断是否是 macos
|
||||
const isMacOS = process.platform === 'darwin';
|
||||
|
||||
// 日志脱敏函数
|
||||
const sanitizeLog = (log: string): string => {
|
||||
// 脱敏 token 参数,将 token=xxx 替换为 token=***
|
||||
return log.replace(/token=[\w\d]+/gi, 'token=***');
|
||||
};
|
||||
// 日志记录
|
||||
export const LogHandler: RequestHandler = async (req, res) => {
|
||||
const filename = req.query['id'];
|
||||
if (!filename || typeof filename !== 'string') {
|
||||
return sendError(res, 'ID不能为空');
|
||||
}
|
||||
|
||||
if (filename.includes('..')) {
|
||||
return sendError(res, 'ID不合法');
|
||||
}
|
||||
const logContent = await WebUiConfig.GetLogContent(filename);
|
||||
const sanitizedLogContent = sanitizeLog(logContent);
|
||||
return sendSuccess(res, sanitizedLogContent);
|
||||
};
|
||||
|
||||
// 日志列表
|
||||
export const LogListHandler: RequestHandler = async (_, res) => {
|
||||
const logList = await WebUiConfig.GetLogsList();
|
||||
return sendSuccess(res, logList);
|
||||
};
|
||||
|
||||
// 实时日志(SSE)
|
||||
export const LogRealTimeHandler: RequestHandler = async (req, res) => {
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
const listener = (log: string) => {
|
||||
try {
|
||||
const sanitizedLog = sanitizeLog(log);
|
||||
res.write(`data: ${sanitizedLog}\n\n`);
|
||||
} catch (error) {
|
||||
console.error('向客户端写入日志数据时出错:', error);
|
||||
}
|
||||
};
|
||||
logSubscription.subscribe(listener);
|
||||
req.on('close', () => {
|
||||
logSubscription.unsubscribe(listener);
|
||||
});
|
||||
};
|
||||
|
||||
// 终端相关处理器
|
||||
export const CreateTerminalHandler: RequestHandler = async (req, res) => {
|
||||
if (isMacOS) {
|
||||
return sendError(res, 'MacOS不支持终端');
|
||||
}
|
||||
try {
|
||||
const { cols, rows } = req.body;
|
||||
const { id } = terminalManager.createTerminal(cols, rows);
|
||||
return sendSuccess(res, { id });
|
||||
} catch (error) {
|
||||
console.error('Failed to create terminal:', error);
|
||||
return sendError(res, '创建终端失败');
|
||||
}
|
||||
};
|
||||
|
||||
export const GetTerminalListHandler: RequestHandler = (_, res) => {
|
||||
const list = terminalManager.getTerminalList();
|
||||
return sendSuccess(res, list);
|
||||
};
|
||||
|
||||
export const CloseTerminalHandler: RequestHandler = (req, res) => {
|
||||
const id = req.params['id'];
|
||||
if (!id) {
|
||||
return sendError(res, 'ID不能为空');
|
||||
}
|
||||
terminalManager.closeTerminal(id);
|
||||
return sendSuccess(res, {});
|
||||
};
|
||||
60
packages/napcat-webui-backend/src/api/OB11Config.ts
Normal file
60
packages/napcat-webui-backend/src/api/OB11Config.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { loadConfig, OneBotConfig } from 'napcat-onebot/config/config';
|
||||
import { webUiPathWrapper } from '@/napcat-webui-backend/index';
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/helper/Data';
|
||||
import { sendError, sendSuccess } from '@/napcat-webui-backend/utils/response';
|
||||
import { isEmpty } from '@/napcat-webui-backend/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);
|
||||
}
|
||||
};
|
||||
14
packages/napcat-webui-backend/src/api/Proxy.ts
Normal file
14
packages/napcat-webui-backend/src/api/Proxy.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import { RequestUtil } from '@/napcat-common/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参数不合法');
|
||||
}
|
||||
};
|
||||
90
packages/napcat-webui-backend/src/api/QQLogin.ts
Normal file
90
packages/napcat-webui-backend/src/api/QQLogin.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { RequestHandler } from 'express';
|
||||
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/helper/Data';
|
||||
import { isEmpty } from '@/napcat-webui-backend/utils/check';
|
||||
import { sendError, sendSuccess } from '@/napcat-webui-backend/utils/response';
|
||||
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
||||
|
||||
// 获取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) => {
|
||||
const data = {
|
||||
isLogin: WebUiDataRuntime.getQQLoginStatus(),
|
||||
qrcodeurl: WebUiDataRuntime.getQQLoginQrcodeURL(),
|
||||
};
|
||||
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 data = WebUiDataRuntime.getQQLoginInfo();
|
||||
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);
|
||||
};
|
||||
19
packages/napcat-webui-backend/src/api/Status.ts
Normal file
19
packages/napcat-webui-backend/src/api/Status.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import { SystemStatus, statusHelperSubscription } from '@/napcat-core/helper/status';
|
||||
|
||||
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();
|
||||
});
|
||||
};
|
||||
127
packages/napcat-webui-backend/src/api/WebUIConfig.ts
Normal file
127
packages/napcat-webui-backend/src/api/WebUIConfig.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
||||
import { sendError, sendSuccess } from '@/napcat-webui-backend/utils/response';
|
||||
import { isEmpty } from '@/napcat-webui-backend/utils/check';
|
||||
|
||||
// 获取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,
|
||||
disableNonLANAccess: config.disableNonLANAccess,
|
||||
});
|
||||
} 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}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取是否禁用非局域网访问
|
||||
export const GetDisableNonLANAccessHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const disable = await WebUiConfig.GetDisableNonLANAccess();
|
||||
return sendSuccess(res, disable);
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
return sendError(res, `获取非局域网访问禁用状态失败: ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 更新是否禁用非局域网访问
|
||||
export const UpdateDisableNonLANAccessHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { disable } = req.body;
|
||||
|
||||
if (typeof disable !== 'boolean') {
|
||||
return sendError(res, 'disable参数必须是布尔值');
|
||||
}
|
||||
|
||||
await WebUiConfig.UpdateDisableNonLANAccess(disable);
|
||||
return sendSuccess(res, null);
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
return sendError(res, `更新非局域网访问禁用状态失败: ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 更新WebUI基础配置
|
||||
export const UpdateWebUIConfigHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { host, port, loginRate, disableWebUI, disableNonLANAccess } = 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 (disableNonLANAccess !== undefined) {
|
||||
if (typeof disableNonLANAccess !== 'boolean') {
|
||||
return sendError(res, 'disableNonLANAccess必须是布尔值');
|
||||
}
|
||||
updateConfig.disableNonLANAccess = disableNonLANAccess;
|
||||
}
|
||||
|
||||
await WebUiConfig.UpdateWebUIConfig(updateConfig);
|
||||
return sendSuccess(res, null);
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
return sendError(res, `更新WebUI配置失败: ${msg}`);
|
||||
}
|
||||
};
|
||||
13
packages/napcat-webui-backend/src/const/status.ts
Normal file
13
packages/napcat-webui-backend/src/const/status.ts
Normal file
@@ -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,
|
||||
}
|
||||
151
packages/napcat-webui-backend/src/helper/Data.ts
Normal file
151
packages/napcat-webui-backend/src/helper/Data.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import type { LoginRuntimeType } from '../types/data';
|
||||
import packageJson from '../../../../package.json';
|
||||
import store from '@/napcat-common/store';
|
||||
|
||||
const LoginRuntime: LoginRuntimeType = {
|
||||
LoginCurrentTime: Date.now(),
|
||||
LoginCurrentRate: 0,
|
||||
QQLoginStatus: false, // 已实现 但太傻了 得去那边注册个回调刷新
|
||||
QQQRCodeURL: '',
|
||||
QQLoginUin: '',
|
||||
QQLoginInfo: {
|
||||
uid: '',
|
||||
uin: '',
|
||||
nick: '',
|
||||
},
|
||||
QQVersion: 'unknown',
|
||||
onQQLoginStatusChange: async (status: boolean) => {
|
||||
LoginRuntime.QQLoginStatus = status;
|
||||
},
|
||||
onWebUiTokenChange: async (_token: string) => {
|
||||
|
||||
},
|
||||
NapCatHelper: {
|
||||
onOB11ConfigChanged: async () => {
|
||||
|
||||
},
|
||||
onQuickLoginRequested: async () => {
|
||||
return { result: false, message: '' };
|
||||
},
|
||||
QQLoginList: [],
|
||||
NewQQLoginList: [],
|
||||
},
|
||||
packageJson,
|
||||
WebUiConfigQuickFunction: async () => {
|
||||
|
||||
},
|
||||
};
|
||||
|
||||
export const WebUiDataRuntime = {
|
||||
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;
|
||||
},
|
||||
|
||||
setQQLoginQrcodeURL (url: LoginRuntimeType['QQQRCodeURL']): void {
|
||||
LoginRuntime.QQQRCodeURL = url;
|
||||
},
|
||||
|
||||
getQQLoginQrcodeURL (): LoginRuntimeType['QQQRCodeURL'] {
|
||||
return LoginRuntime.QQQRCodeURL;
|
||||
},
|
||||
|
||||
setQQLoginInfo (info: LoginRuntimeType['QQLoginInfo']): void {
|
||||
LoginRuntime.QQLoginInfo = info;
|
||||
LoginRuntime.QQLoginUin = info.uin.toString();
|
||||
},
|
||||
|
||||
getQQLoginInfo (): LoginRuntimeType['QQLoginInfo'] {
|
||||
return LoginRuntime.QQLoginInfo;
|
||||
},
|
||||
|
||||
getQQLoginUin (): LoginRuntimeType['QQLoginUin'] {
|
||||
return LoginRuntime.QQLoginUin;
|
||||
},
|
||||
|
||||
getQQQuickLoginList (): LoginRuntimeType['NapCatHelper']['QQLoginList'] {
|
||||
return LoginRuntime.NapCatHelper.QQLoginList;
|
||||
},
|
||||
|
||||
setQQQuickLoginList (list: LoginRuntimeType['NapCatHelper']['QQLoginList']): void {
|
||||
LoginRuntime.NapCatHelper.QQLoginList = list;
|
||||
},
|
||||
|
||||
getQQNewLoginList (): LoginRuntimeType['NapCatHelper']['NewQQLoginList'] {
|
||||
return LoginRuntime.NapCatHelper.NewQQLoginList;
|
||||
},
|
||||
|
||||
setQQNewLoginList (list: LoginRuntimeType['NapCatHelper']['NewQQLoginList']): void {
|
||||
LoginRuntime.NapCatHelper.NewQQLoginList = list;
|
||||
},
|
||||
|
||||
setQuickLoginCall (func: LoginRuntimeType['NapCatHelper']['onQuickLoginRequested']): void {
|
||||
LoginRuntime.NapCatHelper.onQuickLoginRequested = func;
|
||||
},
|
||||
|
||||
requestQuickLogin: function (uin) {
|
||||
return LoginRuntime.NapCatHelper.onQuickLoginRequested(uin);
|
||||
} as LoginRuntimeType['NapCatHelper']['onQuickLoginRequested'],
|
||||
|
||||
setOnOB11ConfigChanged (func: LoginRuntimeType['NapCatHelper']['onOB11ConfigChanged']): void {
|
||||
LoginRuntime.NapCatHelper.onOB11ConfigChanged = func;
|
||||
},
|
||||
|
||||
setOB11Config: function (ob11) {
|
||||
return LoginRuntime.NapCatHelper.onOB11ConfigChanged(ob11);
|
||||
} as LoginRuntimeType['NapCatHelper']['onOB11ConfigChanged'],
|
||||
|
||||
getPackageJson () {
|
||||
return LoginRuntime.packageJson;
|
||||
},
|
||||
|
||||
setQQVersion (version: string) {
|
||||
LoginRuntime.QQVersion = version;
|
||||
},
|
||||
|
||||
getQQVersion () {
|
||||
return LoginRuntime.QQVersion;
|
||||
},
|
||||
|
||||
setWebUiConfigQuickFunction (func: LoginRuntimeType['WebUiConfigQuickFunction']): void {
|
||||
LoginRuntime.WebUiConfigQuickFunction = func;
|
||||
},
|
||||
runWebUiConfigQuickFunction: async function () {
|
||||
await LoginRuntime.WebUiConfigQuickFunction();
|
||||
},
|
||||
};
|
||||
106
packages/napcat-webui-backend/src/helper/SignToken.ts
Normal file
106
packages/napcat-webui-backend/src/helper/SignToken.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import crypto from 'crypto';
|
||||
import store from '@/napcat-common/store';
|
||||
export class AuthHelper {
|
||||
private static readonly secretKey = Math.random().toString(36).slice(2);
|
||||
|
||||
/**
|
||||
* 签名凭证方法。
|
||||
* @param hash 待签名的凭证字符串。
|
||||
* @returns 签名后的凭证对象。
|
||||
*/
|
||||
public static signCredential (hash: string): WebUiCredentialJson {
|
||||
const innerJson: WebUiCredentialInnerJson = {
|
||||
CreatedTime: Date.now(),
|
||||
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 currentTime = Date.now() / 1000;
|
||||
const createdTime = credentialJson.Data.CreatedTime;
|
||||
const timeDifference = currentTime - createdTime;
|
||||
return timeDifference <= 3600 && 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;
|
||||
}
|
||||
}
|
||||
240
packages/napcat-webui-backend/src/helper/config.ts
Normal file
240
packages/napcat-webui-backend/src/helper/config.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { webUiPathWrapper, getInitialWebUiToken } from '@/napcat-webui-backend/index'
|
||||
import { Type, Static } from '@sinclair/typebox';
|
||||
import Ajv from 'ajv';
|
||||
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: '0.0.0.0' }),
|
||||
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 }),
|
||||
// 是否关闭非局域网访问
|
||||
disableNonLANAccess: Type.Boolean({ default: false }),
|
||||
});
|
||||
|
||||
export type WebUiConfigType = Static<typeof WebUiConfigSchema>;
|
||||
|
||||
// 读取当前目录下名为 webui.json 的配置文件,如果不存在则创建初始化配置文件
|
||||
export class WebUiConfigWrapper {
|
||||
WebUiConfigData: WebUiConfigType | undefined = undefined;
|
||||
|
||||
private validateAndApplyDefaults (config: Partial<WebUiConfigType>): WebUiConfigType {
|
||||
new Ajv({ coerceTypes: true, useDefaults: true }).compile(WebUiConfigSchema)(config);
|
||||
return config 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 [];
|
||||
}
|
||||
|
||||
// 判断字体是否存在(webui.woff)
|
||||
async CheckWebUIFontExist (): Promise<boolean> {
|
||||
const fontsPath = resolve(webUiPathWrapper.configPath, './fonts');
|
||||
return await fs
|
||||
.access(resolve(fontsPath, './webui.woff'), constants.F_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
// 获取webui字体文件路径
|
||||
GetWebUIFontPath (): 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 GetDisableNonLANAccess (): Promise<boolean> {
|
||||
const config = await this.GetWebUIConfig();
|
||||
return config.disableNonLANAccess;
|
||||
}
|
||||
|
||||
// 更新是否禁用非局域网访问
|
||||
async UpdateDisableNonLANAccess (disable: boolean): Promise<void> {
|
||||
await this.UpdateWebUIConfig({ disableNonLANAccess: disable });
|
||||
}
|
||||
}
|
||||
285
packages/napcat-webui-backend/src/index.ts
Normal file
285
packages/napcat-webui-backend/src/index.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* @file WebUI服务入口文件
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { createServer } from 'http';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createServer as createHttpsServer } from 'https';
|
||||
import { LogWrapper } from 'napcat-common/src/log';
|
||||
import { NapCatPathWrapper } from 'napcat-common/src/path';
|
||||
import { WebUiConfigWrapper } from '@/napcat-webui-backend/helper/config';
|
||||
import { ALLRouter } from '@/napcat-webui-backend/router';
|
||||
import { cors } from '@/napcat-webui-backend/middleware/cors';
|
||||
import { createUrl, getRandomToken } from '@/napcat-webui-backend/utils/url';
|
||||
import { sendError } from '@/napcat-webui-backend/utils/response';
|
||||
import { join } from 'node:path';
|
||||
import { terminalManager } from '@/napcat-webui-backend/terminal/terminal_manager';
|
||||
import multer from 'multer';
|
||||
import * as net from 'node:net';
|
||||
import { WebUiDataRuntime } from './helper/Data';
|
||||
import { existsSync, readFileSync } from 'node:fs'; // 引入multer用于错误捕获
|
||||
|
||||
// 实例化Express
|
||||
const app = express();
|
||||
/**
|
||||
* 初始化并启动WebUI服务。
|
||||
* 该函数配置了Express服务器以支持JSON解析和静态文件服务,并监听6099端口。
|
||||
* 无需参数。
|
||||
* @returns {Promise<void>} 无返回值。
|
||||
*/
|
||||
export let WebUiConfig: WebUiConfigWrapper;
|
||||
export let webUiPathWrapper: NapCatPathWrapper;
|
||||
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 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: LogWrapper): Promise<{ key: string, cert: string } | null> {
|
||||
try {
|
||||
const certPath = join(webUiPathWrapper.configPath, 'cert.pem');
|
||||
const keyPath = join(webUiPathWrapper.configPath, 'key.pem');
|
||||
|
||||
if (existsSync(certPath) && existsSync(keyPath)) {
|
||||
const cert = readFileSync(certPath, 'utf8');
|
||||
const key = readFileSync(keyPath, 'utf8');
|
||||
logger.log('[NapCat] [WebUi] 找到SSL证书,将启用HTTPS模式');
|
||||
return { cert, key };
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.log('[NapCat] [WebUi] 检查SSL证书时出错: ' + error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export async function InitWebUi (logger: LogWrapper, pathWrapper: NapCatPathWrapper) {
|
||||
webUiPathWrapper = pathWrapper;
|
||||
WebUiConfig = new WebUiConfigWrapper();
|
||||
let config = await WebUiConfig.GetWebUIConfig();
|
||||
|
||||
// 检查并更新默认密码 - 最高优先级
|
||||
if (config.token === 'napcat' || !config.token) {
|
||||
const randomToken = getRandomToken(8);
|
||||
await WebUiConfig.UpdateWebUIConfig({ token: randomToken });
|
||||
logger.log('[NapCat] [WebUi] 检测到默认密码,已自动更新为安全密码');
|
||||
|
||||
// 存储token到全局变量,等待QQ登录成功后发送
|
||||
setPendingTokenToSend(randomToken);
|
||||
logger.log('[NapCat] [WebUi] 新密码将在QQ登录成功后发送给用户');
|
||||
|
||||
// 重新获取更新后的配置
|
||||
config = await WebUiConfig.GetWebUIConfig();
|
||||
}
|
||||
|
||||
// 存储启动时的初始token用于鉴权
|
||||
setInitialWebUiToken(config.token);
|
||||
|
||||
// 检查是否禁用WebUI
|
||||
if (config.disableWebUI) {
|
||||
logger.log('[NapCat] [WebUi] WebUI is disabled by configuration.');
|
||||
return;
|
||||
}
|
||||
|
||||
const [host, port, token] = await InitPort(config);
|
||||
webUiRuntimePort = port;
|
||||
if (port === 0) {
|
||||
logger.log('[NapCat] [WebUi] Current WebUi is not run.');
|
||||
return;
|
||||
}
|
||||
WebUiDataRuntime.setWebUiConfigQuickFunction(
|
||||
async () => {
|
||||
const autoLoginAccount = process.env['NAPCAT_QUICK_ACCOUNT'] || WebUiConfig.getAutoLoginAccount();
|
||||
if (autoLoginAccount) {
|
||||
try {
|
||||
const { result, message } = await WebUiDataRuntime.requestQuickLogin(autoLoginAccount);
|
||||
if (!result) {
|
||||
throw new Error(message);
|
||||
}
|
||||
console.log(`[NapCat] [WebUi] Auto login account: ${autoLoginAccount}`);
|
||||
} catch (error) {
|
||||
console.log('[NapCat] [WebUi] Auto login account failed.' + error);
|
||||
}
|
||||
}
|
||||
});
|
||||
// ------------注册中间件------------
|
||||
// 使用express的json中间件
|
||||
app.use(express.json());
|
||||
|
||||
// CORS中间件
|
||||
// TODO:
|
||||
app.use(cors);
|
||||
|
||||
// 如果是webui字体文件,挂载字体文件
|
||||
app.use('/webui/fonts/AaCute.woff', async (_req, res, next) => {
|
||||
const isFontExist = await WebUiConfig.CheckWebUIFontExist();
|
||||
if (isFontExist) {
|
||||
res.sendFile(WebUiConfig.GetWebUIFontPath());
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
// 如果是自定义色彩,构建一个css文件
|
||||
app.use('/files/theme.css', async (_req, res) => {
|
||||
const colors = await WebUiConfig.GetTheme();
|
||||
|
||||
let css = ':root, .light, [data-theme="light"] {';
|
||||
for (const key in colors.light) {
|
||||
css += `${key}: ${colors.light[key]};`;
|
||||
}
|
||||
css += '}';
|
||||
css += '.dark, [data-theme="dark"] {';
|
||||
for (const key in colors.dark) {
|
||||
css += `${key}: ${colors.dark[key]};`;
|
||||
}
|
||||
css += '}';
|
||||
|
||||
res.send(css);
|
||||
});
|
||||
|
||||
// ------------中间件结束------------
|
||||
|
||||
// ------------挂载路由------------
|
||||
// 挂载静态路由(前端),路径为 /webui
|
||||
app.use('/webui', express.static(pathWrapper.staticPath, {
|
||||
maxAge: '1d',
|
||||
}));
|
||||
// 初始化WebSocket服务器
|
||||
const sslCerts = await checkCertificates(logger);
|
||||
const isHttps = !!sslCerts;
|
||||
const server = isHttps && sslCerts ? createHttpsServer(sslCerts, app) : createServer(app);
|
||||
server.on('upgrade', (request, socket, head) => {
|
||||
terminalManager.initialize(request, socket, head, logger);
|
||||
});
|
||||
// 挂载API接口
|
||||
app.use('/api', ALLRouter);
|
||||
// 所有剩下的请求都转到静态页面
|
||||
const indexFile = join(pathWrapper.staticPath, 'index.html');
|
||||
|
||||
app.all(/\/webui\/(.*)/, (_req, res) => {
|
||||
res.sendFile(indexFile);
|
||||
});
|
||||
|
||||
// 初始服务(先放个首页)
|
||||
app.all('/', (_req, res) => {
|
||||
res.status(301).header('Location', '/webui').send();
|
||||
});
|
||||
|
||||
// 错误处理中间件,捕获multer的错误
|
||||
app.use((err: Error, _: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
if (err instanceof multer.MulterError) {
|
||||
return sendError(res, err.message, true);
|
||||
}
|
||||
next(err);
|
||||
});
|
||||
|
||||
// 全局错误处理中间件(非multer错误)
|
||||
app.use((_: Error, __: express.Request, res: express.Response, ___: express.NextFunction) => {
|
||||
sendError(res, 'An unknown error occurred.', true);
|
||||
});
|
||||
|
||||
// ------------启动服务------------
|
||||
server.listen(port, host, async () => {
|
||||
const searchParams = { token };
|
||||
logger.log(`[NapCat] [WebUi] WebUi Token: ${token}`);
|
||||
logger.log(
|
||||
`[NapCat] [WebUi] WebUi User Panel Url: ${createUrl('127.0.0.1', port.toString(), '/webui', searchParams)}`
|
||||
);
|
||||
if (host !== '') {
|
||||
logger.log(
|
||||
`[NapCat] [WebUi] WebUi User Panel Url: ${createUrl(host, port.toString(), '/webui', searchParams)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ------------Over!------------
|
||||
}
|
||||
|
||||
async function tryUseHost (host: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const server = net.createServer();
|
||||
server.on('listening', () => {
|
||||
server.close();
|
||||
resolve(host);
|
||||
});
|
||||
|
||||
server.on('error', (err: any) => {
|
||||
if (err.code === 'EADDRNOTAVAIL') {
|
||||
reject(new Error('主机地址验证失败,可能为非本机地址'));
|
||||
} else {
|
||||
reject(new Error(`遇到错误: ${err.code}`));
|
||||
}
|
||||
});
|
||||
|
||||
// 尝试监听 让系统随机分配一个端口
|
||||
server.listen(0, host);
|
||||
} catch (error) {
|
||||
// 这里捕获到的错误应该是启动服务器时的同步错误
|
||||
reject(new Error(`服务器启动时发生错误: ${error}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function tryUsePort (port: number, host: string, tryCount: number = 0): 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 (tryCount < MAX_PORT_TRY) {
|
||||
// 使用循环代替递归
|
||||
resolve(tryUsePort(port + 1, host, tryCount + 1));
|
||||
} else {
|
||||
reject(new Error(`端口尝试失败,达到最大尝试次数: ${MAX_PORT_TRY}`));
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`遇到错误: ${err.code}`));
|
||||
}
|
||||
});
|
||||
|
||||
// 尝试监听端口
|
||||
server.listen(port, host);
|
||||
} catch (error) {
|
||||
// 这里捕获到的错误应该是启动服务器时的同步错误
|
||||
reject(new Error(`服务器启动时发生错误: ${error}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
50
packages/napcat-webui-backend/src/middleware/auth.ts
Normal file
50
packages/napcat-webui-backend/src/middleware/auth.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
|
||||
import { getInitialWebUiToken } from '@/napcat-webui-backend/index';
|
||||
|
||||
import { AuthHelper } from '@/napcat-webui-backend/helper/SignToken';
|
||||
import { sendError } from '@/napcat-webui-backend/utils/response';
|
||||
|
||||
// 鉴权中间件
|
||||
export async function auth (req: Request, res: Response, next: NextFunction) {
|
||||
// 判断当前url是否为/login 如果是跳过鉴权
|
||||
if (req.url === '/auth/login') {
|
||||
return next();
|
||||
}
|
||||
|
||||
// 判断是否有Authorization头
|
||||
if (req.headers?.authorization) {
|
||||
// 切割参数以获取token
|
||||
const authorization = req.headers.authorization.split(' ');
|
||||
// 当Bearer后面没有参数时
|
||||
if (authorization.length < 2) {
|
||||
return sendError(res, 'Unauthorized');
|
||||
}
|
||||
// 获取token
|
||||
const hash = authorization[1];
|
||||
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');
|
||||
}
|
||||
62
packages/napcat-webui-backend/src/middleware/cors.ts
Normal file
62
packages/napcat-webui-backend/src/middleware/cors.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { RequestHandler } from 'express';
|
||||
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
||||
|
||||
// 检查是否为局域网IP地址
|
||||
function isLANIP (ip: string): boolean {
|
||||
if (!ip) return false;
|
||||
|
||||
// 移除IPv6的前缀,如果存在
|
||||
const cleanIP = ip.replace(/^::ffff:/, '');
|
||||
|
||||
// 本地回环地址
|
||||
if (cleanIP === '127.0.0.1' || cleanIP === 'localhost' || cleanIP === '::1') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查IPv4私有网络地址
|
||||
const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
||||
const match = cleanIP.match(ipv4Regex);
|
||||
|
||||
if (match) {
|
||||
const [, a, b] = match.map(Number);
|
||||
|
||||
// 10.0.0.0/8
|
||||
if (a === 10) return true;
|
||||
|
||||
// 172.16.0.0/12
|
||||
if (a === 172 && b !== undefined && b >= 16 && b <= 31) return true;
|
||||
|
||||
// 192.168.0.0/16
|
||||
if (a === 192 && b === 168) return true;
|
||||
|
||||
// 169.254.0.0/16 (链路本地地址)
|
||||
if (a === 169 && b === 254) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// CORS 中间件,跨域用
|
||||
export const cors: RequestHandler = async (req, res, next) => {
|
||||
// 检查是否禁用非局域网访问
|
||||
const config = await WebUiConfig.GetWebUIConfig();
|
||||
if (config.disableNonLANAccess) {
|
||||
const clientIP = req.ip || req.socket.remoteAddress || '';
|
||||
if (!isLANIP(clientIP)) {
|
||||
res.status(403).json({ error: '非局域网访问被禁止' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
};
|
||||
15
packages/napcat-webui-backend/src/router/Base.ts
Normal file
15
packages/napcat-webui-backend/src/router/Base.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Router } from 'express';
|
||||
import { GetThemeConfigHandler, PackageInfoHandler, QQVersionHandler, SetThemeConfigHandler } from '../api/BaseInfo';
|
||||
import { StatusRealTimeHandler } from '@/napcat-webui-backend/api/Status';
|
||||
import { GetProxyHandler } from '../api/Proxy';
|
||||
|
||||
const router = Router();
|
||||
// router: 获取nc的package.json信息
|
||||
router.get('/QQVersion', QQVersionHandler);
|
||||
router.get('/PackageInfo', PackageInfoHandler);
|
||||
router.get('/GetSysStatusRealTime', StatusRealTimeHandler);
|
||||
router.get('/proxy', GetProxyHandler);
|
||||
router.get('/Theme', GetThemeConfigHandler);
|
||||
router.post('/SetTheme', SetThemeConfigHandler);
|
||||
|
||||
export { router as BaseRouter };
|
||||
49
packages/napcat-webui-backend/src/router/File.ts
Normal file
49
packages/napcat-webui-backend/src/router/File.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Router } from 'express';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import {
|
||||
ListFilesHandler,
|
||||
CreateDirHandler,
|
||||
DeleteHandler,
|
||||
ReadFileHandler,
|
||||
WriteFileHandler,
|
||||
CreateFileHandler,
|
||||
BatchDeleteHandler, // 添加这一行
|
||||
RenameHandler,
|
||||
MoveHandler,
|
||||
BatchMoveHandler,
|
||||
DownloadHandler,
|
||||
BatchDownloadHandler, // 新增下载处理方法
|
||||
UploadHandler,
|
||||
UploadWebUIFontHandler,
|
||||
DeleteWebUIFontHandler, // 添加上传处理器
|
||||
} from '../api/File';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 1 * 60 * 1000, // 1分钟内
|
||||
max: 60, // 最大60个请求
|
||||
validate: {
|
||||
xForwardedForHeader: false,
|
||||
},
|
||||
});
|
||||
|
||||
router.use(apiLimiter);
|
||||
|
||||
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);
|
||||
export { router as FileRouter };
|
||||
23
packages/napcat-webui-backend/src/router/Log.ts
Normal file
23
packages/napcat-webui-backend/src/router/Log.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
LogHandler,
|
||||
LogListHandler,
|
||||
LogRealTimeHandler,
|
||||
CreateTerminalHandler,
|
||||
GetTerminalListHandler,
|
||||
CloseTerminalHandler,
|
||||
} from '../api/Log';
|
||||
|
||||
const 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 };
|
||||
11
packages/napcat-webui-backend/src/router/OB11Config.ts
Normal file
11
packages/napcat-webui-backend/src/router/OB11Config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
|
||||
import { OB11GetConfigHandler, OB11SetConfigHandler } from '@/napcat-webui-backend/api/OB11Config';
|
||||
|
||||
const router = Router();
|
||||
// router:读取配置
|
||||
router.post('/GetConfig', OB11GetConfigHandler);
|
||||
// router:写入配置
|
||||
router.post('/SetConfig', OB11SetConfigHandler);
|
||||
|
||||
export { router as OB11ConfigRouter };
|
||||
32
packages/napcat-webui-backend/src/router/QQLogin.ts
Normal file
32
packages/napcat-webui-backend/src/router/QQLogin.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Router } from 'express';
|
||||
|
||||
import {
|
||||
QQCheckLoginStatusHandler,
|
||||
QQGetQRcodeHandler,
|
||||
QQGetQuickLoginListHandler,
|
||||
QQSetQuickLoginHandler,
|
||||
QQGetLoginListNewHandler,
|
||||
getQQLoginInfoHandler,
|
||||
getAutoLoginAccountHandler,
|
||||
setAutoLoginAccountHandler,
|
||||
} from '@/napcat-webui-backend/api/QQLogin';
|
||||
|
||||
const 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);
|
||||
|
||||
export { router as QQLoginRouter };
|
||||
31
packages/napcat-webui-backend/src/router/WebUIConfig.ts
Normal file
31
packages/napcat-webui-backend/src/router/WebUIConfig.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
GetWebUIConfigHandler,
|
||||
GetDisableWebUIHandler,
|
||||
UpdateDisableWebUIHandler,
|
||||
GetDisableNonLANAccessHandler,
|
||||
UpdateDisableNonLANAccessHandler,
|
||||
UpdateWebUIConfigHandler,
|
||||
} from '@/napcat-webui-backend/api/WebUIConfig';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 获取WebUI基础配置
|
||||
router.get('/GetConfig', GetWebUIConfigHandler);
|
||||
|
||||
// 更新WebUI基础配置
|
||||
router.post('/UpdateConfig', UpdateWebUIConfigHandler);
|
||||
|
||||
// 获取是否禁用WebUI
|
||||
router.get('/GetDisableWebUI', GetDisableWebUIHandler);
|
||||
|
||||
// 更新是否禁用WebUI
|
||||
router.post('/UpdateDisableWebUI', UpdateDisableWebUIHandler);
|
||||
|
||||
// 获取是否禁用非局域网访问
|
||||
router.get('/GetDisableNonLANAccess', GetDisableNonLANAccessHandler);
|
||||
|
||||
// 更新是否禁用非局域网访问
|
||||
router.post('/UpdateDisableNonLANAccess', UpdateDisableNonLANAccessHandler);
|
||||
|
||||
export { router as WebUIConfigRouter };
|
||||
20
packages/napcat-webui-backend/src/router/auth.ts
Normal file
20
packages/napcat-webui-backend/src/router/auth.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Router } from 'express';
|
||||
|
||||
import {
|
||||
checkHandler,
|
||||
LoginHandler,
|
||||
LogoutHandler,
|
||||
UpdateTokenHandler,
|
||||
} from '@/napcat-webui-backend/api/Auth';
|
||||
|
||||
const router = Router();
|
||||
// router:登录
|
||||
router.post('/login', LoginHandler);
|
||||
// router:检查登录状态
|
||||
router.post('/check', checkHandler);
|
||||
// router:注销
|
||||
router.post('/logout', LogoutHandler);
|
||||
// router:更新token
|
||||
router.post('/update_token', UpdateTokenHandler);
|
||||
|
||||
export { router as AuthRouter };
|
||||
42
packages/napcat-webui-backend/src/router/index.ts
Normal file
42
packages/napcat-webui-backend/src/router/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @file 所有路由的入口文件
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
|
||||
import { OB11ConfigRouter } from '@/napcat-webui-backend/router/OB11Config';
|
||||
import { auth } from '@/napcat-webui-backend/middleware/auth';
|
||||
import { sendSuccess } from '@/napcat-webui-backend/utils/response';
|
||||
|
||||
import { QQLoginRouter } from '@/napcat-webui-backend/router/QQLogin';
|
||||
import { AuthRouter } from '@/napcat-webui-backend/router/auth';
|
||||
import { LogRouter } from '@/napcat-webui-backend/router/Log';
|
||||
import { BaseRouter } from '@/napcat-webui-backend/router/Base';
|
||||
import { FileRouter } from './File';
|
||||
import { WebUIConfigRouter } from './WebUIConfig';
|
||||
|
||||
const 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);
|
||||
|
||||
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 '';
|
||||
},
|
||||
});
|
||||
188
packages/napcat-webui-backend/src/terminal/terminal_manager.ts
Normal file
188
packages/napcat-webui-backend/src/terminal/terminal_manager.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
// import './init-dynamic-dirname';
|
||||
import { WebUiConfig } from '../index';
|
||||
import { AuthHelper } from '../helper/SignToken';
|
||||
import { LogWrapper } from 'napcat-common/src/log';
|
||||
import { WebSocket, WebSocketServer } from 'ws';
|
||||
import os from 'os';
|
||||
// @ts-ignore
|
||||
import { IPty, spawn as ptySpawn } from 'napcat-pty';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
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?: LogWrapper) {
|
||||
logger?.log('[NapCat] [WebUi] terminal websocket initialized');
|
||||
this.wss = new WebSocketServer({
|
||||
noServer: true,
|
||||
verifyClient: async (info, cb) => {
|
||||
// 验证 token
|
||||
const url = new URL(info.req.url || '', 'ws://localhost');
|
||||
const token = url.searchParams.get('token');
|
||||
const terminalId = url.searchParams.get('id');
|
||||
|
||||
if (!token || !terminalId) {
|
||||
// eslint-disable-next-line n/no-callback-literal
|
||||
cb(false, 401, 'Unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析token
|
||||
let Credential: WebUiCredentialJson;
|
||||
try {
|
||||
Credential = JSON.parse(Buffer.from(token, 'base64').toString('utf-8'));
|
||||
} catch (_e) {
|
||||
// eslint-disable-next-line n/no-callback-literal
|
||||
cb(false, 401, 'Unauthorized');
|
||||
return;
|
||||
}
|
||||
const config = await WebUiConfig.GetWebUIConfig();
|
||||
const validate = AuthHelper.validateCredentialWithinOneHour(config.token, Credential);
|
||||
if (!validate) {
|
||||
// eslint-disable-next-line n/no-callback-literal
|
||||
cb(false, 401, 'Unauthorized');
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line n/no-callback-literal
|
||||
cb(true);
|
||||
},
|
||||
});
|
||||
this.wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
this.wss?.emit('connection', ws, req);
|
||||
});
|
||||
this.wss.on('connection', async (ws, req) => {
|
||||
logger?.log('建立终端连接');
|
||||
try {
|
||||
const url = new URL(req.url || '', 'ws://localhost');
|
||||
const terminalId = url.searchParams.get('id')!;
|
||||
|
||||
const instance = this.terminals.get(terminalId);
|
||||
|
||||
if (!instance) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
instance.sockets.add(ws);
|
||||
instance.lastAccess = Date.now();
|
||||
|
||||
// 新增:发送当前终端内容给新连接
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'output', data: instance.buffer }));
|
||||
}
|
||||
|
||||
ws.on('message', (data) => {
|
||||
if (instance) {
|
||||
const result = JSON.parse(data.toString());
|
||||
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: '', // 初始化终端内容缓存
|
||||
};
|
||||
// @ts-ignore
|
||||
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 }));
|
||||
}
|
||||
});
|
||||
});
|
||||
// @ts-ignore
|
||||
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();
|
||||
6
packages/napcat-webui-backend/src/types/config.d.ts
vendored
Normal file
6
packages/napcat-webui-backend/src/types/config.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
interface WebUiConfigType {
|
||||
host: string;
|
||||
port: number;
|
||||
token: string;
|
||||
loginRate: number;
|
||||
}
|
||||
22
packages/napcat-webui-backend/src/types/data.d.ts
vendored
Normal file
22
packages/napcat-webui-backend/src/types/data.d.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { LoginListItem, SelfInfo } from '@/napcat-core';
|
||||
import type { OneBotConfig } from '@/napcat-onebot/config/config';
|
||||
|
||||
interface LoginRuntimeType {
|
||||
LoginCurrentTime: number;
|
||||
LoginCurrentRate: number;
|
||||
QQLoginStatus: boolean;
|
||||
QQQRCodeURL: string;
|
||||
QQLoginUin: string;
|
||||
QQLoginInfo: SelfInfo;
|
||||
QQVersion: string;
|
||||
onQQLoginStatusChange: (status: boolean) => Promise<void>;
|
||||
onWebUiTokenChange: (token: string) => Promise<void>;
|
||||
WebUiConfigQuickFunction: () => Promise<void>;
|
||||
NapCatHelper: {
|
||||
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string }>;
|
||||
onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;
|
||||
QQLoginList: string[];
|
||||
NewQQLoginList: LoginListItem[];
|
||||
};
|
||||
packageJson: object;
|
||||
}
|
||||
7
packages/napcat-webui-backend/src/types/server.d.ts
vendored
Normal file
7
packages/napcat-webui-backend/src/types/server.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
interface APIResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
type Protocol = 'http' | 'https' | 'ws' | 'wss';
|
||||
9
packages/napcat-webui-backend/src/types/sign_token.d.ts
vendored
Normal file
9
packages/napcat-webui-backend/src/types/sign_token.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
interface WebUiCredentialInnerJson {
|
||||
CreatedTime: number;
|
||||
HashEncoded: string;
|
||||
}
|
||||
|
||||
interface WebUiCredentialJson {
|
||||
Data: WebUiCredentialInnerJson;
|
||||
Hmac: string;
|
||||
}
|
||||
260
packages/napcat-webui-backend/src/types/theme.ts
Normal file
260
packages/napcat-webui-backend/src/types/theme.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
|
||||
export const themeType = Type.Object(
|
||||
{
|
||||
dark: Type.Record(Type.String(), Type.String()),
|
||||
light: Type.Record(Type.String(), Type.String()),
|
||||
},
|
||||
{
|
||||
default: {
|
||||
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',
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
105
packages/napcat-webui-backend/src/uploader/disk.ts
Normal file
105
packages/napcat-webui-backend/src/uploader/disk.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import multer from 'multer';
|
||||
import { Request, Response } 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) => {
|
||||
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;
|
||||
52
packages/napcat-webui-backend/src/uploader/webui_font.ts
Normal file
52
packages/napcat-webui-backend/src/uploader/webui_font.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import type { Request, Response } from 'express';
|
||||
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
||||
|
||||
export const webUIFontStorage = multer.diskStorage({
|
||||
destination: (_, __, cb) => {
|
||||
try {
|
||||
const fontsPath = path.dirname(WebUiConfig.GetWebUIFontPath());
|
||||
// 确保字体目录存在
|
||||
fs.mkdirSync(fontsPath, { recursive: true });
|
||||
cb(null, fontsPath);
|
||||
} catch (error) {
|
||||
// 确保错误信息被正确传递
|
||||
cb(new Error(`创建字体目录失败:${(error as Error).message}`), '');
|
||||
}
|
||||
},
|
||||
filename: (_, __, cb) => {
|
||||
// 统一保存为webui.woff
|
||||
cb(null, 'webui.woff');
|
||||
},
|
||||
});
|
||||
|
||||
export const webUIFontUpload = multer({
|
||||
storage: webUIFontStorage,
|
||||
fileFilter: (_, file, cb) => {
|
||||
// 再次验证文件类型
|
||||
if (!file.originalname.toLowerCase().endsWith('.woff')) {
|
||||
cb(new Error('只支持WOFF格式的字体文件'));
|
||||
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) {
|
||||
// 错误处理
|
||||
// sendError(res, error.message, true);
|
||||
return reject(error);
|
||||
}
|
||||
return resolve(true);
|
||||
});
|
||||
});
|
||||
};
|
||||
export default webUIFontUploader;
|
||||
1
packages/napcat-webui-backend/src/utils/check.ts
Normal file
1
packages/napcat-webui-backend/src/utils/check.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const isEmpty = <T>(data: T) => data === undefined || data === null || data === '';
|
||||
22
packages/napcat-webui-backend/src/utils/object.ts
Normal file
22
packages/napcat-webui-backend/src/utils/object.ts
Normal file
@@ -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;
|
||||
}
|
||||
47
packages/napcat-webui-backend/src/utils/response.ts
Normal file
47
packages/napcat-webui-backend/src/utils/response.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { ResponseCode, HttpStatusCode } from '@/napcat-webui-backend/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);
|
||||
};
|
||||
107
packages/napcat-webui-backend/src/utils/url.ts
Normal file
107
packages/napcat-webui-backend/src/utils/url.ts
Normal file
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user