mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-05 07:01:16 +00:00
* feat(webui): 新增配置全量备份与恢复功能。
* chore: Remove dependencies "archiver"
* feat(webui): 增加上传文件大小限制配置并优化上传处理
* 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.
* Revert "chore: Remove dependencies "archiver""
This reverts commit 890736d3c7.
* Regenerate pnpm-lock.yaml (prune entries)
Regenerated pnpm-lock.yaml to reflect the current dependency resolution. This update prunes many removed/unused lock entries (notably archiver, unzipper and related @types, older/deprecated packages such as rimraf v2/fstream/bluebird, etc.) and removes platform 'libc' metadata from several platform-specific packages. There are no package.json changes; run `pnpm install` to sync your local node_modules with the updated lockfile.
---------
Co-authored-by: 手瓜一十雪 <nanaeonn@outlook.com>
152 lines
4.9 KiB
TypeScript
152 lines
4.9 KiB
TypeScript
import { RequestHandler } from 'express';
|
||
import { existsSync, readdirSync, writeFileSync, readFileSync } from 'node:fs';
|
||
import { join, normalize } from 'node:path';
|
||
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 { Readable } from 'node:stream';
|
||
|
||
// 使用compressing库进行流式压缩导出
|
||
export const BackupExportConfigHandler: RequestHandler = async (_req, res) => {
|
||
const isLogin = WebUiDataRuntime.getQQLoginStatus();
|
||
if (!isLogin) {
|
||
return sendError(res, 'Not Login');
|
||
}
|
||
|
||
try {
|
||
const configPath = webUiPathWrapper.configPath;
|
||
|
||
if (!existsSync(configPath)) {
|
||
return sendError(res, '配置目录不存在');
|
||
}
|
||
|
||
const formatDate = (date: Date) => {
|
||
return date.toISOString().replace(/[:.]/g, '-');
|
||
};
|
||
const zipFileName = `config_backup_${formatDate(new Date())}.zip`;
|
||
|
||
// 设置响应头
|
||
res.setHeader('Content-Type', 'application/zip');
|
||
res.setHeader('Content-Disposition', `attachment; filename="${zipFileName}"`);
|
||
|
||
// 使用compressing的Stream API进行流式压缩
|
||
const stream = new compressing.zip.Stream();
|
||
|
||
// 添加目录下的所有文件到压缩流(单层平坦结构)
|
||
const entries = readdirSync(configPath, { withFileTypes: true });
|
||
for (const entry of entries) {
|
||
if (entry.isFile()) {
|
||
const entryPath = join(configPath, entry.name);
|
||
stream.addEntry(entryPath, { relativePath: entry.name });
|
||
}
|
||
}
|
||
|
||
// 管道传输到响应
|
||
stream.pipe(res);
|
||
|
||
// 处理流错误
|
||
stream.on('error', (err) => {
|
||
console.error('压缩流错误:', err);
|
||
if (!res.headersSent) {
|
||
sendError(res, '流式压缩失败');
|
||
}
|
||
});
|
||
|
||
} catch (error) {
|
||
const msg = (error as Error).message;
|
||
console.error('导出配置失败:', error);
|
||
if (!res.headersSent) {
|
||
return sendError(res, `导出配置失败: ${msg}`);
|
||
}
|
||
}
|
||
};
|
||
|
||
// 从内存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) => {
|
||
// 检查是否有文件上传(multer memoryStorage 模式下文件在 req.file.buffer)
|
||
if (!req.file || !req.file.buffer) {
|
||
return sendError(res, '请选择要导入的配置文件');
|
||
}
|
||
|
||
try {
|
||
const configPath = webUiPathWrapper.configPath;
|
||
|
||
// 从内存中解压zip
|
||
const extractedFiles = await extractZipToMemory(req.file.buffer);
|
||
|
||
if (extractedFiles.size === 0) {
|
||
return sendError(res, '配置文件为空或格式不正确');
|
||
}
|
||
|
||
// 备份当前配置到内存
|
||
const backupFiles = new Map<string, Buffer>();
|
||
if (existsSync(configPath)) {
|
||
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));
|
||
}
|
||
}
|
||
}
|
||
|
||
// 写入新的配置文件
|
||
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: '配置导入成功,重启后生效~',
|
||
filesImported: extractedFiles.size,
|
||
filesBackedUp: backupFiles.size
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('导入配置失败:', error);
|
||
const msg = (error as Error).message;
|
||
return sendError(res, `导入配置失败: ${msg}`);
|
||
}
|
||
};
|