From 02dfac23b944462c6949b453b04125ffc7373f62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=8B=E7=93=9C=E4=B8=80=E5=8D=81=E9=9B=AA?= Date: Sun, 1 Feb 2026 10:12:29 +0800 Subject: [PATCH] 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. --- packages/napcat-webui-backend/package.json | 2 - .../src/api/BackupConfig.ts | 169 ++++++++---------- .../napcat-webui-backend/src/helper/config.ts | 2 - .../src/router/OB11Config.ts | 35 +--- 4 files changed, 77 insertions(+), 131 deletions(-) diff --git a/packages/napcat-webui-backend/package.json b/packages/napcat-webui-backend/package.json index b5d731f0..b3b140b7 100644 --- a/packages/napcat-webui-backend/package.json +++ b/packages/napcat-webui-backend/package.json @@ -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": { diff --git a/packages/napcat-webui-backend/src/api/BackupConfig.ts b/packages/napcat-webui-backend/src/api/BackupConfig.ts index 13b4e9e6..075adb18 100644 --- a/packages/napcat-webui-backend/src/api/BackupConfig.ts +++ b/packages/napcat-webui-backend/src/api/BackupConfig.ts @@ -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> { + return new Promise((resolve, reject) => { + const files = new Map(); + 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(); 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) { diff --git a/packages/napcat-webui-backend/src/helper/config.ts b/packages/napcat-webui-backend/src/helper/config.ts index 01e36ed7..42f486a5 100644 --- a/packages/napcat-webui-backend/src/helper/config.ts +++ b/packages/napcat-webui-backend/src/helper/config.ts @@ -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; diff --git a/packages/napcat-webui-backend/src/router/OB11Config.ts b/packages/napcat-webui-backend/src/router/OB11Config.ts index ed5d5c59..d4dab3f9 100644 --- a/packages/napcat-webui-backend/src/router/OB11Config.ts +++ b/packages/napcat-webui-backend/src/router/OB11Config.ts @@ -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 }; +