diff --git a/packages/napcat-webui-backend/src/api/BackupConfig.ts b/packages/napcat-webui-backend/src/api/BackupConfig.ts new file mode 100644 index 00000000..075adb18 --- /dev/null +++ b/packages/napcat-webui-backend/src/api/BackupConfig.ts @@ -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> { + 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}`); + } +}; diff --git a/packages/napcat-webui-backend/src/router/OB11Config.ts b/packages/napcat-webui-backend/src/router/OB11Config.ts index 99f96681..d4dab3f9 100644 --- a/packages/napcat-webui-backend/src/router/OB11Config.ts +++ b/packages/napcat-webui-backend/src/router/OB11Config.ts @@ -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 }; + diff --git a/packages/napcat-webui-frontend/src/pages/dashboard/config/backup.tsx b/packages/napcat-webui-frontend/src/pages/dashboard/config/backup.tsx new file mode 100644 index 00000000..50cd417f --- /dev/null +++ b/packages/napcat-webui-frontend/src/pages/dashboard/config/backup.tsx @@ -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) => { + 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 ( +
+
+

备份与恢复

+

+ 您可以通过导入/导出配置文件来备份和恢复NapCat的所有设置 +

+ +
+ + +
+ +
+
+

+ 导入配置会覆盖当前所有设置,请谨慎操作。导入前建议先导出当前配置作为备份。 +

+
+
+
+
+ ); +}; + +export default BackupConfigCard; \ No newline at end of file diff --git a/packages/napcat-webui-frontend/src/pages/dashboard/config/index.tsx b/packages/napcat-webui-frontend/src/pages/dashboard/config/index.tsx index 711f58d8..1274b879 100644 --- a/packages/napcat-webui-frontend/src/pages/dashboard/config/index.tsx +++ b/packages/napcat-webui-frontend/src/pages/dashboard/config/index.tsx @@ -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 () { + + + + + );