feat(webui): 增加上传文件大小限制配置并优化上传处理

This commit is contained in:
VanillaNahida 2026-01-31 19:56:01 +08:00
parent 890736d3c7
commit d2e44acba1
4 changed files with 55 additions and 40 deletions

View File

@ -1,6 +1,7 @@
import { RequestHandler } from 'express';
import { existsSync, createReadStream, mkdirSync, rmSync, cpSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { join, normalize } from 'node:path';
import os from 'node:os';
import { webUiPathWrapper } from '@/napcat-webui-backend/index';
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
@ -60,16 +61,8 @@ export const BackupExportConfigHandler: RequestHandler = async (_req, res) => {
}
};
// 导入配置
// 将上传的zip文件解压到工作目录下的tmp目录然后覆盖到config文件夹
// 导入配置将上传的zip文件解压到工作目录下的tmp目录然后覆盖到config文件夹
export const BackupImportConfigHandler: RequestHandler = async (req, res) => {
// 获取QQ登录状态
const isLogin = WebUiDataRuntime.getQQLoginStatus();
// 如果未登录,返回错误
if (!isLogin) {
return sendError(res, 'Not Login');
}
// 检查是否有文件上传
if (!req.file) {
return sendError(res, '请选择要导入的配置文件');
@ -77,8 +70,8 @@ export const BackupImportConfigHandler: RequestHandler = async (req, res) => {
try {
const configPath = webUiPathWrapper.configPath;
const tmpPath = join(webUiPathWrapper.cachePath, './tmp');
const backupRootPath = join(webUiPathWrapper.cachePath, './backup');
const tmpPath = join(os.tmpdir(), 'napcat-upload', 'tmp');
const backupRootPath = join(os.tmpdir(), 'napcat-upload', 'backup');
let extractPath = join(tmpPath, 'imported_config');
const uploadedFilePath = req.file.path;
@ -129,6 +122,14 @@ export const BackupImportConfigHandler: RequestHandler = async (req, res) => {
for (const entry of entries) {
const srcPath = join(src, entry.name);
const destPath = join(dest, entry.name);
// 防止路径穿越攻击
const normalizedDestPath = normalize(destPath);
const normalizedDestDir = normalize(dest);
if (!normalizedDestPath.startsWith(normalizedDestDir)) {
continue;
}
if (entry.isDirectory()) {
mkdirSync(destPath, { recursive: true });
copyRecursive(srcPath, destPath);
@ -146,6 +147,14 @@ export const BackupImportConfigHandler: RequestHandler = async (req, res) => {
for (const entry of entries) {
const srcPath = join(src, entry.name);
const destPath = join(dest, entry.name);
// 防止路径穿越攻击
const normalizedDestPath = normalize(destPath);
const normalizedDestDir = normalize(dest);
if (!normalizedDestPath.startsWith(normalizedDestDir)) {
continue;
}
if (entry.isDirectory()) {
mkdirSync(destPath, { recursive: true });
copyRecursive(srcPath, destPath);
@ -156,16 +165,13 @@ export const BackupImportConfigHandler: RequestHandler = async (req, res) => {
};
copyRecursive(extractPath, configPath);
// 清理临时文件
rmSync(join(tmpPath, 'imported_config'), { recursive: true, force: true });
rmSync(uploadedFilePath, { force: true });
return sendSuccess(res, {
message: '配置导入成功,重启后生效~',
backupPath: backupPath
});
} catch (error) {
console.error('导入配置失败:', error);
const msg = (error as Error).message;
return sendError(res, `导入配置失败: ${msg}`);
}

View File

@ -32,6 +32,8 @@ const WebUiConfigSchema = Type.Object({
ipBlacklist: Type.Array(Type.String(), { default: [] }),
// 是否启用 X-Forwarded-For 获取真实IP
enableXForwardedFor: Type.Boolean({ default: false }),
// 上传文件大小限制MB
uploadSizeLimit: Type.Number({ default: 50 }),
});
export type WebUiConfigType = Static<typeof WebUiConfigSchema>;

View File

@ -1,17 +1,35 @@
import { Router } from 'express';
import multer from 'multer';
import { join } from 'node:path';
import { webUiPathWrapper } from '@/napcat-webui-backend/index';
import os from 'node:os';
import { WebUiConfig } from '@/napcat-webui-backend/index';
import { OB11GetConfigHandler, OB11SetConfigHandler } from '@/napcat-webui-backend/src/api/OB11Config';
import { BackupExportConfigHandler, BackupImportConfigHandler } from '@/napcat-webui-backend/src/api/BackupConfig';
const router: Router = Router();
// 延迟初始化multer配置避免在webUiPathWrapper初始化前访问
// 延迟初始化multer配置
const getUpload = () => {
const tmpPath = join(webUiPathWrapper.cachePath, './tmp');
return multer({ dest: tmpPath });
// 使用系统临时目录作为基础路径方便多个napcat用户统一读取使用
const tmpPath = join(os.tmpdir(), 'napcat-upload');
// 获取上传大小限制默认50MB最大200MB
let uploadSizeLimit = 50;
try {
// 使用同步方式获取配置
uploadSizeLimit = WebUiConfig?.WebUiConfigData?.uploadSizeLimit || 50;
} catch (error) {
// 如果获取失败,使用默认值
console.warn('获取上传大小限制失败:', error);
}
// 确保不超过最大限制
uploadSizeLimit = Math.min(uploadSizeLimit, 200);
return multer({
dest: tmpPath,
limits: {
fileSize: uploadSizeLimit * 1024 * 1024 // 转换为字节
}
});
};
// router:读取配置

View File

@ -1,8 +1,7 @@
import { Button } from '@heroui/button';
import toast from 'react-hot-toast';
import { LuDownload, LuUpload } from 'react-icons/lu';
import key from '@/const/key';
import { requestServerWithFetch } from '@/utils/request';
// 导入配置
const handleImportConfig = async (event: React.ChangeEvent<HTMLInputElement>) => {
@ -19,15 +18,8 @@ const handleImportConfig = async (event: React.ChangeEvent<HTMLInputElement>) =>
const formData = new FormData();
formData.append('configFile', file);
const token = localStorage.getItem(key.token);
const headers: HeadersInit = {};
if (token) {
headers['Authorization'] = `Bearer ${JSON.parse(token)}`;
}
const response = await fetch('/api/OB11Config/ImportConfig', {
const response = await requestServerWithFetch('/OB11Config/ImportConfig', {
method: 'POST',
headers,
body: formData,
});
@ -37,7 +29,12 @@ const handleImportConfig = async (event: React.ChangeEvent<HTMLInputElement>) =>
}
const result = await response.json();
toast.success(result.data?.message || '配置导入成功。');
// 检查是否成功导入
if (result.code === 0) {
toast.success(result.data?.message || '配置导入成功。');
} else {
toast.error(`配置导入失败: ${result.data?.message || '未知错误'}`);
}
} catch (error) {
const msg = (error as Error).message;
@ -51,16 +48,8 @@ const handleImportConfig = async (event: React.ChangeEvent<HTMLInputElement>) =>
// 导出配置
const handleExportConfig = async () => {
try {
const token = localStorage.getItem(key.token);
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${JSON.parse(token)}`;
}
const response = await fetch('/api/OB11Config/ExportConfig', {
const response = await requestServerWithFetch('/OB11Config/ExportConfig', {
method: 'GET',
headers,
});
if (!response.ok) {