Use memory-based zip import/export and multer

Replace disk-based zip handling with in-memory streaming to avoid temp files: remove unzipper/@types(unzipper) deps from package.json; update BackupConfig to stream-export configs with compressing.zip.Stream and to import by extracting uploaded zip buffer via compressing.zip.UncompressStream into in-memory Buffers. Backup of existing config is kept in-memory instead of copying to tmp, and imported files are written with path normalization checks. Router changed to use multer.memoryStorage() for uploads (remove dynamic tmp/disk upload logic and uploadSizeLimit usage). Also remove uploadSizeLimit from config schema.
This commit is contained in:
手瓜一十雪 2026-02-01 10:12:29 +08:00
parent d2e44acba1
commit 02dfac23b9
4 changed files with 77 additions and 131 deletions

View File

@ -27,7 +27,6 @@
"multer": "^2.0.1",
"napcat-common": "workspace:*",
"napcat-pty": "workspace:*",
"unzipper": "^0.10.14",
"ws": "^8.18.3"
},
"devDependencies": {
@ -35,7 +34,6 @@
"@types/express": "^5.0.0",
"@types/multer": "^1.4.12",
"@types/node": "^22.0.1",
"@types/unzipper": "^0.10.9",
"@types/ws": "^8.5.12"
},
"engines": {

View File

@ -1,14 +1,13 @@
import { RequestHandler } from 'express';
import { existsSync, createReadStream, mkdirSync, rmSync, cpSync, readdirSync } from 'node:fs';
import { existsSync, readdirSync, writeFileSync, readFileSync } from 'node:fs';
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';
import compressing from 'compressing';
import unzipper from 'unzipper';
import { Readable } from 'node:stream';
// 使用compressing库进行压缩
// 使用compressing库进行流式压缩导出
export const BackupExportConfigHandler: RequestHandler = async (_req, res) => {
const isLogin = WebUiDataRuntime.getQQLoginStatus();
if (!isLogin) {
@ -31,14 +30,16 @@ export const BackupExportConfigHandler: RequestHandler = async (_req, res) => {
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${zipFileName}"`);
// 使用compressing的Stream API[1](@ref)
// 使用compressing的Stream API进行流式压缩
const stream = new compressing.zip.Stream();
// 添加目录下的所有内容到压缩流
// 添加目录下的所有文件到压缩流(单层平坦结构)
const entries = readdirSync(configPath, { withFileTypes: true });
for (const entry of entries) {
const entryPath = join(configPath, entry.name);
stream.addEntry(entryPath, { relativePath: entry.name });
if (entry.isFile()) {
const entryPath = join(configPath, entry.name);
stream.addEntry(entryPath, { relativePath: entry.name });
}
}
// 管道传输到响应
@ -61,113 +62,85 @@ export const BackupExportConfigHandler: RequestHandler = async (_req, res) => {
}
};
// 导入配置将上传的zip文件解压到工作目录下的tmp目录然后覆盖到config文件夹
// 从内存Buffer流式解压返回文件名和内容的映射
async function extractZipToMemory (buffer: Buffer): Promise<Map<string, Buffer>> {
return new Promise((resolve, reject) => {
const files = new Map<string, Buffer>();
const bufferStream = Readable.from(buffer);
const uncompressStream = new compressing.zip.UncompressStream();
uncompressStream.on('entry', (header, stream, next) => {
// 只处理文件,忽略目录
if (header.type === 'file') {
const chunks: Buffer[] = [];
stream.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
stream.on('end', () => {
// 取文件名(忽略路径中的目录部分)
const fileName = header.name.split('/').pop() || header.name;
files.set(fileName, Buffer.concat(chunks));
next();
});
stream.on('error', (err) => {
console.error(`读取文件失败: ${header.name}`, err);
next();
});
} else {
stream.resume();
next();
}
});
uncompressStream.on('finish', () => resolve(files));
uncompressStream.on('error', reject);
bufferStream.pipe(uncompressStream);
});
}
// 导入配置 - 流式处理,完全在内存中解压
export const BackupImportConfigHandler: RequestHandler = async (req, res) => {
// 检查是否有文件上传
if (!req.file) {
// 检查是否有文件上传multer memoryStorage 模式下文件在 req.file.buffer
if (!req.file || !req.file.buffer) {
return sendError(res, '请选择要导入的配置文件');
}
try {
const configPath = webUiPathWrapper.configPath;
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;
// 确保临时目录和备份目录存在
mkdirSync(tmpPath, { recursive: true });
mkdirSync(backupRootPath, { recursive: true });
mkdirSync(extractPath, { recursive: true });
// 从内存中解压zip
const extractedFiles = await extractZipToMemory(req.file.buffer);
// 解压上传的zip文件
await createReadStream(uploadedFilePath)
.pipe(unzipper.Extract({ path: extractPath }))
.promise();
// 检查解压后的文件
let extractedFiles = readdirSync(extractPath);
if (extractedFiles.length === 0) {
rmSync(extractPath, { recursive: true, force: true });
rmSync(uploadedFilePath, { force: true });
if (extractedFiles.size === 0) {
return sendError(res, '配置文件为空或格式不正确');
}
// 检查是否有嵌套的config目录
if (extractedFiles.length === 1) {
const nestedDirName = extractedFiles[0];
if (nestedDirName) {
const nestedPath = join(extractPath, nestedDirName);
if (existsSync(nestedPath) && !existsSync(join(nestedPath, '.git'))) {
const nestedFiles = readdirSync(nestedPath);
if (nestedFiles.length > 0) {
// 如果只有一个目录,且目录不为空且不是.git目录使用该目录作为解压路径
extractPath = nestedPath;
extractedFiles = nestedFiles;
}
}
}
}
// 备份当前配置到专门的backup文件夹
const formatDate = (date: Date) => {
return date.toISOString().replace(/[:.]/g, '-');
};
const backupPath = join(backupRootPath, `config_${formatDate(new Date())}`);
// 备份当前配置到内存
const backupFiles = new Map<string, Buffer>();
if (existsSync(configPath)) {
mkdirSync(backupPath, { recursive: true });
// 递归复制所有文件和文件夹
const copyRecursive = (src: string, dest: string) => {
const entries = readdirSync(src, { withFileTypes: true });
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);
} else {
cpSync(srcPath, destPath);
}
}
};
copyRecursive(configPath, backupPath);
}
// 覆盖配置文件和文件夹
const copyRecursive = (src: string, dest: string) => {
const entries = readdirSync(src, { withFileTypes: true });
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);
} else {
cpSync(srcPath, destPath);
const currentFiles = readdirSync(configPath, { withFileTypes: true });
for (const entry of currentFiles) {
if (entry.isFile()) {
const filePath = join(configPath, entry.name);
backupFiles.set(entry.name, readFileSync(filePath));
}
}
};
copyRecursive(extractPath, configPath);
}
// 写入新的配置文件
for (const [fileName, content] of extractedFiles) {
// 防止路径穿越攻击
const destPath = join(configPath, fileName);
const normalizedPath = normalize(destPath);
if (!normalizedPath.startsWith(normalize(configPath))) {
continue;
}
writeFileSync(destPath, content);
}
return sendSuccess(res, {
message: '配置导入成功,重启后生效~',
backupPath: backupPath
filesImported: extractedFiles.size,
filesBackedUp: backupFiles.size
});
} catch (error) {

View File

@ -32,8 +32,6 @@ 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,36 +1,15 @@
import { Router } from 'express';
import multer from 'multer';
import { join } from 'node:path';
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配置
const getUpload = () => {
// 使用系统临时目录作为基础路径方便多个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 // 转换为字节
}
});
};
// 使用内存存储,配合流式处理
const upload = multer({
storage: multer.memoryStorage()
});
// router:读取配置
router.post('/GetConfig', OB11GetConfigHandler);
@ -39,9 +18,7 @@ router.post('/SetConfig', OB11SetConfigHandler);
// router:导出配置
router.get('/ExportConfig', BackupExportConfigHandler);
// router:导入配置
router.post('/ImportConfig', (req, res, next) => {
const upload = getUpload();
upload.single('configFile')(req, res, next);
}, BackupImportConfigHandler);
router.post('/ImportConfig', upload.single('configFile'), BackupImportConfigHandler);
export { router as OB11ConfigRouter };