mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-04 06:31:13 +00:00
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:
parent
d2e44acba1
commit
02dfac23b9
@ -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": {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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 };
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user