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> { 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) => { // 检查是否有文件上传(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(); 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}`); } };