mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-04 06:31:13 +00:00
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:
parent
d33a872c42
commit
ebe3e9c63c
151
packages/napcat-webui-backend/src/api/BackupConfig.ts
Normal file
151
packages/napcat-webui-backend/src/api/BackupConfig.ts
Normal 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}`);
|
||||
}
|
||||
};
|
||||
@ -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 };
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user