feat(webui): 新增配置全量备份与恢复功能。 (#1571)

* 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>
This commit is contained in:
香草味的纳西妲喵 2026-02-01 10:21:19 +08:00 committed by GitHub
parent d33a872c42
commit ebe3e9c63c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 299 additions and 0 deletions

View File

@ -0,0 +1,151 @@
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}`);
}
};

View File

@ -1,11 +1,24 @@
import { Router } from 'express';
import multer from 'multer';
import { OB11GetConfigHandler, OB11SetConfigHandler } from '@/napcat-webui-backend/src/api/OB11Config';
import { BackupExportConfigHandler, BackupImportConfigHandler } from '@/napcat-webui-backend/src/api/BackupConfig';
const router: Router = Router();
// 使用内存存储,配合流式处理
const upload = multer({
storage: multer.memoryStorage()
});
// router:读取配置
router.post('/GetConfig', OB11GetConfigHandler);
// router:写入配置
router.post('/SetConfig', OB11SetConfigHandler);
// router:导出配置
router.get('/ExportConfig', BackupExportConfigHandler);
// router:导入配置
router.post('/ImportConfig', upload.single('configFile'), BackupImportConfigHandler);
export { router as OB11ConfigRouter };

View File

@ -0,0 +1,129 @@
import { Button } from '@heroui/button';
import toast from 'react-hot-toast';
import { LuDownload, LuUpload } from 'react-icons/lu';
import { requestServerWithFetch } from '@/utils/request';
// 导入配置
const handleImportConfig = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// 检查文件类型
if (!file.name.endsWith('.zip')) {
toast.error('请选择zip格式的配置文件');
return;
}
try {
const formData = new FormData();
formData.append('configFile', file);
const response = await requestServerWithFetch('/OB11Config/ImportConfig', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || '导入配置失败');
}
const result = await response.json();
// 检查是否成功导入
if (result.code === 0) {
toast.success(result.data?.message || '配置导入成功。');
} else {
toast.error(`配置导入失败: ${result.data?.message || '未知错误'}`);
}
} catch (error) {
const msg = (error as Error).message;
toast.error(`导入配置失败: ${msg}`);
} finally {
// 重置文件输入
event.target.value = '';
}
};
// 导出配置
const handleExportConfig = async () => {
try {
const response = await requestServerWithFetch('/OB11Config/ExportConfig', {
method: 'GET',
});
if (!response.ok) {
throw new Error('导出配置失败');
}
// 创建下载链接
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const fileName = response.headers.get('Content-Disposition')?.split('=')[1]?.replace(/"/g, '') || 'config_backup.zip';
a.download = fileName;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('配置导出成功');
} catch (error) {
const msg = (error as Error).message;
toast.error(`导出配置失败: ${msg}`);
}
};
const BackupConfigCard: React.FC = () => {
return (
<div className='space-y-6'>
<div>
<h3 className='text-lg font-medium mb-4'></h3>
<p className='text-sm text-default-500 mb-4'>
/NapCat的所有设置
</p>
<div className='flex flex-wrap gap-3'>
<Button
isIconOnly
className="bg-primary hover:bg-primary/90 text-white"
radius='full'
onPress={handleExportConfig}
title="导出配置"
>
<LuDownload size={20} />
</Button>
<label className="cursor-pointer">
<input
type="file"
accept=".zip"
onChange={handleImportConfig}
className="hidden"
/>
<Button
isIconOnly
className="bg-primary hover:bg-primary/90 text-white"
radius='full'
as="span"
title="导入配置"
>
<LuUpload size={20} />
</Button>
</label>
</div>
<div className='mt-4 p-3 bg-warning/10 border border-warning/20 rounded-lg'>
<div className='flex items-start gap-2'>
<p className='text-sm text-warning'>
</p>
</div>
</div>
</div>
</div>
);
};
export default BackupConfigCard;

View File

@ -13,6 +13,7 @@ import ServerConfigCard from './server';
import SSLConfigCard from './ssl';
import ThemeConfigCard from './theme';
import WebUIConfigCard from './webui';
import BackupConfigCard from './backup';
export interface ConfigPageProps {
children?: React.ReactNode;
@ -108,6 +109,11 @@ export default function ConfigPage () {
<ThemeConfigCard />
</ConfigPageItem>
</Tab>
<Tab title='备份与恢复' key='backup'>
<ConfigPageItem>
<BackupConfigCard />
</ConfigPageItem>
</Tab>
</Tabs>
</section>
);