mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2025-12-19 21:20:07 +08:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
176af14915 | ||
|
|
81cf1fd98e | ||
|
|
5189099146 | ||
|
|
7fc17d45ba | ||
|
|
c54f74609e | ||
|
|
a2d7ac4878 | ||
|
|
fd0afa3b25 | ||
|
|
7685cc3dfc | ||
|
|
f9c0b9d106 | ||
|
|
d31f0a45b4 | ||
|
|
7c701781a1 | ||
|
|
3c612e03ff | ||
|
|
f27db01145 | ||
|
|
ae97cfba03 | ||
|
|
162ddc1bf5 | ||
|
|
afb6ef421a | ||
|
|
173a165c4b | ||
|
|
d525f9b03d | ||
|
|
f2ba789cc0 | ||
|
|
2cdc9bdc09 | ||
|
|
c123b34d5f |
@ -43,7 +43,7 @@ _Modern protocol-side framework implemented based on NTQQ._
|
|||||||
|
|
||||||
**首次使用**请务必查看如下文档看使用教程
|
**首次使用**请务必查看如下文档看使用教程
|
||||||
|
|
||||||
> 项目非盈利,对接问题/基础问题/下层框架问题 请自行搜索解决,本项目社区不提供此类解答。
|
> 项目非盈利,涉及 对接问题/基础问题/下层框架问题 请自行搜索解决,本项目社区不提供此类解答。
|
||||||
|
|
||||||
## Link
|
## Link
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import path from 'node:path';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import { QQVersionConfigType, QQLevel } from './types';
|
import { QQVersionConfigType, QQLevel } from './types';
|
||||||
|
import { RequestUtil } from './request';
|
||||||
|
|
||||||
export async function solveProblem<T extends (...arg: any[]) => any> (func: T, ...args: Parameters<T>): Promise<ReturnType<T> | undefined> {
|
export async function solveProblem<T extends (...arg: any[]) => any> (func: T, ...args: Parameters<T>): Promise<ReturnType<T> | undefined> {
|
||||||
return new Promise<ReturnType<T> | undefined>((resolve) => {
|
return new Promise<ReturnType<T> | undefined>((resolve) => {
|
||||||
@ -211,3 +212,81 @@ export function parseAppidFromMajor (nodeMajor: string): string | undefined {
|
|||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const baseUrl = 'https://github.com/NapNeko/NapCatQQ.git/info/refs?service=git-upload-pack';
|
||||||
|
const urls = [
|
||||||
|
'https://j.1win.ggff.net/' + baseUrl,
|
||||||
|
'https://git.yylx.win/' + baseUrl,
|
||||||
|
'https://ghfile.geekertao.top/' + baseUrl,
|
||||||
|
'https://gh-proxy.net/' + baseUrl,
|
||||||
|
'https://ghm.078465.xyz/' + baseUrl,
|
||||||
|
'https://gitproxy.127731.xyz/' + baseUrl,
|
||||||
|
'https://jiashu.1win.eu.org/' + baseUrl,
|
||||||
|
baseUrl,
|
||||||
|
];
|
||||||
|
|
||||||
|
async function testUrl (url: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await PromiseTimer(RequestUtil.HttpGetText(url), 5000);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findAvailableUrl (): Promise<string | null> {
|
||||||
|
for (const url of urls) {
|
||||||
|
if (await testUrl(url)) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllTags (): Promise<string[]> {
|
||||||
|
const availableUrl = await findAvailableUrl();
|
||||||
|
if (!availableUrl) {
|
||||||
|
throw new Error('No available URL for fetching tags');
|
||||||
|
}
|
||||||
|
const raw = await RequestUtil.HttpGetText(availableUrl);
|
||||||
|
return raw
|
||||||
|
.split('\n')
|
||||||
|
.map(line => {
|
||||||
|
const match = line.match(/refs\/tags\/(.+)$/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
})
|
||||||
|
.filter(tag => tag !== null && !tag!.endsWith('^{}')) as string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function getLatestTag (): Promise<string> {
|
||||||
|
const tags = await getAllTags();
|
||||||
|
|
||||||
|
tags.sort((a, b) => compareVersion(a, b));
|
||||||
|
|
||||||
|
const latest = tags.at(-1);
|
||||||
|
if (!latest) {
|
||||||
|
throw new Error('No tags found');
|
||||||
|
}
|
||||||
|
// 去掉开头的 v
|
||||||
|
return latest.replace(/^v/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function compareVersion (a: string, b: string): number {
|
||||||
|
const normalize = (v: string) =>
|
||||||
|
v.replace(/^v/, '') // 去掉开头的 v
|
||||||
|
.split('.')
|
||||||
|
.map(n => parseInt(n) || 0);
|
||||||
|
|
||||||
|
const pa = normalize(a);
|
||||||
|
const pb = normalize(b);
|
||||||
|
const len = Math.max(pa.length, pb.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const na = pa[i] || 0;
|
||||||
|
const nb = pb[i] || 0;
|
||||||
|
if (na !== nb) return na - nb;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ export class NodeIDependsAdapter {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMSFSsoError (_args: unknown) {
|
onMSFSsoError (_code: number, _desc: string) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
32
packages/napcat-core/external/appid.json
vendored
32
packages/napcat-core/external/appid.json
vendored
@ -466,5 +466,37 @@
|
|||||||
"6.9.85-42086": {
|
"6.9.85-42086": {
|
||||||
"appid": 537320237,
|
"appid": 537320237,
|
||||||
"qua": "V1_MAC_NQ_6.9.85_42086_GW_B"
|
"qua": "V1_MAC_NQ_6.9.85_42086_GW_B"
|
||||||
|
},
|
||||||
|
"9.9.23-42430": {
|
||||||
|
"appid": 537320212,
|
||||||
|
"qua": "V1_WIN_NQ_9.9.23_42430_GW_B"
|
||||||
|
},
|
||||||
|
"9.9.25-42744": {
|
||||||
|
"appid": 537328470,
|
||||||
|
"qua": "V1_WIN_NQ_9.9.23_42744_GW_B"
|
||||||
|
},
|
||||||
|
"6.9.86-42744": {
|
||||||
|
"appid": 537328495,
|
||||||
|
"qua": "V1_MAC_NQ_6.9.85_42744_GW_B"
|
||||||
|
},
|
||||||
|
"9.9.25-42905": {
|
||||||
|
"appid": 537328521,
|
||||||
|
"qua": "V1_WIN_NQ_9.9.25_42905_GW_B"
|
||||||
|
},
|
||||||
|
"6.9.86-42905": {
|
||||||
|
"appid": 537328546,
|
||||||
|
"qua": "V1_MAC_NQ_6.9.86_42905_GW_B"
|
||||||
|
},
|
||||||
|
"3.2.22-42941": {
|
||||||
|
"appid": 537328659,
|
||||||
|
"qua": "V1_LNX_NQ_3.2.22_42941_GW_B"
|
||||||
|
},
|
||||||
|
"9.9.25-42941": {
|
||||||
|
"appid": 537328623,
|
||||||
|
"qua": "V1_WIN_NQ_9.9.25_42941_GW_B"
|
||||||
|
},
|
||||||
|
"6.9.86-42941": {
|
||||||
|
"appid": 537328648,
|
||||||
|
"qua": "V1_MAC_NQ_6.9.86_42941_GW_B"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
36
packages/napcat-core/external/napi2native.json
vendored
36
packages/napcat-core/external/napi2native.json
vendored
@ -90,5 +90,41 @@
|
|||||||
"3.2.21-42086-x64": {
|
"3.2.21-42086-x64": {
|
||||||
"send": "5B42CF0",
|
"send": "5B42CF0",
|
||||||
"recv": "2FDA6F0"
|
"recv": "2FDA6F0"
|
||||||
|
},
|
||||||
|
"9.9.23-42430-x64": {
|
||||||
|
"send": "0A01A34",
|
||||||
|
"recv": "1D1CFF9"
|
||||||
|
},
|
||||||
|
"9.9.25-42744-x64": {
|
||||||
|
"send": "0A0D104",
|
||||||
|
"recv": "1D3E7F9"
|
||||||
|
},
|
||||||
|
"6.9.85-42744-arm64": {
|
||||||
|
"send": "23DFEF0",
|
||||||
|
"recv": "095FD80"
|
||||||
|
},
|
||||||
|
"9.9.25-42905-x64": {
|
||||||
|
"send": "0A12E74",
|
||||||
|
"recv": "1D450FD"
|
||||||
|
},
|
||||||
|
"6.9.86-42905-arm64": {
|
||||||
|
"send": "2342408",
|
||||||
|
"recv": "09639B8"
|
||||||
|
},
|
||||||
|
"3.2.22-42941-x64": {
|
||||||
|
"send": "5BC1630",
|
||||||
|
"recv": "3011E00"
|
||||||
|
},
|
||||||
|
"3.2.22-42941-arm64": {
|
||||||
|
"send": "3DC90AC",
|
||||||
|
"recv": "1497A70"
|
||||||
|
},
|
||||||
|
"9.9.25-42941-x64": {
|
||||||
|
"send": "0A131D4",
|
||||||
|
"recv": "1D4547D"
|
||||||
|
},
|
||||||
|
"6.9.86-42941-arm64": {
|
||||||
|
"send": "2346108",
|
||||||
|
"recv": "09675F0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
36
packages/napcat-core/external/packet.json
vendored
36
packages/napcat-core/external/packet.json
vendored
@ -602,5 +602,41 @@
|
|||||||
"3.2.21-42086-arm64": {
|
"3.2.21-42086-arm64": {
|
||||||
"send": "6B13038",
|
"send": "6B13038",
|
||||||
"recv": "6B169C8"
|
"recv": "6B169C8"
|
||||||
|
},
|
||||||
|
"9.9.23-42430-x64": {
|
||||||
|
"send": "2C9A4A0",
|
||||||
|
"recv": "2C9DA20"
|
||||||
|
},
|
||||||
|
"9.9.25-42744-x64": {
|
||||||
|
"send": "2CD8E40",
|
||||||
|
"recv": "2CDC3C0"
|
||||||
|
},
|
||||||
|
"6.9.86-42744-arm64": {
|
||||||
|
"send": "3DCC840",
|
||||||
|
"recv": "3DCF150"
|
||||||
|
},
|
||||||
|
"9.9.25-42905-x64": {
|
||||||
|
"send": "2CE46A0",
|
||||||
|
"recv": "2CE7C20"
|
||||||
|
},
|
||||||
|
"6.9.86-42905-arm64": {
|
||||||
|
"send": "3DD6098",
|
||||||
|
"recv": "3DD89A8"
|
||||||
|
},
|
||||||
|
"3.2.22-42941-x64": {
|
||||||
|
"send": "A8AD8A0",
|
||||||
|
"recv": "A8B1320"
|
||||||
|
},
|
||||||
|
"9.9.25-42941-x64": {
|
||||||
|
"send": "2CE4DA0",
|
||||||
|
"recv": "2CE8320"
|
||||||
|
},
|
||||||
|
"3.2.22-42941-arm64": {
|
||||||
|
"send": "6BC95E8",
|
||||||
|
"recv": "6BCCF78"
|
||||||
|
},
|
||||||
|
"6.9.86-42941-arm64": {
|
||||||
|
"send": "3DDDAD0",
|
||||||
|
"recv": "3DE03E0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -3,7 +3,7 @@ export class NodeIKernelStorageCleanListener {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onScanCacheProgressChanged (_args: unknown): any {
|
onScanCacheProgressChanged (_current_progress: number, _total_progress: number): any {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -11,7 +11,7 @@ export class NodeIKernelStorageCleanListener {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onFinishScan (_args: unknown): any {
|
onFinishScan (_sizes: Array<`${number}`>): any {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,13 +6,30 @@ export interface NodeIKernelStorageCleanService {
|
|||||||
addKernelStorageCleanListener (listener: NodeIKernelStorageCleanListener): number;
|
addKernelStorageCleanListener (listener: NodeIKernelStorageCleanListener): number;
|
||||||
|
|
||||||
removeKernelStorageCleanListener (listenerId: number): void;
|
removeKernelStorageCleanListener (listenerId: number): void;
|
||||||
|
// [
|
||||||
addCacheScanedPaths(arg: unknown): unknown;
|
// "hotUpdate",
|
||||||
|
// [
|
||||||
|
// "C:\\Users\\nanaeo\\AppData\\Roaming\\QQ\\packages"
|
||||||
|
// ]
|
||||||
|
// ],
|
||||||
|
// [
|
||||||
|
// "tmp",
|
||||||
|
// [
|
||||||
|
// "C:\\Users\\nanaeo\\AppData\\Roaming\\QQ\\tmp"
|
||||||
|
// ]
|
||||||
|
// ],
|
||||||
|
// [
|
||||||
|
// "SilentCacheappSessionPartation9212",
|
||||||
|
// [
|
||||||
|
// "C:\\Users\\nanaeo\\AppData\\Roaming\\QQ\\Partitions\\qqnt_9212"
|
||||||
|
// ]
|
||||||
|
// ]
|
||||||
|
addCacheScanedPaths (paths: Map<`tmp` | `SilentCacheappSessionPartation9212` | `hotUpdate`, unknown>): unknown;
|
||||||
|
|
||||||
addFilesScanedPaths (arg: unknown): unknown;
|
addFilesScanedPaths (arg: unknown): unknown;
|
||||||
|
|
||||||
scanCache (): Promise<GeneralCallResult & {
|
scanCache (): Promise<GeneralCallResult & {
|
||||||
size: string[]
|
size: string[];
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
addReportData (arg: unknown): unknown;
|
addReportData (arg: unknown): unknown;
|
||||||
@ -25,9 +42,9 @@ export interface NodeIKernelStorageCleanService {
|
|||||||
|
|
||||||
clearChatCacheInfo (arg1: unknown, arg2: unknown): unknown;
|
clearChatCacheInfo (arg1: unknown, arg2: unknown): unknown;
|
||||||
|
|
||||||
clearCacheDataByKeys(arg: unknown): unknown;
|
clearCacheDataByKeys (keys: Array<string>): Promise<GeneralCallResult>;
|
||||||
|
|
||||||
setSilentScan(arg: unknown): unknown;
|
setSilentScan (is_silent: boolean): unknown;
|
||||||
|
|
||||||
closeCleanWindow (): unknown;
|
closeCleanWindow (): unknown;
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import { QQBasicInfoWrapper } from '@/napcat-core/helper/qq-basic-info';
|
|||||||
import { InstanceContext, loadQQWrapper, NapCatCore, NapCatCoreWorkingEnv, NodeIKernelLoginListener, NodeIKernelLoginService, NodeIQQNTWrapperSession, SelfInfo, WrapperNodeApi } from '@/napcat-core';
|
import { InstanceContext, loadQQWrapper, NapCatCore, NapCatCoreWorkingEnv, NodeIKernelLoginListener, NodeIKernelLoginService, NodeIQQNTWrapperSession, SelfInfo, WrapperNodeApi } from '@/napcat-core';
|
||||||
import { proxiedListenerOf } from '@/napcat-core/helper/proxy-handler';
|
import { proxiedListenerOf } from '@/napcat-core/helper/proxy-handler';
|
||||||
import { statusHelperSubscription } from '@/napcat-core/helper/status';
|
import { statusHelperSubscription } from '@/napcat-core/helper/status';
|
||||||
|
import { applyPendingUpdates } from '@/napcat-webui-backend/src/api/UpdateNapCat';
|
||||||
|
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||||
|
|
||||||
// Framework ES入口文件
|
// Framework ES入口文件
|
||||||
export async function getWebUiUrl () {
|
export async function getWebUiUrl () {
|
||||||
@ -32,6 +34,7 @@ export async function NCoreInitFramework (
|
|||||||
});
|
});
|
||||||
|
|
||||||
const pathWrapper = new NapCatPathWrapper();
|
const pathWrapper = new NapCatPathWrapper();
|
||||||
|
await applyPendingUpdates(pathWrapper);
|
||||||
const logger = new LogWrapper(pathWrapper.logsPath);
|
const logger = new LogWrapper(pathWrapper.logsPath);
|
||||||
const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
|
const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
|
||||||
const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVersion());
|
const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVersion());
|
||||||
@ -73,6 +76,7 @@ export async function NCoreInitFramework (
|
|||||||
await loaderObject.core.initCore();
|
await loaderObject.core.initCore();
|
||||||
|
|
||||||
// 启动WebUi
|
// 启动WebUi
|
||||||
|
WebUiDataRuntime.setWorkingEnv(NapCatCoreWorkingEnv.Framework);
|
||||||
InitWebUi(logger, pathWrapper, logSubscription, statusHelperSubscription).then().catch(e => logger.logError(e));
|
InitWebUi(logger, pathWrapper, logSubscription, statusHelperSubscription).then().catch(e => logger.logError(e));
|
||||||
// 初始化LLNC的Onebot实现
|
// 初始化LLNC的Onebot实现
|
||||||
await new NapCatOneBot11Adapter(loaderObject.core, loaderObject.context, pathWrapper).InitOneBot();
|
await new NapCatOneBot11Adapter(loaderObject.core, loaderObject.context, pathWrapper).InitOneBot();
|
||||||
|
|||||||
Binary file not shown.
@ -6,23 +6,23 @@ import { Static, Type } from '@sinclair/typebox';
|
|||||||
const SchemaData = Type.Object({
|
const SchemaData = Type.Object({
|
||||||
user_id: Type.Optional(Type.Union([Type.Number(), Type.String()])),
|
user_id: Type.Optional(Type.Union([Type.Number(), Type.String()])),
|
||||||
group_id: Type.Optional(Type.Union([Type.Number(), Type.String()])),
|
group_id: Type.Optional(Type.Union([Type.Number(), Type.String()])),
|
||||||
phoneNumber: Type.String({ default: '' }),
|
phone_number: Type.String({ default: '' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
type Payload = Static<typeof SchemaData>;
|
type Payload = Static<typeof SchemaData>;
|
||||||
|
|
||||||
export class SharePeer extends OneBotAction<Payload, GeneralCallResult & {
|
export class SharePeerBase extends OneBotAction<Payload, GeneralCallResult & {
|
||||||
arkMsg?: string;
|
arkMsg?: string;
|
||||||
arkJson?: string;
|
arkJson?: string;
|
||||||
}> {
|
}> {
|
||||||
override actionName = ActionName.SharePeer;
|
|
||||||
override payloadSchema = SchemaData;
|
override payloadSchema = SchemaData;
|
||||||
|
|
||||||
async _handle (payload: Payload) {
|
async _handle (payload: Payload) {
|
||||||
if (payload.group_id) {
|
if (payload.group_id) {
|
||||||
return await this.core.apis.GroupApi.getGroupRecommendContactArkJson(payload.group_id.toString());
|
return await this.core.apis.GroupApi.getGroupRecommendContactArkJson(payload.group_id.toString());
|
||||||
} else if (payload.user_id) {
|
} else if (payload.user_id) {
|
||||||
return await this.core.apis.UserApi.getBuddyRecommendContactArkJson(payload.user_id.toString(), payload.phoneNumber);
|
return await this.core.apis.UserApi.getBuddyRecommendContactArkJson(payload.user_id.toString(), payload.phone_number);
|
||||||
}
|
}
|
||||||
throw new Error('group_id or user_id is required');
|
throw new Error('group_id or user_id is required');
|
||||||
}
|
}
|
||||||
@ -31,14 +31,25 @@ export class SharePeer extends OneBotAction<Payload, GeneralCallResult & {
|
|||||||
const SchemaDataGroupEx = Type.Object({
|
const SchemaDataGroupEx = Type.Object({
|
||||||
group_id: Type.Union([Type.Number(), Type.String()]),
|
group_id: Type.Union([Type.Number(), Type.String()]),
|
||||||
});
|
});
|
||||||
|
export class SharePeer extends SharePeerBase {
|
||||||
|
override actionName = ActionName.SharePeer;
|
||||||
|
}
|
||||||
type PayloadGroupEx = Static<typeof SchemaDataGroupEx>;
|
type PayloadGroupEx = Static<typeof SchemaDataGroupEx>;
|
||||||
|
|
||||||
export class ShareGroupEx extends OneBotAction<PayloadGroupEx, string> {
|
export class ShareGroupExBase extends OneBotAction<PayloadGroupEx, string> {
|
||||||
override actionName = ActionName.ShareGroupEx;
|
|
||||||
override payloadSchema = SchemaDataGroupEx;
|
override payloadSchema = SchemaDataGroupEx;
|
||||||
|
|
||||||
async _handle (payload: PayloadGroupEx) {
|
async _handle (payload: PayloadGroupEx) {
|
||||||
return await this.core.apis.GroupApi.getArkJsonGroupShare(payload.group_id.toString());
|
return await this.core.apis.GroupApi.getArkJsonGroupShare(payload.group_id.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class ShareGroupEx extends ShareGroupExBase {
|
||||||
|
override actionName = ActionName.ShareGroupEx;
|
||||||
|
}
|
||||||
|
export class SendGroupArkShare extends ShareGroupExBase {
|
||||||
|
override actionName = ActionName.SendGroupArkShare;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SendArkShare extends SharePeerBase {
|
||||||
|
override actionName = ActionName.SendArkShare;
|
||||||
|
}
|
||||||
@ -14,10 +14,11 @@ const SchemaData = Type.Object({
|
|||||||
user_id: Type.String(),
|
user_id: Type.String(),
|
||||||
message_seq: Type.Optional(Type.String()),
|
message_seq: Type.Optional(Type.String()),
|
||||||
count: Type.Number({ default: 20 }),
|
count: Type.Number({ default: 20 }),
|
||||||
reverseOrder: Type.Boolean({ default: false }),
|
reverse_order: Type.Boolean({ default: false }),
|
||||||
disable_get_url: Type.Boolean({ default: false }),
|
disable_get_url: Type.Boolean({ default: false }),
|
||||||
parse_mult_msg: Type.Boolean({ default: true }),
|
parse_mult_msg: Type.Boolean({ default: true }),
|
||||||
quick_reply: Type.Boolean({ default: false }),
|
quick_reply: Type.Boolean({ default: false }),
|
||||||
|
reverseOrder: Type.Boolean({ default: false }),// @deprecated 兼容旧版本
|
||||||
});
|
});
|
||||||
|
|
||||||
type Payload = Static<typeof SchemaData>;
|
type Payload = Static<typeof SchemaData>;
|
||||||
@ -35,7 +36,7 @@ export default class GetFriendMsgHistory extends OneBotAction<Payload, Response>
|
|||||||
const hasMessageSeq = !payload.message_seq ? !!payload.message_seq : !(payload.message_seq?.toString() === '' || payload.message_seq?.toString() === '0');
|
const hasMessageSeq = !payload.message_seq ? !!payload.message_seq : !(payload.message_seq?.toString() === '' || payload.message_seq?.toString() === '0');
|
||||||
const startMsgId = hasMessageSeq ? (MessageUnique.getMsgIdAndPeerByShortId(+payload.message_seq!)?.MsgId ?? payload.message_seq!.toString()) : '0';
|
const startMsgId = hasMessageSeq ? (MessageUnique.getMsgIdAndPeerByShortId(+payload.message_seq!)?.MsgId ?? payload.message_seq!.toString()) : '0';
|
||||||
const msgList = hasMessageSeq
|
const msgList = hasMessageSeq
|
||||||
? (await this.core.apis.MsgApi.getMsgHistory(peer, startMsgId, +payload.count, payload.reverseOrder)).msgList
|
? (await this.core.apis.MsgApi.getMsgHistory(peer, startMsgId, +payload.count, payload.reverse_order || payload.reverseOrder)).msgList
|
||||||
: (await this.core.apis.MsgApi.getAioFirstViewLatestMsgs(peer, +payload.count)).msgList;
|
: (await this.core.apis.MsgApi.getAioFirstViewLatestMsgs(peer, +payload.count)).msgList;
|
||||||
if (msgList.length === 0) throw new Error(`消息${payload.message_seq}不存在`);
|
if (msgList.length === 0) throw new Error(`消息${payload.message_seq}不存在`);
|
||||||
// 转换序号
|
// 转换序号
|
||||||
|
|||||||
@ -14,10 +14,11 @@ const SchemaData = Type.Object({
|
|||||||
group_id: Type.String(),
|
group_id: Type.String(),
|
||||||
message_seq: Type.Optional(Type.String()),
|
message_seq: Type.Optional(Type.String()),
|
||||||
count: Type.Number({ default: 20 }),
|
count: Type.Number({ default: 20 }),
|
||||||
reverseOrder: Type.Boolean({ default: false }),
|
reverse_order: Type.Boolean({ default: false }),
|
||||||
disable_get_url: Type.Boolean({ default: false }),
|
disable_get_url: Type.Boolean({ default: false }),
|
||||||
parse_mult_msg: Type.Boolean({ default: true }),
|
parse_mult_msg: Type.Boolean({ default: true }),
|
||||||
quick_reply: Type.Boolean({ default: false }),
|
quick_reply: Type.Boolean({ default: false }),
|
||||||
|
reverseOrder: Type.Boolean({ default: false }),// @deprecated 兼容旧版本
|
||||||
});
|
});
|
||||||
|
|
||||||
type Payload = Static<typeof SchemaData>;
|
type Payload = Static<typeof SchemaData>;
|
||||||
@ -32,7 +33,7 @@ export default class GoCQHTTPGetGroupMsgHistory extends OneBotAction<Payload, Re
|
|||||||
// 拉取消息
|
// 拉取消息
|
||||||
const startMsgId = hasMessageSeq ? (MessageUnique.getMsgIdAndPeerByShortId(+payload.message_seq!)?.MsgId ?? payload.message_seq!.toString()) : '0';
|
const startMsgId = hasMessageSeq ? (MessageUnique.getMsgIdAndPeerByShortId(+payload.message_seq!)?.MsgId ?? payload.message_seq!.toString()) : '0';
|
||||||
const msgList = hasMessageSeq
|
const msgList = hasMessageSeq
|
||||||
? (await this.core.apis.MsgApi.getMsgHistory(peer, startMsgId, +payload.count, payload.reverseOrder)).msgList
|
? (await this.core.apis.MsgApi.getMsgHistory(peer, startMsgId, +payload.count, payload.reverse_order || payload.reverseOrder)).msgList
|
||||||
: (await this.core.apis.MsgApi.getAioFirstViewLatestMsgs(peer, +payload.count)).msgList;
|
: (await this.core.apis.MsgApi.getAioFirstViewLatestMsgs(peer, +payload.count)).msgList;
|
||||||
if (msgList.length === 0) throw new Error(`消息${payload.message_seq}不存在`);
|
if (msgList.length === 0) throw new Error(`消息${payload.message_seq}不存在`);
|
||||||
// 转换序号
|
// 转换序号
|
||||||
|
|||||||
@ -54,7 +54,7 @@ import { GetOnlineClient } from './go-cqhttp/GetOnlineClient';
|
|||||||
import { IOCRImage, OCRImage } from './extends/OCRImage';
|
import { IOCRImage, OCRImage } from './extends/OCRImage';
|
||||||
import { TranslateEnWordToZn } from './extends/TranslateEnWordToZn';
|
import { TranslateEnWordToZn } from './extends/TranslateEnWordToZn';
|
||||||
import { SetQQProfile } from './go-cqhttp/SetQQProfile';
|
import { SetQQProfile } from './go-cqhttp/SetQQProfile';
|
||||||
import { ShareGroupEx, SharePeer } from './extends/ShareContact';
|
import { SendArkShare, SendGroupArkShare, ShareGroupEx, SharePeer } from './extends/ShareContact';
|
||||||
import { CreateCollection } from './extends/CreateCollection';
|
import { CreateCollection } from './extends/CreateCollection';
|
||||||
import { SetLongNick } from './extends/SetLongNick';
|
import { SetLongNick } from './extends/SetLongNick';
|
||||||
import DelEssenceMsg from './group/DelEssenceMsg';
|
import DelEssenceMsg from './group/DelEssenceMsg';
|
||||||
@ -170,6 +170,8 @@ export function createActionMap (obContext: NapCatOneBot11Adapter, core: NapCatC
|
|||||||
new SetQQProfile(obContext, core),
|
new SetQQProfile(obContext, core),
|
||||||
new ShareGroupEx(obContext, core),
|
new ShareGroupEx(obContext, core),
|
||||||
new SharePeer(obContext, core),
|
new SharePeer(obContext, core),
|
||||||
|
new SendGroupArkShare(obContext, core),
|
||||||
|
new SendArkShare(obContext, core),
|
||||||
new CreateCollection(obContext, core),
|
new CreateCollection(obContext, core),
|
||||||
new SetLongNick(obContext, core),
|
new SetLongNick(obContext, core),
|
||||||
new ForwardFriendSingleMsg(obContext, core),
|
new ForwardFriendSingleMsg(obContext, core),
|
||||||
|
|||||||
@ -125,8 +125,11 @@ export const ActionName = {
|
|||||||
// 以下为扩展napcat扩展
|
// 以下为扩展napcat扩展
|
||||||
Unknown: 'unknown',
|
Unknown: 'unknown',
|
||||||
SetDiyOnlineStatus: 'set_diy_online_status',
|
SetDiyOnlineStatus: 'set_diy_online_status',
|
||||||
SharePeer: 'ArkSharePeer',
|
SharePeer: 'ArkSharePeer',// @deprecated
|
||||||
ShareGroupEx: 'ArkShareGroup',
|
ShareGroupEx: 'ArkShareGroup',// @deprecated
|
||||||
|
// 标准化接口
|
||||||
|
SendGroupArkShare: 'send_group_ark_share',
|
||||||
|
SendArkShare: 'send_ark_share',
|
||||||
// RebootNormal : 'reboot_normal', //无快速登录重新启动
|
// RebootNormal : 'reboot_normal', //无快速登录重新启动
|
||||||
GetRobotUinRange: 'get_robot_uin_range',
|
GetRobotUinRange: 'get_robot_uin_range',
|
||||||
SetOnlineStatus: 'set_online_status',
|
SetOnlineStatus: 'set_online_status',
|
||||||
|
|||||||
@ -12,7 +12,17 @@ import { ArgvOrCommandLine } from '@homebridge/node-pty-prebuilt-multiarch/src/t
|
|||||||
import { assign } from '@homebridge/node-pty-prebuilt-multiarch/src/utils';
|
import { assign } from '@homebridge/node-pty-prebuilt-multiarch/src/utils';
|
||||||
import { pty_loader } from './prebuild-loader';
|
import { pty_loader } from './prebuild-loader';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
export const pty = pty_loader();
|
|
||||||
|
// 懒加载pty,避免在模块导入时立即执行pty_loader()
|
||||||
|
let _pty: any;
|
||||||
|
export const pty: any = new Proxy({}, {
|
||||||
|
get (_target, prop) {
|
||||||
|
if (!_pty) {
|
||||||
|
_pty = pty_loader();
|
||||||
|
}
|
||||||
|
return _pty[prop];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let helperPath: string;
|
let helperPath: string;
|
||||||
helperPath = '../build/Release/spawn-helper';
|
helperPath = '../build/Release/spawn-helper';
|
||||||
|
|||||||
@ -35,6 +35,7 @@ import { logSubscription, LogWrapper } from '@/napcat-core/helper/log';
|
|||||||
import { proxiedListenerOf } from '@/napcat-core/helper/proxy-handler';
|
import { proxiedListenerOf } from '@/napcat-core/helper/proxy-handler';
|
||||||
import { QQBasicInfoWrapper } from '@/napcat-core/helper/qq-basic-info';
|
import { QQBasicInfoWrapper } from '@/napcat-core/helper/qq-basic-info';
|
||||||
import { statusHelperSubscription } from '@/napcat-core/helper/status';
|
import { statusHelperSubscription } from '@/napcat-core/helper/status';
|
||||||
|
import { applyPendingUpdates } from '@/napcat-webui-backend/src/api/UpdateNapCat';
|
||||||
// NapCat Shell App ES 入口文件
|
// NapCat Shell App ES 入口文件
|
||||||
async function handleUncaughtExceptions (logger: LogWrapper) {
|
async function handleUncaughtExceptions (logger: LogWrapper) {
|
||||||
process.on('uncaughtException', (err) => {
|
process.on('uncaughtException', (err) => {
|
||||||
@ -318,6 +319,7 @@ export async function NCoreInitShell () {
|
|||||||
const pathWrapper = new NapCatPathWrapper();
|
const pathWrapper = new NapCatPathWrapper();
|
||||||
const logger = new LogWrapper(pathWrapper.logsPath);
|
const logger = new LogWrapper(pathWrapper.logsPath);
|
||||||
handleUncaughtExceptions(logger);
|
handleUncaughtExceptions(logger);
|
||||||
|
await applyPendingUpdates(pathWrapper);
|
||||||
|
|
||||||
// 初始化 FFmpeg 服务
|
// 初始化 FFmpeg 服务
|
||||||
await FFmpegService.init(pathWrapper.binaryPath, logger);
|
await FFmpegService.init(pathWrapper.binaryPath, logger);
|
||||||
@ -338,8 +340,8 @@ export async function NCoreInitShell () {
|
|||||||
o3Service.addO3MiscListener(new NodeIO3MiscListener());
|
o3Service.addO3MiscListener(new NodeIO3MiscListener());
|
||||||
|
|
||||||
logger.log('[NapCat] [Core] NapCat.Core Version: ' + napCatVersion);
|
logger.log('[NapCat] [Core] NapCat.Core Version: ' + napCatVersion);
|
||||||
|
WebUiDataRuntime.setWorkingEnv(NapCatCoreWorkingEnv.Shell);
|
||||||
InitWebUi(logger, pathWrapper, logSubscription, statusHelperSubscription).then().catch(e => logger.logError(e));
|
InitWebUi(logger, pathWrapper, logSubscription, statusHelperSubscription).then().catch(e => logger.logError(e));
|
||||||
|
|
||||||
const engine = wrapper.NodeIQQNTWrapperEngine.get();
|
const engine = wrapper.NodeIQQNTWrapperEngine.get();
|
||||||
const loginService = wrapper.NodeIKernelLoginService.get();
|
const loginService = wrapper.NodeIKernelLoginService.get();
|
||||||
let session: NodeIQQNTWrapperSession;
|
let session: NodeIQQNTWrapperSession;
|
||||||
|
|||||||
@ -58,7 +58,7 @@ export default function vitePluginNapcatVersion () {
|
|||||||
try {
|
try {
|
||||||
const json = JSON.parse(data);
|
const json = JSON.parse(data);
|
||||||
if (Array.isArray(json) && json[0]?.name) {
|
if (Array.isArray(json) && json[0]?.name) {
|
||||||
resolve(json[0].name);
|
resolve(json[0].name.replace(/^v/, ''));
|
||||||
} else reject(new Error('Invalid GitHub tag response'));
|
} else reject(new Error('Invalid GitHub tag response'));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reject(e);
|
reject(e);
|
||||||
@ -79,7 +79,7 @@ export default function vitePluginNapcatVersion () {
|
|||||||
return tag;
|
return tag;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[vite-plugin-napcat-version] Failed to fetch tag:', e.message);
|
console.warn('[vite-plugin-napcat-version] Failed to fetch tag:', e.message);
|
||||||
return cached ?? 'v0.0.0';
|
return cached ?? '0.0.0';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -95,7 +95,13 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
|||||||
WebUiConfig = new WebUiConfigWrapper();
|
WebUiConfig = new WebUiConfigWrapper();
|
||||||
let config = await WebUiConfig.GetWebUIConfig();
|
let config = await WebUiConfig.GetWebUIConfig();
|
||||||
|
|
||||||
// 检查并更新默认密码 - 最高优先级
|
// 检查是否禁用WebUI(若禁用则不进行密码检测)
|
||||||
|
if (config.disableWebUI) {
|
||||||
|
logger.log('[NapCat] [WebUi] WebUI is disabled by configuration.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查并更新默认密码(仅在启用WebUI时)
|
||||||
if (config.token === 'napcat' || !config.token) {
|
if (config.token === 'napcat' || !config.token) {
|
||||||
const randomToken = process.env['NAPCAT_WEBUI_SECRET_KEY'] || getRandomToken(8);
|
const randomToken = process.env['NAPCAT_WEBUI_SECRET_KEY'] || getRandomToken(8);
|
||||||
await WebUiConfig.UpdateWebUIConfig({ token: randomToken });
|
await WebUiConfig.UpdateWebUIConfig({ token: randomToken });
|
||||||
@ -112,12 +118,6 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
|||||||
// 存储启动时的初始token用于鉴权
|
// 存储启动时的初始token用于鉴权
|
||||||
setInitialWebUiToken(config.token);
|
setInitialWebUiToken(config.token);
|
||||||
|
|
||||||
// 检查是否禁用WebUI
|
|
||||||
if (config.disableWebUI) {
|
|
||||||
logger.log('[NapCat] [WebUi] WebUI is disabled by configuration.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [host, port, token] = await InitPort(config);
|
const [host, port, token] = await InitPort(config);
|
||||||
webUiRuntimePort = port;
|
webUiRuntimePort = port;
|
||||||
if (port === 0) {
|
if (port === 0) {
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@simplewebauthn/server": "^13.2.2",
|
||||||
"@sinclair/typebox": "^0.34.38",
|
"@sinclair/typebox": "^0.34.38",
|
||||||
"ajv": "^8.13.0",
|
"ajv": "^8.13.0",
|
||||||
"compressing": "^1.10.3",
|
"compressing": "^1.10.3",
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { RequestHandler } from 'express';
|
import { RequestHandler } from 'express';
|
||||||
import { AuthHelper } from '@/napcat-webui-backend/src/helper/SignToken';
|
import { AuthHelper } from '@/napcat-webui-backend/src/helper/SignToken';
|
||||||
|
import { PasskeyHelper } from '@/napcat-webui-backend/src/helper/PasskeyHelper';
|
||||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||||
import { sendSuccess, sendError } from '@/napcat-webui-backend/src/utils/response';
|
import { sendSuccess, sendError } from '@/napcat-webui-backend/src/utils/response';
|
||||||
import { isEmpty } from '@/napcat-webui-backend/src/utils/check';
|
import { isEmpty } from '@/napcat-webui-backend/src/utils/check';
|
||||||
@ -148,3 +149,115 @@ export const UpdateTokenHandler: RequestHandler = async (req, res) => {
|
|||||||
return sendError(res, `Failed to update token: ${e.message}`);
|
return sendError(res, `Failed to update token: ${e.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 生成Passkey注册选项
|
||||||
|
export const GeneratePasskeyRegistrationOptionsHandler: RequestHandler = async (_req, res) => {
|
||||||
|
try {
|
||||||
|
// 使用固定用户ID,因为WebUI只有一个用户
|
||||||
|
const userId = 'napcat-user';
|
||||||
|
const userName = 'NapCat User';
|
||||||
|
|
||||||
|
// 从请求头获取host来确定RP_ID
|
||||||
|
const host = _req.get('host') || 'localhost';
|
||||||
|
const hostname = host.split(':')[0] || 'localhost'; // 移除端口
|
||||||
|
// 对于本地开发,使用localhost而不是IP地址
|
||||||
|
const rpId = (hostname === '127.0.0.1' || hostname === 'localhost') ? 'localhost' : hostname;
|
||||||
|
|
||||||
|
const options = await PasskeyHelper.generateRegistrationOptions(userId, userName, rpId);
|
||||||
|
return sendSuccess(res, options);
|
||||||
|
} catch (error) {
|
||||||
|
return sendError(res, `Failed to generate registration options: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 验证Passkey注册
|
||||||
|
export const VerifyPasskeyRegistrationHandler: RequestHandler = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { response } = req.body;
|
||||||
|
if (!response) {
|
||||||
|
return sendError(res, 'Response is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = req.get('origin') || req.protocol + '://' + req.get('host');
|
||||||
|
const host = req.get('host') || 'localhost';
|
||||||
|
const hostname = host.split(':')[0] || 'localhost'; // 移除端口
|
||||||
|
// 对于本地开发,使用localhost而不是IP地址
|
||||||
|
const rpId = (hostname === '127.0.0.1' || hostname === 'localhost') ? 'localhost' : hostname;
|
||||||
|
const userId = 'napcat-user';
|
||||||
|
const verification = await PasskeyHelper.verifyRegistration(userId, response, origin, rpId);
|
||||||
|
|
||||||
|
if (verification.verified) {
|
||||||
|
return sendSuccess(res, { verified: true });
|
||||||
|
} else {
|
||||||
|
return sendError(res, 'Registration failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return sendError(res, `Registration verification failed: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生成Passkey认证选项
|
||||||
|
export const GeneratePasskeyAuthenticationOptionsHandler: RequestHandler = async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = 'napcat-user';
|
||||||
|
|
||||||
|
if (!(await PasskeyHelper.hasPasskeys(userId))) {
|
||||||
|
return sendError(res, 'No passkeys registered');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从请求头获取host来确定RP_ID
|
||||||
|
const host = _req.get('host') || 'localhost';
|
||||||
|
const hostname = host.split(':')[0] || 'localhost'; // 移除端口
|
||||||
|
// 对于本地开发,使用localhost而不是IP地址
|
||||||
|
const rpId = (hostname === '127.0.0.1' || hostname === 'localhost') ? 'localhost' : hostname;
|
||||||
|
|
||||||
|
const options = await PasskeyHelper.generateAuthenticationOptions(userId, rpId);
|
||||||
|
return sendSuccess(res, options);
|
||||||
|
} catch (error) {
|
||||||
|
return sendError(res, `Failed to generate authentication options: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 验证Passkey认证
|
||||||
|
export const VerifyPasskeyAuthenticationHandler: RequestHandler = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { response } = req.body;
|
||||||
|
if (!response) {
|
||||||
|
return sendError(res, 'Response is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取WebUI配置用于限速检查
|
||||||
|
const WebUiConfigData = await WebUiConfig.GetWebUIConfig();
|
||||||
|
// 获取客户端IP
|
||||||
|
const clientIP = req.ip || req.socket.remoteAddress || '';
|
||||||
|
|
||||||
|
// 检查登录频率
|
||||||
|
if (!WebUiDataRuntime.checkLoginRate(clientIP, WebUiConfigData.loginRate)) {
|
||||||
|
return sendError(res, 'login rate limit');
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = req.get('origin') || req.protocol + '://' + req.get('host');
|
||||||
|
const host = req.get('host') || 'localhost';
|
||||||
|
const hostname = host.split(':')[0] || 'localhost'; // 移除端口
|
||||||
|
// 对于本地开发,使用localhost而不是IP地址
|
||||||
|
const rpId = (hostname === '127.0.0.1' || hostname === 'localhost') ? 'localhost' : hostname;
|
||||||
|
const userId = 'napcat-user';
|
||||||
|
const verification = await PasskeyHelper.verifyAuthentication(userId, response, origin, rpId);
|
||||||
|
|
||||||
|
if (verification.verified) {
|
||||||
|
// 使用与普通登录相同的凭证签发
|
||||||
|
const initialToken = getInitialWebUiToken();
|
||||||
|
if (!initialToken) {
|
||||||
|
return sendError(res, 'Server token not initialized');
|
||||||
|
}
|
||||||
|
const signCredential = Buffer.from(JSON.stringify(AuthHelper.signCredential(AuthHelper.generatePasswordHash(initialToken)))).toString('base64');
|
||||||
|
return sendSuccess(res, {
|
||||||
|
Credential: signCredential,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return sendError(res, 'Authentication failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return sendError(res, `Authentication verification failed: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@ -3,12 +3,22 @@ import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
|||||||
|
|
||||||
import { sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
import { sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||||
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
||||||
|
import { getLatestTag } from 'napcat-common/src/helper';
|
||||||
|
|
||||||
export const GetNapCatVersion: RequestHandler = (_, res) => {
|
export const GetNapCatVersion: RequestHandler = (_, res) => {
|
||||||
const data = WebUiDataRuntime.GetNapCatVersion();
|
const data = WebUiDataRuntime.GetNapCatVersion();
|
||||||
sendSuccess(res, { version: data });
|
sendSuccess(res, { version: data });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getLatestTagHandler: RequestHandler = async (_, res) => {
|
||||||
|
try {
|
||||||
|
const latestTag = await getLatestTag();
|
||||||
|
sendSuccess(res, latestTag);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch latest tag' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const QQVersionHandler: RequestHandler = (_, res) => {
|
export const QQVersionHandler: RequestHandler = (_, res) => {
|
||||||
const data = WebUiDataRuntime.getQQVersion();
|
const data = WebUiDataRuntime.getQQVersion();
|
||||||
sendSuccess(res, data);
|
sendSuccess(res, data);
|
||||||
|
|||||||
388
packages/napcat-webui-backend/src/api/UpdateNapCat.ts
Normal file
388
packages/napcat-webui-backend/src/api/UpdateNapCat.ts
Normal file
@ -0,0 +1,388 @@
|
|||||||
|
import { RequestHandler } from 'express';
|
||||||
|
import { sendSuccess, sendError } from '@/napcat-webui-backend/src/utils/response';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as https from 'https';
|
||||||
|
import compressing from 'compressing';
|
||||||
|
import { webUiPathWrapper } from '../../index';
|
||||||
|
import { NapCatPathWrapper } from '@/napcat-common/src/path';
|
||||||
|
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||||
|
import { NapCatCoreWorkingEnv } from '@/napcat-webui-backend/src/types';
|
||||||
|
|
||||||
|
interface Release {
|
||||||
|
tag_name: string;
|
||||||
|
assets: Array<{
|
||||||
|
name: string;
|
||||||
|
browser_download_url: string;
|
||||||
|
}>;
|
||||||
|
body?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新配置文件接口
|
||||||
|
interface UpdateConfig {
|
||||||
|
version: string;
|
||||||
|
updateTime: string;
|
||||||
|
files: Array<{
|
||||||
|
sourcePath: string;
|
||||||
|
targetPath: string;
|
||||||
|
backupPath?: string;
|
||||||
|
}>;
|
||||||
|
changelog?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 需要跳过更新的文件
|
||||||
|
const SKIP_UPDATE_FILES = [
|
||||||
|
'NapCatWinBootMain.exe',
|
||||||
|
'NapCatWinBootHook.dll'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归扫描目录中的所有文件
|
||||||
|
*/
|
||||||
|
function scanFilesRecursively (dirPath: string, basePath: string = dirPath): Array<{
|
||||||
|
sourcePath: string;
|
||||||
|
relativePath: string;
|
||||||
|
}> {
|
||||||
|
const files: Array<{
|
||||||
|
sourcePath: string;
|
||||||
|
relativePath: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
const items = fs.readdirSync(dirPath);
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const fullPath = path.join(dirPath, item);
|
||||||
|
const relativePath = path.relative(basePath, fullPath);
|
||||||
|
const stat = fs.statSync(fullPath);
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
// 递归扫描子目录
|
||||||
|
files.push(...scanFilesRecursively(fullPath, basePath));
|
||||||
|
} else if (stat.isFile()) {
|
||||||
|
files.push({
|
||||||
|
sourcePath: fullPath,
|
||||||
|
relativePath: relativePath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 镜像源列表(参考ffmpeg下载实现)
|
||||||
|
const mirrorUrls = [
|
||||||
|
'https://j.1win.ggff.net/',
|
||||||
|
'https://git.yylx.win/',
|
||||||
|
'https://ghfile.geekertao.top/',
|
||||||
|
'https://gh-proxy.net/',
|
||||||
|
'https://ghm.078465.xyz/',
|
||||||
|
'https://gitproxy.127731.xyz/',
|
||||||
|
'https://jiashu.1win.eu.org/',
|
||||||
|
'', // 原始URL
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试URL是否可用
|
||||||
|
*/
|
||||||
|
async function testUrl (url: string): Promise<boolean> {
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
const req = https.get(url, { timeout: 5000 }, (res) => {
|
||||||
|
const statusCode = res.statusCode || 0;
|
||||||
|
if (statusCode >= 200 && statusCode < 300) {
|
||||||
|
req.destroy();
|
||||||
|
resolve(true);
|
||||||
|
} else {
|
||||||
|
req.destroy();
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', () => resolve(false));
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建镜像URL
|
||||||
|
*/
|
||||||
|
function buildMirrorUrl (originalUrl: string, mirror: string): string {
|
||||||
|
if (!mirror) return originalUrl;
|
||||||
|
return mirror + originalUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找可用的下载URL
|
||||||
|
*/
|
||||||
|
async function findAvailableUrl (originalUrl: string): Promise<string> {
|
||||||
|
console.log('Testing download URLs...');
|
||||||
|
|
||||||
|
// 先测试原始URL
|
||||||
|
if (await testUrl(originalUrl)) {
|
||||||
|
console.log('Using original URL:', originalUrl);
|
||||||
|
return originalUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试镜像源
|
||||||
|
for (const mirror of mirrorUrls) {
|
||||||
|
const mirrorUrl = buildMirrorUrl(originalUrl, mirror);
|
||||||
|
console.log('Testing mirror:', mirrorUrl);
|
||||||
|
if (await testUrl(mirrorUrl)) {
|
||||||
|
console.log('Using mirror URL:', mirrorUrl);
|
||||||
|
return mirrorUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('所有下载源都不可用');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载文件(带进度和重试)
|
||||||
|
*/
|
||||||
|
async function downloadFile (url: string, dest: string): Promise<void> {
|
||||||
|
console.log('Starting download from:', url);
|
||||||
|
const file = fs.createWriteStream(dest);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = https.get(url, {
|
||||||
|
headers: { 'User-Agent': 'NapCat-WebUI' }
|
||||||
|
}, (res) => {
|
||||||
|
console.log('Response status:', res.statusCode);
|
||||||
|
console.log('Content-Type:', res.headers['content-type']);
|
||||||
|
|
||||||
|
if (res.statusCode === 302 || res.statusCode === 301) {
|
||||||
|
console.log('Following redirect to:', res.headers.location);
|
||||||
|
file.close();
|
||||||
|
fs.unlinkSync(dest);
|
||||||
|
downloadFile(res.headers.location!, dest).then(resolve).catch(reject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
file.close();
|
||||||
|
fs.unlinkSync(dest);
|
||||||
|
reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.pipe(file);
|
||||||
|
file.on('finish', () => {
|
||||||
|
file.close();
|
||||||
|
console.log('Download completed');
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
request.on('error', (err) => {
|
||||||
|
console.error('Download error:', err);
|
||||||
|
file.close();
|
||||||
|
fs.unlink(dest, () => { });
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UpdateNapCatHandler: RequestHandler = async (_req, res) => {
|
||||||
|
try {
|
||||||
|
// 获取最新release信息
|
||||||
|
const latestRelease = await getLatestRelease() as Release;
|
||||||
|
const ReleaseName = WebUiDataRuntime.getWorkingEnv() === NapCatCoreWorkingEnv.Framework ? 'NapCat.Framework.zip' : 'NapCat.Shell.zip';
|
||||||
|
const shellZipAsset = latestRelease.assets.find(asset => asset.name === ReleaseName);
|
||||||
|
if (!shellZipAsset) {
|
||||||
|
throw new Error(`未找到${ReleaseName}文件`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建临时目录
|
||||||
|
const tempDir = path.join(webUiPathWrapper.binaryPath, './temp');
|
||||||
|
if (!fs.existsSync(tempDir)) {
|
||||||
|
fs.mkdirSync(tempDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找可用的下载URL
|
||||||
|
const downloadUrl = await findAvailableUrl(shellZipAsset.browser_download_url);
|
||||||
|
|
||||||
|
// 下载zip
|
||||||
|
const zipPath = path.join(tempDir, 'napcat-latest.zip');
|
||||||
|
console.log('[NapCat Update] Saving to:', zipPath);
|
||||||
|
await downloadFile(downloadUrl, zipPath);
|
||||||
|
|
||||||
|
// 检查文件大小
|
||||||
|
const stats = fs.statSync(zipPath);
|
||||||
|
console.log('[NapCat Update] Downloaded file size:', stats.size, 'bytes');
|
||||||
|
|
||||||
|
// 解压到临时目录
|
||||||
|
const extractPath = path.join(tempDir, 'napcat-extract');
|
||||||
|
console.log('[NapCat Update] Extracting to:', extractPath);
|
||||||
|
await compressing.zip.uncompress(zipPath, extractPath);
|
||||||
|
|
||||||
|
// 获取解压后的实际内容目录(NapCat.Shell.zip直接包含文件,无额外根目录)
|
||||||
|
const sourcePath = extractPath;
|
||||||
|
|
||||||
|
// 执行更新操作
|
||||||
|
try {
|
||||||
|
// 扫描需要更新的文件
|
||||||
|
const allFiles = scanFilesRecursively(sourcePath);
|
||||||
|
const failedFiles: Array<{
|
||||||
|
sourcePath: string;
|
||||||
|
targetPath: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// 先尝试直接替换文件
|
||||||
|
for (const fileInfo of allFiles) {
|
||||||
|
const targetFilePath = path.join(webUiPathWrapper.binaryPath, fileInfo.relativePath);
|
||||||
|
|
||||||
|
// 跳过指定的文件
|
||||||
|
if (SKIP_UPDATE_FILES.includes(path.basename(fileInfo.relativePath))) {
|
||||||
|
console.log(`[NapCat Update] Skipping update for ${fileInfo.relativePath}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 确保目标目录存在
|
||||||
|
const targetDir = path.dirname(targetFilePath);
|
||||||
|
if (!fs.existsSync(targetDir)) {
|
||||||
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试直接替换文件
|
||||||
|
if (fs.existsSync(targetFilePath)) {
|
||||||
|
fs.unlinkSync(targetFilePath); // 删除旧文件
|
||||||
|
}
|
||||||
|
fs.copyFileSync(fileInfo.sourcePath, targetFilePath);
|
||||||
|
} catch (error) {
|
||||||
|
// 如果替换失败,添加到失败列表
|
||||||
|
console.log(`[NapCat Update] Failed to update ${targetFilePath}, will retry on next startup:`, error);
|
||||||
|
failedFiles.push({
|
||||||
|
sourcePath: fileInfo.sourcePath,
|
||||||
|
targetPath: targetFilePath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有替换失败的文件,创建更新配置文件
|
||||||
|
if (failedFiles.length > 0) {
|
||||||
|
const updateConfig: UpdateConfig = {
|
||||||
|
version: latestRelease.tag_name,
|
||||||
|
updateTime: new Date().toISOString(),
|
||||||
|
files: failedFiles,
|
||||||
|
changelog: latestRelease.body || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存更新配置文件
|
||||||
|
const configPath = path.join(webUiPathWrapper.configPath, 'napcat-update.json');
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify(updateConfig, null, 2));
|
||||||
|
console.log(`[NapCat Update] Update config saved for ${failedFiles.length} failed files: ${configPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送成功响应
|
||||||
|
const message = failedFiles.length > 0
|
||||||
|
? `更新完成,重启应用以应用剩余${failedFiles.length}个文件的更新`
|
||||||
|
: '更新完成';
|
||||||
|
sendSuccess(res, {
|
||||||
|
status: 'completed',
|
||||||
|
message,
|
||||||
|
newVersion: latestRelease.tag_name,
|
||||||
|
failedFilesCount: failedFiles.length
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新失败:', error);
|
||||||
|
sendError(res, '更新失败: ' + (error instanceof Error ? error.message : '未知错误'));
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('更新失败:', error);
|
||||||
|
sendError(res, '更新失败: ' + error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getLatestRelease (): Promise<Release> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
https.get('https://api.github.com/repos/NapNeko/NapCatQQ/releases/latest', {
|
||||||
|
headers: { 'User-Agent': 'NapCat-WebUI' }
|
||||||
|
}, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', chunk => data += chunk);
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const release = JSON.parse(data) as Release;
|
||||||
|
console.log('Release info:', {
|
||||||
|
tag_name: release.tag_name,
|
||||||
|
assets: release.assets?.map(a => ({ name: a.name, url: a.browser_download_url }))
|
||||||
|
});
|
||||||
|
resolve(release);
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用待处理的更新(在应用启动时调用)
|
||||||
|
*/
|
||||||
|
export async function applyPendingUpdates (webUiPathWrapper: NapCatPathWrapper): Promise<void> {
|
||||||
|
const configPath = path.join(webUiPathWrapper.configPath, 'napcat-update.json');
|
||||||
|
|
||||||
|
if (!fs.existsSync(configPath)) {
|
||||||
|
console.log('No pending updates found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[NapCat Update] Applying pending updates...');
|
||||||
|
const updateConfig: UpdateConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||||
|
|
||||||
|
const remainingFiles: Array<{
|
||||||
|
sourcePath: string;
|
||||||
|
targetPath: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const file of updateConfig.files) {
|
||||||
|
try {
|
||||||
|
// 检查源文件是否存在
|
||||||
|
if (!fs.existsSync(file.sourcePath)) {
|
||||||
|
console.warn(`[NapCat Update] Source file not found: ${file.sourcePath}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保目标目录存在
|
||||||
|
const targetDir = path.dirname(file.targetPath);
|
||||||
|
if (!fs.existsSync(targetDir)) {
|
||||||
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试替换文件
|
||||||
|
if (fs.existsSync(file.targetPath)) {
|
||||||
|
fs.unlinkSync(file.targetPath); // 删除旧文件
|
||||||
|
}
|
||||||
|
fs.copyFileSync(file.sourcePath, file.targetPath);
|
||||||
|
console.log(`[NapCat Update] Updated ${path.basename(file.targetPath)} on startup`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[NapCat Update] Failed to update ${file.targetPath} on startup:`, error);
|
||||||
|
// 如果仍然失败,保留在列表中
|
||||||
|
remainingFiles.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果还有失败的文件,更新配置文件
|
||||||
|
if (remainingFiles.length > 0) {
|
||||||
|
const updatedConfig: UpdateConfig = {
|
||||||
|
...updateConfig,
|
||||||
|
files: remainingFiles
|
||||||
|
};
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2));
|
||||||
|
console.log(`${remainingFiles.length} files still pending update`);
|
||||||
|
} else {
|
||||||
|
// 所有文件都成功更新,删除配置文件
|
||||||
|
fs.unlinkSync(configPath);
|
||||||
|
console.log('[NapCat Update] All pending updates applied successfully');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[NapCat Update] Failed to apply pending updates:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,9 @@
|
|||||||
import store from 'napcat-common/src/store';
|
import store from 'napcat-common/src/store';
|
||||||
import { napCatVersion } from 'napcat-common/src/version';
|
import { napCatVersion } from 'napcat-common/src/version';
|
||||||
import type { LoginRuntimeType } from '../types';
|
import { NapCatCoreWorkingEnv, type LoginRuntimeType } from '../types';
|
||||||
|
|
||||||
const LoginRuntime: LoginRuntimeType = {
|
const LoginRuntime: LoginRuntimeType = {
|
||||||
|
workingEnv: NapCatCoreWorkingEnv.Unknown,
|
||||||
LoginCurrentTime: Date.now(),
|
LoginCurrentTime: Date.now(),
|
||||||
LoginCurrentRate: 0,
|
LoginCurrentRate: 0,
|
||||||
QQLoginStatus: false, // 已实现 但太傻了 得去那边注册个回调刷新
|
QQLoginStatus: false, // 已实现 但太傻了 得去那边注册个回调刷新
|
||||||
@ -36,6 +37,12 @@ const LoginRuntime: LoginRuntimeType = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
export const WebUiDataRuntime = {
|
export const WebUiDataRuntime = {
|
||||||
|
setWorkingEnv (env: NapCatCoreWorkingEnv): void {
|
||||||
|
LoginRuntime.workingEnv = env;
|
||||||
|
},
|
||||||
|
getWorkingEnv (): NapCatCoreWorkingEnv {
|
||||||
|
return LoginRuntime.workingEnv;
|
||||||
|
},
|
||||||
setWebUiTokenChangeCallback (func: (token: string) => Promise<void>): void {
|
setWebUiTokenChangeCallback (func: (token: string) => Promise<void>): void {
|
||||||
LoginRuntime.onWebUiTokenChange = func;
|
LoginRuntime.onWebUiTokenChange = func;
|
||||||
},
|
},
|
||||||
|
|||||||
206
packages/napcat-webui-backend/src/helper/PasskeyHelper.ts
Normal file
206
packages/napcat-webui-backend/src/helper/PasskeyHelper.ts
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
import {
|
||||||
|
generateRegistrationOptions,
|
||||||
|
verifyRegistrationResponse,
|
||||||
|
generateAuthenticationOptions,
|
||||||
|
verifyAuthenticationResponse,
|
||||||
|
type AuthenticatorTransportFuture,
|
||||||
|
} from '@simplewebauthn/server';
|
||||||
|
import { isoBase64URL } from '@simplewebauthn/server/helpers';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { webUiPathWrapper } from '../../index';
|
||||||
|
|
||||||
|
interface PasskeyCredential {
|
||||||
|
id: string;
|
||||||
|
publicKey: string;
|
||||||
|
counter: number;
|
||||||
|
transports?: AuthenticatorTransportFuture[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const RP_NAME = 'NapCat WebUI';
|
||||||
|
|
||||||
|
export class PasskeyHelper {
|
||||||
|
private static getPasskeyFilePath (): string {
|
||||||
|
return path.join(webUiPathWrapper.configPath, 'passkey.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内存中存储临时挑战数据
|
||||||
|
private static challenges: Map<string, string> = new Map();
|
||||||
|
private static async ensurePasskeyFile (): Promise<void> {
|
||||||
|
try {
|
||||||
|
// 确保配置文件目录存在
|
||||||
|
const passkeyFile = this.getPasskeyFilePath();
|
||||||
|
await fs.mkdir(path.dirname(passkeyFile), { recursive: true });
|
||||||
|
// 检查文件是否存在,如果不存在创建空文件
|
||||||
|
try {
|
||||||
|
await fs.access(passkeyFile);
|
||||||
|
} catch {
|
||||||
|
await fs.writeFile(passkeyFile, JSON.stringify({}, null, 2));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Directory or file already exists or other error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getAllPasskeys (): Promise<Record<string, PasskeyCredential[]>> {
|
||||||
|
await this.ensurePasskeyFile();
|
||||||
|
try {
|
||||||
|
const passkeyFile = this.getPasskeyFilePath();
|
||||||
|
const data = await fs.readFile(passkeyFile, 'utf-8');
|
||||||
|
const passkeys = JSON.parse(data);
|
||||||
|
return typeof passkeys === 'object' && passkeys !== null ? passkeys : {};
|
||||||
|
} catch (error) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async saveAllPasskeys (allPasskeys: Record<string, PasskeyCredential[]>): Promise<void> {
|
||||||
|
await this.ensurePasskeyFile();
|
||||||
|
const passkeyFile = this.getPasskeyFilePath();
|
||||||
|
await fs.writeFile(passkeyFile, JSON.stringify(allPasskeys, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getUserPasskeys (userId: string): Promise<PasskeyCredential[]> {
|
||||||
|
const allPasskeys = await this.getAllPasskeys();
|
||||||
|
return allPasskeys[userId] || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 持久性存储用户的passkey到统一配置文件
|
||||||
|
private static async setUserPasskeys (userId: string, passkeys: PasskeyCredential[]): Promise<void> {
|
||||||
|
const allPasskeys = await this.getAllPasskeys();
|
||||||
|
if (passkeys.length > 0) {
|
||||||
|
allPasskeys[userId] = passkeys;
|
||||||
|
} else {
|
||||||
|
delete allPasskeys[userId];
|
||||||
|
}
|
||||||
|
await this.saveAllPasskeys(allPasskeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async generateRegistrationOptions (userId: string, userName: string, rpId: string) {
|
||||||
|
const userPasskeys = await this.getUserPasskeys(userId);
|
||||||
|
|
||||||
|
const options = await generateRegistrationOptions({
|
||||||
|
rpName: RP_NAME,
|
||||||
|
rpID: rpId,
|
||||||
|
userID: new TextEncoder().encode(userId),
|
||||||
|
userName: userName,
|
||||||
|
attestationType: 'none',
|
||||||
|
excludeCredentials: userPasskeys.map(passkey => ({
|
||||||
|
id: passkey.id,
|
||||||
|
type: 'public-key' as const,
|
||||||
|
transports: passkey.transports,
|
||||||
|
})),
|
||||||
|
// Temporarily simplify authenticatorSelection - remove residentKey to avoid conflicts
|
||||||
|
authenticatorSelection: {
|
||||||
|
userVerification: 'preferred',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store challenge temporarily in memory
|
||||||
|
this.challenges.set(`reg_${userId}`, options.challenge);
|
||||||
|
// Auto cleanup after 5 minutes
|
||||||
|
setTimeout(() => {
|
||||||
|
this.challenges.delete(`reg_${userId}`);
|
||||||
|
}, 300000);
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async verifyRegistration (userId: string, response: any, origin: string, rpId: string) {
|
||||||
|
const expectedChallenge = this.challenges.get(`reg_${userId}`);
|
||||||
|
if (!expectedChallenge) {
|
||||||
|
throw new Error('Challenge not found or expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
const verification = await verifyRegistrationResponse({
|
||||||
|
response,
|
||||||
|
expectedChallenge,
|
||||||
|
expectedOrigin: origin,
|
||||||
|
expectedRPID: rpId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (verification.verified && verification.registrationInfo) {
|
||||||
|
const { registrationInfo } = verification;
|
||||||
|
|
||||||
|
const newPasskey: PasskeyCredential = {
|
||||||
|
id: registrationInfo.credential.id,
|
||||||
|
publicKey: isoBase64URL.fromBuffer(registrationInfo.credential.publicKey),
|
||||||
|
counter: registrationInfo.credential.counter || 0,
|
||||||
|
transports: response.response.transports,
|
||||||
|
};
|
||||||
|
|
||||||
|
const userPasskeys = await this.getUserPasskeys(userId);
|
||||||
|
userPasskeys.push(newPasskey);
|
||||||
|
await this.setUserPasskeys(userId, userPasskeys);
|
||||||
|
|
||||||
|
// Clean up challenge
|
||||||
|
this.challenges.delete(`reg_${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return verification;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async generateAuthenticationOptions (userId: string, rpId: string) {
|
||||||
|
const userPasskeys = await this.getUserPasskeys(userId);
|
||||||
|
|
||||||
|
const options = await generateAuthenticationOptions({
|
||||||
|
rpID: rpId,
|
||||||
|
allowCredentials: userPasskeys.map(passkey => ({
|
||||||
|
id: passkey.id,
|
||||||
|
type: 'public-key' as const,
|
||||||
|
transports: passkey.transports,
|
||||||
|
})),
|
||||||
|
userVerification: 'preferred',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store challenge temporarily in memory
|
||||||
|
this.challenges.set(`auth_${userId}`, options.challenge);
|
||||||
|
// Auto cleanup after 5 minutes
|
||||||
|
setTimeout(() => {
|
||||||
|
this.challenges.delete(`auth_${userId}`);
|
||||||
|
}, 300000);
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async verifyAuthentication (userId: string, response: any, origin: string, rpId: string) {
|
||||||
|
const expectedChallenge = this.challenges.get(`auth_${userId}`);
|
||||||
|
if (!expectedChallenge) {
|
||||||
|
throw new Error('Challenge not found or expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userPasskeys = await this.getUserPasskeys(userId);
|
||||||
|
const passkey = userPasskeys.find(p => p.id === response.id);
|
||||||
|
if (!passkey) {
|
||||||
|
throw new Error('Passkey not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const verification = await verifyAuthenticationResponse({
|
||||||
|
response,
|
||||||
|
expectedChallenge,
|
||||||
|
expectedOrigin: origin,
|
||||||
|
expectedRPID: rpId,
|
||||||
|
credential: {
|
||||||
|
id: passkey.id,
|
||||||
|
publicKey: isoBase64URL.toBuffer(passkey.publicKey),
|
||||||
|
counter: passkey.counter,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (verification.verified && verification.authenticationInfo) {
|
||||||
|
// Update counter
|
||||||
|
passkey.counter = verification.authenticationInfo.newCounter;
|
||||||
|
await this.setUserPasskeys(userId, userPasskeys);
|
||||||
|
|
||||||
|
// Clean up challenge
|
||||||
|
this.challenges.delete(`auth_${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return verification;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async hasPasskeys (userId: string): Promise<boolean> {
|
||||||
|
const userPasskeys = await this.getUserPasskeys(userId);
|
||||||
|
return userPasskeys.length > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,6 +12,12 @@ export async function auth (req: Request, res: Response, next: NextFunction) {
|
|||||||
if (req.url === '/auth/login') {
|
if (req.url === '/auth/login') {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
if (req.url === '/auth/passkey/generate-authentication-options' ||
|
||||||
|
req.url === '/auth/passkey/verify-authentication') {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 判断是否有Authorization头
|
// 判断是否有Authorization头
|
||||||
if (req.headers?.authorization) {
|
if (req.headers?.authorization) {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { GetThemeConfigHandler, GetNapCatVersion, QQVersionHandler, SetThemeConfigHandler } from '../api/BaseInfo';
|
import { GetThemeConfigHandler, GetNapCatVersion, QQVersionHandler, SetThemeConfigHandler, getLatestTagHandler } from '../api/BaseInfo';
|
||||||
import { StatusRealTimeHandler } from '@/napcat-webui-backend/src/api/Status';
|
import { StatusRealTimeHandler } from '@/napcat-webui-backend/src/api/Status';
|
||||||
import { GetProxyHandler } from '../api/Proxy';
|
import { GetProxyHandler } from '../api/Proxy';
|
||||||
|
|
||||||
@ -7,6 +7,7 @@ const router = Router();
|
|||||||
// router: 获取nc的package.json信息
|
// router: 获取nc的package.json信息
|
||||||
router.get('/QQVersion', QQVersionHandler);
|
router.get('/QQVersion', QQVersionHandler);
|
||||||
router.get('/GetNapCatVersion', GetNapCatVersion);
|
router.get('/GetNapCatVersion', GetNapCatVersion);
|
||||||
|
router.get('/getLatestTag', getLatestTagHandler);
|
||||||
router.get('/GetSysStatusRealTime', StatusRealTimeHandler);
|
router.get('/GetSysStatusRealTime', StatusRealTimeHandler);
|
||||||
router.get('/proxy', GetProxyHandler);
|
router.get('/proxy', GetProxyHandler);
|
||||||
router.get('/Theme', GetThemeConfigHandler);
|
router.get('/Theme', GetThemeConfigHandler);
|
||||||
|
|||||||
13
packages/napcat-webui-backend/src/router/UpdateNapCat.ts
Normal file
13
packages/napcat-webui-backend/src/router/UpdateNapCat.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* @file UpdateNapCat路由
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { UpdateNapCatHandler } from '@/napcat-webui-backend/src/api/UpdateNapCat';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// POST /api/UpdateNapCat/update - 更新NapCat
|
||||||
|
router.post('/update', UpdateNapCatHandler);
|
||||||
|
|
||||||
|
export { router as UpdateNapCatRouter };
|
||||||
@ -5,6 +5,10 @@ import {
|
|||||||
LoginHandler,
|
LoginHandler,
|
||||||
LogoutHandler,
|
LogoutHandler,
|
||||||
UpdateTokenHandler,
|
UpdateTokenHandler,
|
||||||
|
GeneratePasskeyRegistrationOptionsHandler,
|
||||||
|
VerifyPasskeyRegistrationHandler,
|
||||||
|
GeneratePasskeyAuthenticationOptionsHandler,
|
||||||
|
VerifyPasskeyAuthenticationHandler,
|
||||||
} from '@/napcat-webui-backend/src/api/Auth';
|
} from '@/napcat-webui-backend/src/api/Auth';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@ -16,5 +20,13 @@ router.post('/check', checkHandler);
|
|||||||
router.post('/logout', LogoutHandler);
|
router.post('/logout', LogoutHandler);
|
||||||
// router:更新token
|
// router:更新token
|
||||||
router.post('/update_token', UpdateTokenHandler);
|
router.post('/update_token', UpdateTokenHandler);
|
||||||
|
// router:生成Passkey注册选项
|
||||||
|
router.post('/passkey/generate-registration-options', GeneratePasskeyRegistrationOptionsHandler);
|
||||||
|
// router:验证Passkey注册
|
||||||
|
router.post('/passkey/verify-registration', VerifyPasskeyRegistrationHandler);
|
||||||
|
// router:生成Passkey认证选项
|
||||||
|
router.post('/passkey/generate-authentication-options', GeneratePasskeyAuthenticationOptionsHandler);
|
||||||
|
// router:验证Passkey认证
|
||||||
|
router.post('/passkey/verify-authentication', VerifyPasskeyAuthenticationHandler);
|
||||||
|
|
||||||
export { router as AuthRouter };
|
export { router as AuthRouter };
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { LogRouter } from '@/napcat-webui-backend/src/router/Log';
|
|||||||
import { BaseRouter } from '@/napcat-webui-backend/src/router/Base';
|
import { BaseRouter } from '@/napcat-webui-backend/src/router/Base';
|
||||||
import { FileRouter } from './File';
|
import { FileRouter } from './File';
|
||||||
import { WebUIConfigRouter } from './WebUIConfig';
|
import { WebUIConfigRouter } from './WebUIConfig';
|
||||||
|
import { UpdateNapCatRouter } from './UpdateNapCat';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@ -38,5 +39,7 @@ router.use('/Log', LogRouter);
|
|||||||
router.use('/File', FileRouter);
|
router.use('/File', FileRouter);
|
||||||
// router:WebUI配置相关路由
|
// router:WebUI配置相关路由
|
||||||
router.use('/WebUIConfig', WebUIConfigRouter);
|
router.use('/WebUIConfig', WebUIConfigRouter);
|
||||||
|
// router:更新NapCat相关路由
|
||||||
|
router.use('/UpdateNapCat', UpdateNapCatRouter);
|
||||||
|
|
||||||
export { router as ALLRouter };
|
export { router as ALLRouter };
|
||||||
|
|||||||
@ -30,8 +30,13 @@ export interface WebUiCredentialJson {
|
|||||||
Data: WebUiCredentialInnerJson;
|
Data: WebUiCredentialInnerJson;
|
||||||
Hmac: string;
|
Hmac: string;
|
||||||
}
|
}
|
||||||
|
export enum NapCatCoreWorkingEnv {
|
||||||
|
Unknown = 0,
|
||||||
|
Shell = 1,
|
||||||
|
Framework = 2,
|
||||||
|
}
|
||||||
export interface LoginRuntimeType {
|
export interface LoginRuntimeType {
|
||||||
|
workingEnv: NapCatCoreWorkingEnv;
|
||||||
LoginCurrentTime: number;
|
LoginCurrentTime: number;
|
||||||
LoginCurrentRate: number;
|
LoginCurrentRate: number;
|
||||||
QQLoginStatus: boolean;
|
QQLoginStatus: boolean;
|
||||||
|
|||||||
2
packages/napcat-webui-frontend/.gitignore
vendored
2
packages/napcat-webui-frontend/.gitignore
vendored
@ -26,7 +26,5 @@ dist-ssr
|
|||||||
# NPM LOCK files
|
# NPM LOCK files
|
||||||
package-lock.json
|
package-lock.json
|
||||||
yarn.lock
|
yarn.lock
|
||||||
pnpm-lock.yaml
|
|
||||||
|
|
||||||
|
|
||||||
dist.zip
|
dist.zip
|
||||||
@ -48,6 +48,7 @@
|
|||||||
"@monaco-editor/react": "4.7.0-rc.0",
|
"@monaco-editor/react": "4.7.0-rc.0",
|
||||||
"@react-aria/visually-hidden": "^3.8.19",
|
"@react-aria/visually-hidden": "^3.8.19",
|
||||||
"@reduxjs/toolkit": "^2.5.1",
|
"@reduxjs/toolkit": "^2.5.1",
|
||||||
|
"@simplewebauthn/browser": "^13.2.2",
|
||||||
"@uidotdev/usehooks": "^2.4.1",
|
"@uidotdev/usehooks": "^2.4.1",
|
||||||
"@xterm/addon-canvas": "^0.7.0",
|
"@xterm/addon-canvas": "^0.7.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Button } from '@heroui/button';
|
import { Button } from '@heroui/button';
|
||||||
import { Image } from '@heroui/image';
|
import { Image } from '@heroui/image';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { motion } from 'motion/react';
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { IoMdLogOut } from 'react-icons/io';
|
import { IoMdLogOut } from 'react-icons/io';
|
||||||
import { MdDarkMode, MdLightMode } from 'react-icons/md';
|
import { MdDarkMode, MdLightMode } from 'react-icons/md';
|
||||||
@ -18,10 +18,11 @@ import Menus from './menus';
|
|||||||
interface SideBarProps {
|
interface SideBarProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
items: MenuItem[]
|
items: MenuItem[]
|
||||||
|
onClose?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const SideBar: React.FC<SideBarProps> = (props) => {
|
const SideBar: React.FC<SideBarProps> = (props) => {
|
||||||
const { open, items } = props;
|
const { open, items, onClose } = props;
|
||||||
const { toggleTheme, isDark } = useTheme();
|
const { toggleTheme, isDark } = useTheme();
|
||||||
const { revokeAuth } = useAuth();
|
const { revokeAuth } = useAuth();
|
||||||
const dialog = useDialog();
|
const dialog = useDialog();
|
||||||
@ -33,6 +34,20 @@ const SideBar: React.FC<SideBarProps> = (props) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
className='fixed inset-y-0 left-64 right-0 bg-black/20 backdrop-blur-[1px] z-40 md:hidden'
|
||||||
|
aria-hidden='true'
|
||||||
|
onClick={onClose}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0, transition: { duration: 0.15 } }}
|
||||||
|
transition={{ duration: 0.2, delay: 0.15 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
<motion.div
|
<motion.div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'overflow-hidden fixed top-0 left-0 h-full z-50 bg-background md:bg-transparent md:static shadow-md md:shadow-none rounded-r-md md:rounded-none'
|
'overflow-hidden fixed top-0 left-0 h-full z-50 bg-background md:bg-transparent md:static shadow-md md:shadow-none rounded-r-md md:rounded-none'
|
||||||
@ -87,6 +102,7 @@ const SideBar: React.FC<SideBarProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,30 +1,26 @@
|
|||||||
import { Button } from '@heroui/button';
|
|
||||||
import { Card, CardBody, CardHeader } from '@heroui/card';
|
import { Card, CardBody, CardHeader } from '@heroui/card';
|
||||||
|
import { Button } from '@heroui/button';
|
||||||
import { Chip } from '@heroui/chip';
|
import { Chip } from '@heroui/chip';
|
||||||
import { Spinner } from '@heroui/spinner';
|
import { Spinner } from '@heroui/spinner';
|
||||||
import { Tooltip } from '@heroui/tooltip';
|
import { Tooltip } from '@heroui/tooltip';
|
||||||
import { useRequest } from 'ahooks';
|
import { useRequest } from 'ahooks';
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { BsStars } from 'react-icons/bs';
|
|
||||||
import { FaCircleInfo, FaInfo, FaQq } from 'react-icons/fa6';
|
import { FaCircleInfo, FaInfo, FaQq } from 'react-icons/fa6';
|
||||||
import { IoLogoChrome, IoLogoOctocat } from 'react-icons/io';
|
import { IoLogoChrome, IoLogoOctocat } from 'react-icons/io';
|
||||||
import { RiMacFill } from 'react-icons/ri';
|
import { RiMacFill } from 'react-icons/ri';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
import useDialog from '@/hooks/use-dialog';
|
|
||||||
|
|
||||||
import { request } from '@/utils/request';
|
|
||||||
import { compareVersion } from '@/utils/version';
|
|
||||||
|
|
||||||
import WebUIManager from '@/controllers/webui_manager';
|
import WebUIManager from '@/controllers/webui_manager';
|
||||||
import { GithubRelease } from '@/types/github';
|
import useDialog from '@/hooks/use-dialog';
|
||||||
|
|
||||||
import TailwindMarkdown from './tailwind_markdown';
|
|
||||||
|
|
||||||
export interface SystemInfoItemProps {
|
export interface SystemInfoItemProps {
|
||||||
title: string
|
title: string;
|
||||||
icon?: React.ReactNode
|
icon?: React.ReactNode;
|
||||||
value?: React.ReactNode
|
value?: React.ReactNode;
|
||||||
endContent?: React.ReactNode
|
endContent?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
|
const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
|
||||||
@ -44,97 +40,168 @@ const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface NewVersionTipProps {
|
export interface NewVersionTipProps {
|
||||||
currentVersion?: string
|
currentVersion?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// const NewVersionTip = (props: NewVersionTipProps) => {
|
||||||
|
// const { currentVersion } = props;
|
||||||
|
// const dialog = useDialog();
|
||||||
|
// const { data: releaseData, error } = useRequest(() =>
|
||||||
|
// request.get<GithubRelease[]>(
|
||||||
|
// 'https://api.github.com/repos/NapNeko/NapCatQQ/releases'
|
||||||
|
// )
|
||||||
|
// );
|
||||||
|
|
||||||
|
// if (error) {
|
||||||
|
// return (
|
||||||
|
// <Tooltip content='检查新版本失败'>
|
||||||
|
// <Button
|
||||||
|
// isIconOnly
|
||||||
|
// radius='full'
|
||||||
|
// color='primary'
|
||||||
|
// variant='shadow'
|
||||||
|
// className='!w-5 !h-5 !min-w-0 text-small shadow-md'
|
||||||
|
// onPress={() => {
|
||||||
|
// dialog.alert({
|
||||||
|
// title: '检查新版本失败',
|
||||||
|
// content: error.message,
|
||||||
|
// });
|
||||||
|
// }}
|
||||||
|
// >
|
||||||
|
// <FaInfo />
|
||||||
|
// </Button>
|
||||||
|
// </Tooltip>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const latestVersion = releaseData?.data?.[0]?.tag_name;
|
||||||
|
|
||||||
|
// if (!latestVersion || !currentVersion) {
|
||||||
|
// return null;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (compareVersion(latestVersion, currentVersion) <= 0) {
|
||||||
|
// return null;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const middleVersions: GithubRelease[] = [];
|
||||||
|
|
||||||
|
// for (let i = 0; i < releaseData.data.length; i++) {
|
||||||
|
// const versionInfo = releaseData.data[i];
|
||||||
|
// if (compareVersion(versionInfo.tag_name, currentVersion) > 0) {
|
||||||
|
// middleVersions.push(versionInfo);
|
||||||
|
// } else {
|
||||||
|
// break;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const AISummaryComponent = () => {
|
||||||
|
// const {
|
||||||
|
// data: aiSummaryData,
|
||||||
|
// loading: aiSummaryLoading,
|
||||||
|
// error: aiSummaryError,
|
||||||
|
// run: runAiSummary,
|
||||||
|
// } = useRequest(
|
||||||
|
// (version) =>
|
||||||
|
// request.get<ServerResponse<string | null>>(
|
||||||
|
// `https://release.nc.152710.xyz/?version=${version}`,
|
||||||
|
// {
|
||||||
|
// timeout: 30000,
|
||||||
|
// }
|
||||||
|
// ),
|
||||||
|
// {
|
||||||
|
// manual: true,
|
||||||
|
// }
|
||||||
|
// );
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// runAiSummary(currentVersion);
|
||||||
|
// }, [currentVersion, runAiSummary]);
|
||||||
|
|
||||||
|
// if (aiSummaryLoading) {
|
||||||
|
// return (
|
||||||
|
// <div className='flex justify-center py-1'>
|
||||||
|
// <Spinner size='sm' />
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
// if (aiSummaryError) {
|
||||||
|
// return <div className='text-center text-primary-500'>AI 摘要获取失败</div>;
|
||||||
|
// }
|
||||||
|
// return <span className='text-default-700'>{aiSummaryData?.data.data}</span>;
|
||||||
|
// };
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <Tooltip content='有新版本可用'>
|
||||||
|
// <Button
|
||||||
|
// isIconOnly
|
||||||
|
// radius='full'
|
||||||
|
// color='primary'
|
||||||
|
// variant='shadow'
|
||||||
|
// className='!w-5 !h-5 !min-w-0 text-small shadow-md'
|
||||||
|
// onPress={() => {
|
||||||
|
// dialog.confirm({
|
||||||
|
// title: '有新版本可用',
|
||||||
|
// content: (
|
||||||
|
// <div className='space-y-2'>
|
||||||
|
// <div className='text-sm space-x-2'>
|
||||||
|
// <span>当前版本</span>
|
||||||
|
// <Chip color='primary' variant='flat'>
|
||||||
|
// v{currentVersion}
|
||||||
|
// </Chip>
|
||||||
|
// </div>
|
||||||
|
// <div className='text-sm space-x-2'>
|
||||||
|
// <span>最新版本</span>
|
||||||
|
// <Chip color='primary'>{latestVersion}</Chip>
|
||||||
|
// </div>
|
||||||
|
// <div className='p-2 rounded-md bg-content2 text-sm'>
|
||||||
|
// <div className='text-primary-400 font-bold flex items-center gap-1 mb-1'>
|
||||||
|
// <BsStars />
|
||||||
|
// <span>AI总结</span>
|
||||||
|
// </div>
|
||||||
|
// <AISummaryComponent />
|
||||||
|
// </div>
|
||||||
|
// <div className='text-sm space-y-2 !mt-4'>
|
||||||
|
// {middleVersions.map((versionInfo) => (
|
||||||
|
// <div
|
||||||
|
// key={versionInfo.tag_name}
|
||||||
|
// className='p-4 bg-content1 rounded-md shadow-small'
|
||||||
|
// >
|
||||||
|
// <TailwindMarkdown content={versionInfo.body} />
|
||||||
|
// </div>
|
||||||
|
// ))}
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// ),
|
||||||
|
// scrollBehavior: 'inside',
|
||||||
|
// size: '3xl',
|
||||||
|
// confirmText: '前往下载',
|
||||||
|
// onConfirm () {
|
||||||
|
// window.open(
|
||||||
|
// 'https://github.com/NapNeko/NapCatQQ/releases',
|
||||||
|
// '_blank',
|
||||||
|
// 'noopener'
|
||||||
|
// );
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// }}
|
||||||
|
// >
|
||||||
|
// <FaInfo />
|
||||||
|
// </Button>
|
||||||
|
// </Tooltip>
|
||||||
|
// );
|
||||||
|
// };
|
||||||
|
|
||||||
const NewVersionTip = (props: NewVersionTipProps) => {
|
const NewVersionTip = (props: NewVersionTipProps) => {
|
||||||
const { currentVersion } = props;
|
const { currentVersion } = props;
|
||||||
const dialog = useDialog();
|
const dialog = useDialog();
|
||||||
const { data: releaseData, error } = useRequest(() =>
|
const { data: latestVersion, error } = useRequest(WebUIManager.getLatestTag);
|
||||||
request.get<GithubRelease[]>(
|
const [updating, setUpdating] = useState(false);
|
||||||
'https://api.github.com/repos/NapNeko/NapCatQQ/releases'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (error) {
|
if (error || !latestVersion || !currentVersion || latestVersion === currentVersion) {
|
||||||
return (
|
|
||||||
<Tooltip content='检查新版本失败'>
|
|
||||||
<Button
|
|
||||||
isIconOnly
|
|
||||||
radius='full'
|
|
||||||
color='primary'
|
|
||||||
variant='shadow'
|
|
||||||
className='!w-5 !h-5 !min-w-0 text-small shadow-md'
|
|
||||||
onPress={() => {
|
|
||||||
dialog.alert({
|
|
||||||
title: '检查新版本失败',
|
|
||||||
content: error.message,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FaInfo />
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const latestVersion = releaseData?.data?.[0]?.tag_name;
|
|
||||||
|
|
||||||
if (!latestVersion || !currentVersion) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (compareVersion(latestVersion, currentVersion) <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const middleVersions: GithubRelease[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < releaseData.data.length; i++) {
|
|
||||||
const versionInfo = releaseData.data[i];
|
|
||||||
if (compareVersion(versionInfo.tag_name, currentVersion) > 0) {
|
|
||||||
middleVersions.push(versionInfo);
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const AISummaryComponent = () => {
|
|
||||||
const {
|
|
||||||
data: aiSummaryData,
|
|
||||||
loading: aiSummaryLoading,
|
|
||||||
error: aiSummaryError,
|
|
||||||
run: runAiSummary,
|
|
||||||
} = useRequest(
|
|
||||||
(version) =>
|
|
||||||
request.get<ServerResponse<string | null>>(
|
|
||||||
`https://release.nc.152710.xyz/?version=${version}`,
|
|
||||||
{
|
|
||||||
timeout: 30000,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{
|
|
||||||
manual: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
runAiSummary(currentVersion);
|
|
||||||
}, [currentVersion, runAiSummary]);
|
|
||||||
|
|
||||||
if (aiSummaryLoading) {
|
|
||||||
return (
|
|
||||||
<div className='flex justify-center py-1'>
|
|
||||||
<Spinner size='sm' />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (aiSummaryError) {
|
|
||||||
return <div className='text-center text-primary-500'>AI 摘要获取失败</div>;
|
|
||||||
}
|
|
||||||
return <span className='text-default-700'>{aiSummaryData?.data.data}</span>;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip content='有新版本可用'>
|
<Tooltip content='有新版本可用'>
|
||||||
<Button
|
<Button
|
||||||
@ -156,36 +223,34 @@ const NewVersionTip = (props: NewVersionTipProps) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className='text-sm space-x-2'>
|
<div className='text-sm space-x-2'>
|
||||||
<span>最新版本</span>
|
<span>最新版本</span>
|
||||||
<Chip color='primary'>{latestVersion}</Chip>
|
<Chip color='primary'>v{latestVersion}</Chip>
|
||||||
</div>
|
</div>
|
||||||
<div className='p-2 rounded-md bg-content2 text-sm'>
|
{updating && (
|
||||||
<div className='text-primary-400 font-bold flex items-center gap-1 mb-1'>
|
<div className='flex justify-center'>
|
||||||
<BsStars />
|
<Spinner size='sm' />
|
||||||
<span>AI总结</span>
|
|
||||||
</div>
|
|
||||||
<AISummaryComponent />
|
|
||||||
</div>
|
|
||||||
<div className='text-sm space-y-2 !mt-4'>
|
|
||||||
{middleVersions.map((versionInfo) => (
|
|
||||||
<div
|
|
||||||
key={versionInfo.tag_name}
|
|
||||||
className='p-4 bg-content1 rounded-md shadow-small'
|
|
||||||
>
|
|
||||||
<TailwindMarkdown content={versionInfo.body} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
scrollBehavior: 'inside',
|
confirmText: updating ? '更新中...' : '更新',
|
||||||
size: '3xl',
|
onConfirm: async () => {
|
||||||
confirmText: '前往下载',
|
setUpdating(true);
|
||||||
onConfirm () {
|
toast('更新中,预计需要几分钟,请耐心等待', {
|
||||||
window.open(
|
duration: 3000,
|
||||||
'https://github.com/NapNeko/NapCatQQ/releases',
|
});
|
||||||
'_blank',
|
try {
|
||||||
'noopener'
|
await WebUIManager.UpdateNapCat();
|
||||||
);
|
toast.success('更新完成,重启生效', {
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update failed:', error);
|
||||||
|
toast.success('更新异常', {
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setUpdating(false);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@ -228,7 +293,7 @@ const NapCatVersion = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface SystemInfoProps {
|
export interface SystemInfoProps {
|
||||||
archInfo?: string
|
archInfo?: string;
|
||||||
}
|
}
|
||||||
const SystemInfo: React.FC<SystemInfoProps> = (props) => {
|
const SystemInfo: React.FC<SystemInfoProps> = (props) => {
|
||||||
const { archInfo } = props;
|
const { archInfo } = props;
|
||||||
|
|||||||
@ -141,7 +141,7 @@ const oneBotHttpApiMessage = {
|
|||||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||||
message_seq: z.union([z.string(), z.number()]).describe('消息序号'),
|
message_seq: z.union([z.string(), z.number()]).describe('消息序号'),
|
||||||
count: z.number().int().positive().describe('获取数量'),
|
count: z.number().int().positive().describe('获取数量'),
|
||||||
reverseOrder: z.boolean().describe('是否倒序'),
|
reverse_order: z.boolean().describe('是否倒序'),
|
||||||
}),
|
}),
|
||||||
response: baseResponseSchema.extend({
|
response: baseResponseSchema.extend({
|
||||||
data: z.object({
|
data: z.object({
|
||||||
@ -166,7 +166,7 @@ const oneBotHttpApiMessage = {
|
|||||||
user_id: z.union([z.string(), z.number()]).describe('用户QQ号'),
|
user_id: z.union([z.string(), z.number()]).describe('用户QQ号'),
|
||||||
message_seq: z.union([z.string(), z.number()]).describe('消息序号'),
|
message_seq: z.union([z.string(), z.number()]).describe('消息序号'),
|
||||||
count: z.number().int().positive().describe('获取数量'),
|
count: z.number().int().positive().describe('获取数量'),
|
||||||
reverseOrder: z.boolean().describe('是否倒序'),
|
reverse_order: z.boolean().describe('是否倒序'),
|
||||||
}),
|
}),
|
||||||
response: baseResponseSchema.extend({
|
response: baseResponseSchema.extend({
|
||||||
data: z.object({
|
data: z.object({
|
||||||
|
|||||||
@ -15,7 +15,7 @@ const oneBotHttpApiUser = {
|
|||||||
data: commonResponseDataSchema,
|
data: commonResponseDataSchema,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
'/ArkSharePeer': {
|
'/send_ark_share': {
|
||||||
description: '获取推荐好友/群聊卡片',
|
description: '获取推荐好友/群聊卡片',
|
||||||
request: z
|
request: z
|
||||||
.object({
|
.object({
|
||||||
@ -27,7 +27,7 @@ const oneBotHttpApiUser = {
|
|||||||
.union([z.string(), z.number()])
|
.union([z.string(), z.number()])
|
||||||
.optional()
|
.optional()
|
||||||
.describe('用户ID,与 group_id 二选一'),
|
.describe('用户ID,与 group_id 二选一'),
|
||||||
phoneNumber: z.string().optional().describe('对方手机号码'),
|
phone_number: z.string().optional().describe('对方手机号码'),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) =>
|
(data) =>
|
||||||
@ -45,7 +45,7 @@ const oneBotHttpApiUser = {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
'/ArkShareGroup': {
|
'/send_group_ark_share': {
|
||||||
description: '获取推荐群聊卡片',
|
description: '获取推荐群聊卡片',
|
||||||
request: z.object({
|
request: z.object({
|
||||||
group_id: z.union([z.string(), z.number()]).describe('群聊ID'),
|
group_id: z.union([z.string(), z.number()]).describe('群聊ID'),
|
||||||
|
|||||||
@ -6,25 +6,25 @@ import type { ModalProps } from '@/components/modal';
|
|||||||
|
|
||||||
export interface AlertProps
|
export interface AlertProps
|
||||||
extends Omit<ModalProps, 'onCancel' | 'showCancel' | 'cancelText'> {
|
extends Omit<ModalProps, 'onCancel' | 'showCancel' | 'cancelText'> {
|
||||||
onConfirm?: () => void
|
onConfirm?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfirmProps extends ModalProps {
|
export interface ConfirmProps extends ModalProps {
|
||||||
onConfirm?: () => void
|
onConfirm?: () => void;
|
||||||
onCancel?: () => void
|
onCancel?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModalItem extends ModalProps {
|
export interface ModalItem extends ModalProps {
|
||||||
id: number
|
id: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DialogContextProps {
|
export interface DialogContextProps {
|
||||||
alert: (config: AlertProps) => void
|
alert: (config: AlertProps) => void;
|
||||||
confirm: (config: ConfirmProps) => void
|
confirm: (config: ConfirmProps) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DialogProviderProps {
|
export interface DialogProviderProps {
|
||||||
children: React.ReactNode
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DialogContext = React.createContext<DialogContextProps>({
|
export const DialogContext = React.createContext<DialogContextProps>({
|
||||||
|
|||||||
@ -48,6 +48,21 @@ export default class WebUIManager {
|
|||||||
return data.data;
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async getLatestTag () {
|
||||||
|
const { data } =
|
||||||
|
await serverRequest.get<ServerResponse<string>>('/base/getLatestTag');
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async UpdateNapCat () {
|
||||||
|
const { data } = await serverRequest.post<ServerResponse<any>>(
|
||||||
|
'/UpdateNapCat/update',
|
||||||
|
{},
|
||||||
|
{ timeout: 60000 } // 1分钟超时
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
public static async getQQVersion () {
|
public static async getQQVersion () {
|
||||||
const { data } =
|
const { data } =
|
||||||
await serverRequest.get<ServerResponse<string>>('/base/QQVersion');
|
await serverRequest.get<ServerResponse<string>>('/base/QQVersion');
|
||||||
@ -197,4 +212,35 @@ export default class WebUIManager {
|
|||||||
);
|
);
|
||||||
return data.data;
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Passkey相关方法
|
||||||
|
public static async generatePasskeyRegistrationOptions () {
|
||||||
|
const { data } = await serverRequest.post<ServerResponse<any>>(
|
||||||
|
'/auth/passkey/generate-registration-options'
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async verifyPasskeyRegistration (response: any) {
|
||||||
|
const { data } = await serverRequest.post<ServerResponse<any>>(
|
||||||
|
'/auth/passkey/verify-registration',
|
||||||
|
{ response }
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async generatePasskeyAuthenticationOptions () {
|
||||||
|
const { data } = await serverRequest.post<ServerResponse<any>>(
|
||||||
|
'/auth/passkey/generate-authentication-options'
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async verifyPasskeyAuthentication (response: any) {
|
||||||
|
const { data } = await serverRequest.post<ServerResponse<any>>(
|
||||||
|
'/auth/passkey/verify-authentication',
|
||||||
|
{ response }
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -85,7 +85,11 @@ const Layout: React.FC<{ children: React.ReactNode; }> = ({ children }) => {
|
|||||||
backgroundSize: 'cover',
|
backgroundSize: 'cover',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SideBar items={menus} open={openSideBar} />
|
<SideBar
|
||||||
|
items={menus}
|
||||||
|
open={openSideBar}
|
||||||
|
onClose={() => setOpenSideBar(false)}
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@ -107,7 +111,7 @@ const Layout: React.FC<{ children: React.ReactNode; }> = ({ children }) => {
|
|||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'mr-1 ease-in-out ml-0 md:relative',
|
'mr-1 ease-in-out ml-0 md:relative z-50 md:z-auto',
|
||||||
openSideBar && 'pl-2 absolute',
|
openSideBar && 'pl-2 absolute',
|
||||||
'md:!ml-0 md:pl-0'
|
'md:!ml-0 md:pl-0'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -20,9 +20,10 @@ import WebUIManager from '@/controllers/webui_manager';
|
|||||||
|
|
||||||
function VersionInfo () {
|
function VersionInfo () {
|
||||||
const { data, loading, error } = useRequest(WebUIManager.GetNapCatVersion);
|
const { data, loading, error } = useRequest(WebUIManager.GetNapCatVersion);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className='flex items-center gap-4'>
|
||||||
<div className='flex items-center gap-2 text-2xl font-bold'>
|
<div className='flex items-center gap-2 text-2xl font-bold'>
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<div className='text-primary-500 drop-shadow-md'>NapCat</div>
|
<div className='text-primary-500 drop-shadow-md'>NapCat</div>
|
||||||
{error
|
{error
|
||||||
? (
|
? (
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Input } from '@heroui/input';
|
import { Input } from '@heroui/input';
|
||||||
|
import { Button } from '@heroui/button';
|
||||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Controller, useForm } from 'react-hook-form';
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
@ -14,6 +15,24 @@ import useMusic from '@/hooks/use-music';
|
|||||||
|
|
||||||
import { siteConfig } from '@/config/site';
|
import { siteConfig } from '@/config/site';
|
||||||
import FileManager from '@/controllers/file_manager';
|
import FileManager from '@/controllers/file_manager';
|
||||||
|
import WebUIManager from '@/controllers/webui_manager';
|
||||||
|
|
||||||
|
// Base64URL to Uint8Array converter
|
||||||
|
function base64UrlToUint8Array (base64Url: string): Uint8Array {
|
||||||
|
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const rawData = window.atob(base64);
|
||||||
|
const outputArray = new Uint8Array(rawData.length);
|
||||||
|
for (let i = 0; i < rawData.length; ++i) {
|
||||||
|
outputArray[i] = rawData.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return outputArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uint8Array to Base64URL converter
|
||||||
|
function uint8ArrayToBase64Url (uint8Array: Uint8Array): string {
|
||||||
|
const base64 = window.btoa(String.fromCharCode(...uint8Array));
|
||||||
|
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
const WebUIConfigCard = () => {
|
const WebUIConfigCard = () => {
|
||||||
const {
|
const {
|
||||||
@ -35,6 +54,25 @@ const WebUIConfigCard = () => {
|
|||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
const { setListId, listId } = useMusic();
|
const { setListId, listId } = useMusic();
|
||||||
|
const [registrationOptions, setRegistrationOptions] = useState<any>(null);
|
||||||
|
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
|
||||||
|
|
||||||
|
// 预先获取注册选项(可以在任何时候调用)
|
||||||
|
const preloadRegistrationOptions = async () => {
|
||||||
|
setIsLoadingOptions(true);
|
||||||
|
try {
|
||||||
|
console.log('预先获取注册选项...');
|
||||||
|
const options = await WebUIManager.generatePasskeyRegistrationOptions();
|
||||||
|
setRegistrationOptions(options);
|
||||||
|
console.log('✅ 注册选项已获取并存储');
|
||||||
|
toast.success('注册选项已准备就绪,请点击注册按钮');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 获取注册选项失败:', error);
|
||||||
|
toast.error('获取注册选项失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setIsLoadingOptions(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
setWebuiValue('musicListID', listId);
|
setWebuiValue('musicListID', listId);
|
||||||
@ -125,6 +163,122 @@ const WebUIConfigCard = () => {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className='flex flex-col gap-2'>
|
||||||
|
<div className='flex-shrink-0 w-full'>Passkey认证</div>
|
||||||
|
<div className='text-sm text-default-400 mb-2'>
|
||||||
|
注册Passkey后,您可以更便捷地登录WebUI,无需每次输入token
|
||||||
|
</div>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<Button
|
||||||
|
color='secondary'
|
||||||
|
variant='flat'
|
||||||
|
onPress={preloadRegistrationOptions}
|
||||||
|
isLoading={isLoadingOptions}
|
||||||
|
className='w-fit'
|
||||||
|
>
|
||||||
|
{!isLoadingOptions && '📥'}
|
||||||
|
准备选项
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color='primary'
|
||||||
|
variant='flat'
|
||||||
|
onPress={() => {
|
||||||
|
// 必须在用户手势的同步上下文中立即调用WebAuthn API
|
||||||
|
if (!registrationOptions) {
|
||||||
|
toast.error('请先点击"准备选项"按钮获取注册选项');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('开始Passkey注册...');
|
||||||
|
console.log('使用预先获取的选项:', registrationOptions);
|
||||||
|
|
||||||
|
// 立即调用WebAuthn API,不要用async/await
|
||||||
|
navigator.credentials.create({
|
||||||
|
publicKey: {
|
||||||
|
challenge: base64UrlToUint8Array(registrationOptions.challenge) as BufferSource,
|
||||||
|
rp: {
|
||||||
|
name: registrationOptions.rp.name,
|
||||||
|
id: registrationOptions.rp.id
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
id: base64UrlToUint8Array(registrationOptions.user.id) as BufferSource,
|
||||||
|
name: registrationOptions.user.name,
|
||||||
|
displayName: registrationOptions.user.displayName,
|
||||||
|
},
|
||||||
|
pubKeyCredParams: registrationOptions.pubKeyCredParams,
|
||||||
|
timeout: 30000,
|
||||||
|
excludeCredentials: registrationOptions.excludeCredentials?.map((cred: any) => ({
|
||||||
|
id: base64UrlToUint8Array(cred.id) as BufferSource,
|
||||||
|
type: cred.type,
|
||||||
|
transports: cred.transports,
|
||||||
|
})) || [],
|
||||||
|
attestation: registrationOptions.attestation,
|
||||||
|
},
|
||||||
|
}).then(async (credential) => {
|
||||||
|
console.log('✅ 注册成功!凭据已创建');
|
||||||
|
console.log('凭据ID:', (credential as PublicKeyCredential).id);
|
||||||
|
console.log('凭据类型:', (credential as PublicKeyCredential).type);
|
||||||
|
|
||||||
|
// Prepare response for verification - convert to expected format
|
||||||
|
const cred = credential as PublicKeyCredential;
|
||||||
|
const response = {
|
||||||
|
id: cred.id, // 保持为base64url字符串
|
||||||
|
rawId: uint8ArrayToBase64Url(new Uint8Array(cred.rawId)), // 转换为base64url字符串
|
||||||
|
response: {
|
||||||
|
attestationObject: uint8ArrayToBase64Url(new Uint8Array((cred.response as AuthenticatorAttestationResponse).attestationObject)), // 转换为base64url字符串
|
||||||
|
clientDataJSON: uint8ArrayToBase64Url(new Uint8Array(cred.response.clientDataJSON)), // 转换为base64url字符串
|
||||||
|
transports: (cred.response as AuthenticatorAttestationResponse).getTransports?.() || [],
|
||||||
|
},
|
||||||
|
type: cred.type,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('准备验证响应:', response);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify registration
|
||||||
|
const result = await WebUIManager.verifyPasskeyRegistration(response);
|
||||||
|
|
||||||
|
if (result.verified) {
|
||||||
|
toast.success('Passkey注册成功!现在您可以使用Passkey自动登录');
|
||||||
|
setRegistrationOptions(null); // 清除已使用的选项
|
||||||
|
} else {
|
||||||
|
throw new Error('Passkey registration failed');
|
||||||
|
}
|
||||||
|
} catch (verifyError) {
|
||||||
|
console.error('❌ 验证失败:', verifyError);
|
||||||
|
const err = verifyError as Error;
|
||||||
|
toast.error(`Passkey验证失败: ${err.message}`);
|
||||||
|
}
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('❌ 注册失败:', error);
|
||||||
|
const err = error as Error;
|
||||||
|
console.error('错误名称:', err.name);
|
||||||
|
console.error('错误信息:', err.message);
|
||||||
|
|
||||||
|
// Provide more specific error messages
|
||||||
|
if (err.name === 'NotAllowedError') {
|
||||||
|
toast.error('Passkey注册被拒绝。请确保您允许了生物识别认证权限。');
|
||||||
|
} else if (err.name === 'NotSupportedError') {
|
||||||
|
toast.error('您的浏览器不支持Passkey功能。');
|
||||||
|
} else if (err.name === 'SecurityError') {
|
||||||
|
toast.error('安全错误:请确保使用HTTPS或localhost环境。');
|
||||||
|
} else {
|
||||||
|
toast.error(`Passkey注册失败: ${err.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={!registrationOptions}
|
||||||
|
className='w-fit'
|
||||||
|
>
|
||||||
|
🔐 注册Passkey
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{registrationOptions && (
|
||||||
|
<div className='text-xs text-green-600'>
|
||||||
|
✅ 注册选项已准备就绪,可以开始注册
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<SaveButtons
|
<SaveButtons
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
reset={reset}
|
reset={reset}
|
||||||
|
|||||||
@ -24,7 +24,78 @@ export default function WebLoginPage () {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [tokenValue, setTokenValue] = useState<string>(token || '');
|
const [tokenValue, setTokenValue] = useState<string>(token || '');
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
const [isPasskeyLoading, setIsPasskeyLoading] = useState<boolean>(true); // 初始为true,表示正在检查passkey
|
||||||
const [, setLocalToken] = useLocalStorage<string>(key.token, '');
|
const [, setLocalToken] = useLocalStorage<string>(key.token, '');
|
||||||
|
|
||||||
|
// Helper function to decode base64url
|
||||||
|
function base64UrlToUint8Array (base64Url: string): Uint8Array {
|
||||||
|
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const binaryString = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binaryString.length);
|
||||||
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
|
bytes[i] = binaryString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to encode Uint8Array to base64url
|
||||||
|
function uint8ArrayToBase64Url (uint8Array: Uint8Array): string {
|
||||||
|
const base64 = btoa(String.fromCharCode(...uint8Array));
|
||||||
|
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动检查并尝试passkey登录
|
||||||
|
const tryPasskeyLogin = async () => {
|
||||||
|
try {
|
||||||
|
// 检查是否有passkey
|
||||||
|
const options = await WebUIManager.generatePasskeyAuthenticationOptions();
|
||||||
|
|
||||||
|
// 如果有passkey,自动进行认证
|
||||||
|
const credential = await navigator.credentials.get({
|
||||||
|
publicKey: {
|
||||||
|
challenge: base64UrlToUint8Array(options.challenge) as BufferSource,
|
||||||
|
allowCredentials: options.allowCredentials?.map((cred: any) => ({
|
||||||
|
id: base64UrlToUint8Array(cred.id) as BufferSource,
|
||||||
|
type: cred.type,
|
||||||
|
transports: cred.transports,
|
||||||
|
})),
|
||||||
|
userVerification: options.userVerification,
|
||||||
|
},
|
||||||
|
}) as PublicKeyCredential;
|
||||||
|
|
||||||
|
if (!credential) {
|
||||||
|
throw new Error('Passkey authentication cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 准备响应进行验证 - 转换为base64url字符串格式
|
||||||
|
const authResponse = credential.response as AuthenticatorAssertionResponse;
|
||||||
|
const response = {
|
||||||
|
id: credential.id,
|
||||||
|
rawId: uint8ArrayToBase64Url(new Uint8Array(credential.rawId)),
|
||||||
|
response: {
|
||||||
|
authenticatorData: uint8ArrayToBase64Url(new Uint8Array(authResponse.authenticatorData)),
|
||||||
|
clientDataJSON: uint8ArrayToBase64Url(new Uint8Array(authResponse.clientDataJSON)),
|
||||||
|
signature: uint8ArrayToBase64Url(new Uint8Array(authResponse.signature)),
|
||||||
|
userHandle: authResponse.userHandle ? uint8ArrayToBase64Url(new Uint8Array(authResponse.userHandle)) : null,
|
||||||
|
},
|
||||||
|
type: credential.type,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 验证认证
|
||||||
|
const data = await WebUIManager.verifyPasskeyAuthentication(response);
|
||||||
|
|
||||||
|
if (data && data.Credential) {
|
||||||
|
setLocalToken(data.Credential);
|
||||||
|
navigate('/qq_login', { replace: true });
|
||||||
|
return true; // 登录成功
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// passkey登录失败,继续显示token登录界面
|
||||||
|
console.log('Passkey login failed or not available:', error);
|
||||||
|
}
|
||||||
|
return false; // 登录失败
|
||||||
|
};
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
if (!tokenValue) {
|
if (!tokenValue) {
|
||||||
toast.error('请输入token');
|
toast.error('请输入token');
|
||||||
@ -48,7 +119,7 @@ export default function WebLoginPage () {
|
|||||||
|
|
||||||
// 处理全局键盘事件
|
// 处理全局键盘事件
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !isLoading) {
|
if (e.key === 'Enter' && !isLoading && !isPasskeyLoading) {
|
||||||
onSubmit();
|
onSubmit();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -60,12 +131,19 @@ export default function WebLoginPage () {
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [tokenValue, isLoading]); // 依赖项包含用于登录的状态
|
}, [tokenValue, isLoading, isPasskeyLoading]); // 依赖项包含用于登录的状态
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 如果URL中有token,直接登录
|
||||||
if (token) {
|
if (token) {
|
||||||
onSubmit();
|
onSubmit();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 否则尝试passkey自动登录
|
||||||
|
tryPasskeyLogin().finally(() => {
|
||||||
|
setIsPasskeyLoading(false);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -92,6 +170,11 @@ export default function WebLoginPage () {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardBody className='flex gap-5 py-5 px-5 md:px-10'>
|
<CardBody className='flex gap-5 py-5 px-5 md:px-10'>
|
||||||
|
{isPasskeyLoading && (
|
||||||
|
<div className='text-center text-small text-default-600 dark:text-default-400 px-2'>
|
||||||
|
🔐 正在检查Passkey...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -135,7 +218,7 @@ export default function WebLoginPage () {
|
|||||||
'!cursor-text',
|
'!cursor-text',
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
isDisabled={isLoading}
|
isDisabled={isLoading || isPasskeyLoading}
|
||||||
label='Token'
|
label='Token'
|
||||||
placeholder='请输入token'
|
placeholder='请输入token'
|
||||||
radius='lg'
|
radius='lg'
|
||||||
|
|||||||
12113
pnpm-lock.yaml
Normal file
12113
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user