mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-13 00:10:27 +00:00
Compare commits
13 Commits
feat/secur
...
v4.14.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c65b1eaf1 | ||
|
|
ebe3e9c63c | ||
|
|
d33a872c42 | ||
|
|
9377dc3d52 | ||
|
|
17322bb5a4 | ||
|
|
c0bcced5fb | ||
|
|
805c1d5ea2 | ||
|
|
b3399b07ad | ||
|
|
71f8504849 | ||
|
|
3b7ca1a08f | ||
|
|
57f3c4dd31 | ||
|
|
5b20ebb7b0 | ||
|
|
3a3eaeec7c |
8
packages/napcat-core/external/appid.json
vendored
8
packages/napcat-core/external/appid.json
vendored
@@ -518,5 +518,13 @@
|
||||
"9.9.26-44725": {
|
||||
"appid": 537337569,
|
||||
"qua": "V1_WIN_NQ_9.9.26_44725_GW_B"
|
||||
},
|
||||
"9.9.27-45627": {
|
||||
"appid": 537340060,
|
||||
"qua": "V1_WIN_NQ_9.9.26_45627_GW_B"
|
||||
},
|
||||
"6.9.88-44725": {
|
||||
"appid": 537337594,
|
||||
"qua": "V1_MAC_NQ_6.9.88_44725_GW_B"
|
||||
}
|
||||
}
|
||||
12
packages/napcat-core/external/napi2native.json
vendored
12
packages/napcat-core/external/napi2native.json
vendored
@@ -154,5 +154,17 @@
|
||||
"9.9.26-44725-x64": {
|
||||
"send": "0A18D0C",
|
||||
"recv": "1D4BF0D"
|
||||
},
|
||||
"9.9.27-45627-x64": {
|
||||
"send": "0A697CC",
|
||||
"recv": "1E86AC1"
|
||||
},
|
||||
"6.9.88-44725-x64": {
|
||||
"send": "2756EF6",
|
||||
"recv": "0A36152"
|
||||
},
|
||||
"6.9.88-44725-arm64": {
|
||||
"send": "2313C68",
|
||||
"recv": "09693E4"
|
||||
}
|
||||
}
|
||||
12
packages/napcat-core/external/packet.json
vendored
12
packages/napcat-core/external/packet.json
vendored
@@ -662,5 +662,17 @@
|
||||
"9.9.26-44725-x64": {
|
||||
"send": "2CEBB20",
|
||||
"recv": "2CEF0A0"
|
||||
},
|
||||
"9.9.27-45627-x64": {
|
||||
"send": "2E59CC0",
|
||||
"recv": "2E5D240"
|
||||
},
|
||||
"6.9.88-44725-x64": {
|
||||
"send": "451FE90",
|
||||
"recv": "4522A40"
|
||||
},
|
||||
"6.9.88-44725-arm64": {
|
||||
"send": "3D79168",
|
||||
"recv": "3D7BA78"
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,14 @@ import * as crypto from 'node:crypto';
|
||||
import { PacketMsg } from '@/napcat-core/packet/message/message';
|
||||
|
||||
interface ForwardMsgJson {
|
||||
app: string
|
||||
app: string;
|
||||
config: ForwardMsgJsonConfig,
|
||||
desc: string,
|
||||
extra: ForwardMsgJsonExtra,
|
||||
meta: ForwardMsgJsonMeta,
|
||||
prompt: string,
|
||||
ver: string,
|
||||
view: string
|
||||
view: string;
|
||||
}
|
||||
|
||||
interface ForwardMsgJsonConfig {
|
||||
@@ -17,7 +17,7 @@ interface ForwardMsgJsonConfig {
|
||||
forward: number,
|
||||
round: number,
|
||||
type: string,
|
||||
width: number
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface ForwardMsgJsonExtra {
|
||||
@@ -26,17 +26,17 @@ interface ForwardMsgJsonExtra {
|
||||
}
|
||||
|
||||
interface ForwardMsgJsonMeta {
|
||||
detail: ForwardMsgJsonMetaDetail
|
||||
detail: ForwardMsgJsonMetaDetail;
|
||||
}
|
||||
|
||||
interface ForwardMsgJsonMetaDetail {
|
||||
news: {
|
||||
text: string
|
||||
text: string;
|
||||
}[],
|
||||
resid: string,
|
||||
source: string,
|
||||
summary: string,
|
||||
uniseq: string
|
||||
uniseq: string;
|
||||
}
|
||||
|
||||
interface ForwardAdaptMsg {
|
||||
@@ -50,8 +50,8 @@ interface ForwardAdaptMsgElement {
|
||||
}
|
||||
|
||||
export class ForwardMsgBuilder {
|
||||
private static build (resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string): ForwardMsgJson {
|
||||
const id = crypto.randomUUID();
|
||||
private static build (resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string, uuid?: string): ForwardMsgJson {
|
||||
const id = uuid ?? crypto.randomUUID();
|
||||
const isGroupMsg = msg.some(m => m.isGroupMsg);
|
||||
if (!source) {
|
||||
source = msg.length === 0 ? '聊天记录' : (isGroupMsg ? '群聊的聊天记录' : msg.map(m => m.senderName).filter((v, i, a) => a.indexOf(v) === i).slice(0, 4).join('和') + '的聊天记录');
|
||||
@@ -104,13 +104,19 @@ export class ForwardMsgBuilder {
|
||||
return this.build(resId, []);
|
||||
}
|
||||
|
||||
static fromPacketMsg (resId: string, packetMsg: PacketMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string): ForwardMsgJson {
|
||||
static fromPacketMsg (resId: string, packetMsg: PacketMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string, uuid?: string): ForwardMsgJson {
|
||||
return this.build(resId, packetMsg.map(msg => ({
|
||||
senderName: msg.senderName,
|
||||
isGroupMsg: msg.groupId !== undefined,
|
||||
msg: msg.msg.map(m => ({
|
||||
preview: m.valid ? m.toPreview() : '[该消息类型暂不支持查看]',
|
||||
})),
|
||||
})), source, news, summary, prompt);
|
||||
})),
|
||||
source,
|
||||
news,
|
||||
summary,
|
||||
prompt,
|
||||
uuid,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { OidbPacket } from '@/napcat-core/packet/transformer/base';
|
||||
import { ImageOcrResult } from '@/napcat-core/packet/entities/ocrResult';
|
||||
import { gunzipSync } from 'zlib';
|
||||
import { PacketMsgConverter } from '@/napcat-core/packet/message/converter';
|
||||
import { UploadForwardMsgParams } from '@/napcat-core/packet/transformer/message/UploadForwardMsgV2';
|
||||
|
||||
export class PacketOperationContext {
|
||||
private readonly context: PacketContext;
|
||||
@@ -26,7 +27,7 @@ export class PacketOperationContext {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
async sendPacket<T extends boolean = false>(pkt: OidbPacket, rsp?: T): Promise<T extends true ? Buffer : void> {
|
||||
async sendPacket<T extends boolean = false> (pkt: OidbPacket, rsp?: T): Promise<T extends true ? Buffer : void> {
|
||||
return await this.context.client.sendOidbPacket(pkt, rsp);
|
||||
}
|
||||
|
||||
@@ -94,12 +95,15 @@ export class PacketOperationContext {
|
||||
.filter(Boolean)
|
||||
);
|
||||
const res = await Promise.allSettled(reqList);
|
||||
this.context.logger.info(`上传资源${res.length}个,失败${res.filter((r) => r.status === 'rejected').length}个`);
|
||||
res.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
this.context.logger.error(`上传第${index + 1}个资源失败:${result.reason.stack}`);
|
||||
}
|
||||
});
|
||||
const failedCount = res.filter((r) => r.status === 'rejected').length;
|
||||
if (failedCount > 0) {
|
||||
this.context.logger.warn(`上传资源${res.length}个,失败${failedCount}个`);
|
||||
res.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
this.context.logger.error(`上传第${index + 1}个资源失败:${result.reason.stack}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async UploadImage (img: PacketMsgPicElement) {
|
||||
@@ -224,7 +228,15 @@ export class PacketOperationContext {
|
||||
const res = trans.UploadForwardMsg.parse(resp);
|
||||
return res.result.resId;
|
||||
}
|
||||
|
||||
async UploadForwardMsgV2 (msg: UploadForwardMsgParams[], groupUin: number = 0) {
|
||||
//await this.SendPreprocess(msg, groupUin);
|
||||
// 遍历上传资源
|
||||
await Promise.allSettled(msg.map(async (item) => { return await this.SendPreprocess(item.actionMsg, groupUin); }));
|
||||
const req = trans.UploadForwardMsgV2.build(this.context.napcore.basicInfo.uid, msg, groupUin);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true);
|
||||
const res = trans.UploadForwardMsg.parse(resp);
|
||||
return res.result.resId;
|
||||
}
|
||||
async MoveGroupFile (
|
||||
groupUin: number,
|
||||
fileUUID: string,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import zlib from 'node:zlib';
|
||||
import * as proto from '@/napcat-core/packet/transformer/proto';
|
||||
import { NapProtoMsg } from 'napcat-protobuf';
|
||||
import { OidbPacket, PacketBufBuilder, PacketTransformer } from '@/napcat-core/packet/transformer/base';
|
||||
import { PacketMsg } from '@/napcat-core/packet/message/message';
|
||||
|
||||
export interface UploadForwardMsgParams {
|
||||
actionCommand: string;
|
||||
actionMsg: PacketMsg[];
|
||||
}
|
||||
class UploadForwardMsgV2 extends PacketTransformer<typeof proto.SendLongMsgResp> {
|
||||
build (selfUid: string, msg: UploadForwardMsgParams[], groupUin: number = 0): OidbPacket {
|
||||
const reqdata = msg.map((item) => ({
|
||||
actionCommand: item.actionCommand,
|
||||
actionData: {
|
||||
msgBody: this.msgBuilder.buildFakeMsg(selfUid, item.actionMsg),
|
||||
}
|
||||
}));
|
||||
const longMsgResultData = new NapProtoMsg(proto.LongMsgResult).encode(
|
||||
{
|
||||
action: reqdata,
|
||||
}
|
||||
);
|
||||
const payload = zlib.gzipSync(Buffer.from(longMsgResultData));
|
||||
const req = new NapProtoMsg(proto.SendLongMsgReq).encode(
|
||||
{
|
||||
info: {
|
||||
type: groupUin === 0 ? 1 : 3,
|
||||
uid: {
|
||||
uid: groupUin === 0 ? selfUid : groupUin.toString(),
|
||||
},
|
||||
groupUin,
|
||||
payload,
|
||||
},
|
||||
settings: {
|
||||
field1: 4, field2: 1, field3: 7, field4: 0,
|
||||
},
|
||||
}
|
||||
);
|
||||
return {
|
||||
cmd: 'trpc.group.long_msg_interface.MsgService.SsoSendLongMsg',
|
||||
data: PacketBufBuilder(req),
|
||||
};
|
||||
}
|
||||
|
||||
parse (data: Buffer) {
|
||||
return new NapProtoMsg(proto.SendLongMsgResp).decode(data);
|
||||
}
|
||||
}
|
||||
|
||||
export default new UploadForwardMsgV2();
|
||||
@@ -2,3 +2,4 @@ export { default as UploadForwardMsg } from './UploadForwardMsg';
|
||||
export { default as FetchGroupMessage } from './FetchGroupMessage';
|
||||
export { default as FetchC2CMessage } from './FetchC2CMessage';
|
||||
export { default as DownloadForwardMsg } from './DownloadForwardMsg';
|
||||
export { default as UploadForwardMsgV2 } from './UploadForwardMsgV2';
|
||||
@@ -17,6 +17,7 @@ import { rawMsgWithSendMsg } from 'napcat-core/packet/message/converter';
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { MsgActionsExamples } from '@/napcat-onebot/action/msg/examples';
|
||||
import { OB11MessageMixTypeSchema } from '@/napcat-onebot/types/message';
|
||||
import { UploadForwardMsgParams } from '@/napcat-core/packet/transformer/message/UploadForwardMsgV2';
|
||||
|
||||
export const SendMsgPayloadSchema = Type.Object({
|
||||
message_type: Type.Optional(Type.Union([Type.Literal('private'), Type.Literal('group')], { description: '消息类型 (private/group)' })),
|
||||
@@ -211,10 +212,14 @@ export class SendMsgBase extends OneBotAction<SendMsgPayload, ReturnDataType> {
|
||||
}, dp: number = 0): Promise<{
|
||||
finallySendElements: SendArkElement,
|
||||
res_id?: string,
|
||||
uuid?: string,
|
||||
packetMsg: PacketMsg[],
|
||||
deleteAfterSentFiles: string[],
|
||||
innerPacketMsg?: Array<{ uuid: string, packetMsg: PacketMsg[]; }>;
|
||||
} | null> {
|
||||
const packetMsg: PacketMsg[] = [];
|
||||
const delFiles: string[] = [];
|
||||
const innerMsg: Array<{ uuid: string, packetMsg: PacketMsg[]; }> = new Array();
|
||||
for (const node of messageNodes) {
|
||||
if (dp >= 3) {
|
||||
this.core.context.logger.logWarn('转发消息深度超过3层,将停止解析!');
|
||||
@@ -232,6 +237,13 @@ export class SendMsgBase extends OneBotAction<SendMsgPayload, ReturnDataType> {
|
||||
}, dp + 1);
|
||||
sendElements = uploadReturnData?.finallySendElements ? [uploadReturnData.finallySendElements] : [];
|
||||
delFiles.push(...(uploadReturnData?.deleteAfterSentFiles || []));
|
||||
if (uploadReturnData?.uuid) {
|
||||
innerMsg.push({ uuid: uploadReturnData.uuid, packetMsg: uploadReturnData.packetMsg });
|
||||
uploadReturnData.innerPacketMsg?.forEach(m => {
|
||||
innerMsg.push({ uuid: m.uuid, packetMsg: m.packetMsg });
|
||||
});
|
||||
}
|
||||
|
||||
} else {
|
||||
const sendElementsCreateReturn = await this.obContext.apis.MsgApi.createSendElements(OB11Data, msgPeer);
|
||||
sendElements = sendElementsCreateReturn.sendElements;
|
||||
@@ -273,8 +285,19 @@ export class SendMsgBase extends OneBotAction<SendMsgPayload, ReturnDataType> {
|
||||
this.core.context.logger.logWarn('handleForwardedNodesPacket 元素为空!');
|
||||
return null;
|
||||
}
|
||||
const resid = await this.core.apis.PacketApi.pkt.operation.UploadForwardMsg(packetMsg, msgPeer.chatType === ChatType.KCHATTYPEGROUP ? +msgPeer.peerUid : 0);
|
||||
const forwardJson = ForwardMsgBuilder.fromPacketMsg(resid, packetMsg, source, news, summary, prompt);
|
||||
const uploadMsgData: UploadForwardMsgParams[] = [{
|
||||
actionCommand: 'MultiMsg',
|
||||
actionMsg: packetMsg,
|
||||
}];
|
||||
innerMsg.forEach(({ uuid, packetMsg: msg }) => {
|
||||
uploadMsgData.push({
|
||||
actionCommand: uuid,
|
||||
actionMsg: msg,
|
||||
});
|
||||
});
|
||||
const resid = await this.core.apis.PacketApi.pkt.operation.UploadForwardMsgV2(uploadMsgData, msgPeer.chatType === ChatType.KCHATTYPEGROUP ? +msgPeer.peerUid : 0);
|
||||
const uuid = crypto.randomUUID();
|
||||
const forwardJson = ForwardMsgBuilder.fromPacketMsg(resid, packetMsg, source, news, summary, prompt, uuid);
|
||||
return {
|
||||
deleteAfterSentFiles: delFiles,
|
||||
finallySendElements: {
|
||||
@@ -285,6 +308,9 @@ export class SendMsgBase extends OneBotAction<SendMsgPayload, ReturnDataType> {
|
||||
},
|
||||
} as SendArkElement,
|
||||
res_id: resid,
|
||||
uuid: uuid,
|
||||
packetMsg: packetMsg,
|
||||
innerPacketMsg: innerMsg,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -316,6 +316,11 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
|
||||
entry = newEntry;
|
||||
}
|
||||
|
||||
if (!entry.enable) {
|
||||
this.logger.log(`[PluginManager] Skipping loading disabled plugin: ${pluginId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return await this.loadPlugin(entry);
|
||||
}
|
||||
|
||||
|
||||
@@ -123,8 +123,8 @@ export class PluginLoader {
|
||||
const entryFile = this.findEntryFile(pluginDir, packageJson);
|
||||
const entryPath = entryFile ? path.join(pluginDir, entryFile) : undefined;
|
||||
|
||||
// 获取启用状态(默认启用)
|
||||
const enable = statusConfig[pluginId] !== false;
|
||||
// 获取启用状态(默认禁用,内置插件除外)
|
||||
const enable = statusConfig[pluginId] ?? (pluginId === 'napcat-plugin-builtin');
|
||||
|
||||
// 创建插件条目
|
||||
const entry: PluginEntry = {
|
||||
@@ -159,7 +159,7 @@ export class PluginLoader {
|
||||
id: dirname, // 使用目录名作为 ID
|
||||
fileId: dirname,
|
||||
pluginPath: path.join(this.pluginPath, dirname),
|
||||
enable: statusConfig[dirname] !== false,
|
||||
enable: statusConfig[dirname] ?? (dirname === 'napcat-plugin-builtin'),
|
||||
loaded: false,
|
||||
runtime: {
|
||||
status: 'error',
|
||||
|
||||
@@ -285,6 +285,11 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
|
||||
entry = newEntry;
|
||||
}
|
||||
|
||||
if (!entry.enable) {
|
||||
this.logger.log(`[PluginManager] Skipping loading disabled plugin: ${pluginId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return await this.loadPlugin(entry);
|
||||
}
|
||||
|
||||
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
@@ -340,7 +340,7 @@ export async function applyPendingUpdates (webUiPathWrapper: NapCatPathWrapper,
|
||||
const configPath = path.join(webUiPathWrapper.configPath, 'napcat-update.json');
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
logger.log('[NapCat Update] No pending updates found');
|
||||
//logger.log('[NapCat Update] No pending updates found');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
||||
return {
|
||||
text: '更新',
|
||||
icon: <IoMdDownload size={16} />,
|
||||
color: 'success' as const,
|
||||
color: 'default' as const,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
|
||||
@@ -260,14 +260,14 @@ const NewVersionTip = (props: NewVersionTipProps) => {
|
||||
<div className="cursor-pointer flex items-center justify-center" onClick={updateStatus === 'updating' ? undefined : showUpdateDialog}>
|
||||
<Chip
|
||||
size="sm"
|
||||
color="danger"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
classNames={{
|
||||
content: "font-bold text-[10px] px-1 flex items-center justify-center",
|
||||
base: "h-5 min-h-5 min-w-[42px]"
|
||||
}}
|
||||
>
|
||||
{updateStatus === 'updating' ? <Spinner size="sm" color="danger" classNames={{ wrapper: "w-3 h-3" }} /> : 'New'}
|
||||
{updateStatus === 'updating' ? <Spinner size="sm" color="primary" classNames={{ wrapper: "w-3 h-3" }} /> : 'New'}
|
||||
</Chip>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -89,75 +89,74 @@ export default function ExtensionPage () {
|
||||
return (
|
||||
<>
|
||||
<title>扩展页面 - NapCat WebUI</title>
|
||||
<div className='p-2 md:p-4 relative h-full flex flex-col'>
|
||||
<div className='p-2 md:p-4 relative h-[calc(100vh-6rem)] md:h-[calc(100vh-4rem)] flex flex-col'>
|
||||
<PageLoading loading={loading} />
|
||||
|
||||
<div className='flex mb-4 items-center gap-4'>
|
||||
<div className='flex items-center gap-2 text-default-600'>
|
||||
<MdExtension size={24} />
|
||||
<span className='text-lg font-medium'>插件扩展页面</span>
|
||||
<div className='flex mb-4 items-center justify-between gap-4 flex-wrap'>
|
||||
<div className='flex items-center gap-4'>
|
||||
<div className='flex items-center gap-2 text-default-600'>
|
||||
<MdExtension size={24} />
|
||||
<span className='text-lg font-medium'>插件扩展页面</span>
|
||||
</div>
|
||||
<Button
|
||||
isIconOnly
|
||||
className='bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md'
|
||||
radius='full'
|
||||
onPress={refresh}
|
||||
>
|
||||
<IoMdRefresh size={24} />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
isIconOnly
|
||||
className='bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md'
|
||||
radius='full'
|
||||
onPress={refresh}
|
||||
>
|
||||
<IoMdRefresh size={24} />
|
||||
</Button>
|
||||
{extensionPages.length > 0 && (
|
||||
<Tabs
|
||||
aria-label='Extension Pages'
|
||||
className='max-w-full'
|
||||
selectedKey={selectedTab}
|
||||
onSelectionChange={(key) => setSelectedTab(key as string)}
|
||||
classNames={{
|
||||
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
|
||||
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
|
||||
panel: 'hidden',
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
title={
|
||||
<div className='flex items-center gap-2'>
|
||||
{tab.icon && <span>{tab.icon}</span>}
|
||||
<span>{tab.title}</span>
|
||||
<span className='text-xs text-default-400'>({tab.pluginName})</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{extensionPages.length === 0 && !loading
|
||||
? (
|
||||
<div className='flex-1 flex flex-col items-center justify-center text-default-400'>
|
||||
<MdExtension size={64} className='mb-4 opacity-50' />
|
||||
<p className='text-lg'>暂无插件扩展页面</p>
|
||||
<p className='text-sm mt-2'>插件可以通过注册页面来扩展 WebUI 功能</p>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className='flex-1 flex flex-col min-h-0'>
|
||||
<Tabs
|
||||
aria-label='Extension Pages'
|
||||
className='max-w-full'
|
||||
selectedKey={selectedTab}
|
||||
onSelectionChange={(key) => setSelectedTab(key as string)}
|
||||
classNames={{
|
||||
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md flex-wrap',
|
||||
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
|
||||
panel: 'flex-1 min-h-0 p-0',
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
title={
|
||||
<div className='flex items-center gap-2'>
|
||||
{tab.icon && <span>{tab.icon}</span>}
|
||||
<span>{tab.title}</span>
|
||||
<span className='text-xs text-default-400'>({tab.pluginName})</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='relative w-full h-[calc(100vh-220px)] bg-white/40 dark:bg-black/20 backdrop-blur-md rounded-lg overflow-hidden'>
|
||||
{iframeLoading && (
|
||||
<div className='absolute inset-0 flex items-center justify-center bg-default-100/50 z-10'>
|
||||
<Spinner size='lg' />
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
src={currentPageUrl}
|
||||
className='w-full h-full border-0'
|
||||
onLoad={handleIframeLoad}
|
||||
title={tab.title}
|
||||
sandbox='allow-scripts allow-same-origin allow-forms allow-popups'
|
||||
/>
|
||||
</div>
|
||||
</Tab>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
{extensionPages.length === 0 && !loading ? (
|
||||
<div className='flex-1 flex flex-col items-center justify-center text-default-400'>
|
||||
<MdExtension size={64} className='mb-4 opacity-50' />
|
||||
<p className='text-lg'>暂无插件扩展页面</p>
|
||||
<p className='text-sm mt-2'>插件可以通过注册页面来扩展 WebUI 功能</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex-1 min-h-0 bg-white/40 dark:bg-black/20 backdrop-blur-md rounded-lg overflow-hidden relative'>
|
||||
{iframeLoading && (
|
||||
<div className='absolute inset-0 flex items-center justify-center bg-default-100/50 z-10'>
|
||||
<Spinner size='lg' />
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
src={currentPageUrl}
|
||||
className='w-full h-full border-0'
|
||||
onLoad={handleIframeLoad}
|
||||
title='extension-page'
|
||||
sandbox='allow-scripts allow-same-origin allow-forms allow-popups'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user