mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-01 16:20:25 +00:00
refactor: 整体重构 (#1381)
* feat: pnpm new * Refactor build and release workflows, update dependencies Switch build scripts and workflows from npm to pnpm, update build and artifact paths, and simplify release workflow by removing version detection and changelog steps. Add new dependencies (silk-wasm, express, ws, node-pty-prebuilt-multiarch), update exports in package.json files, and add vite config for napcat-framework. Also, rename manifest.json for framework package and fix static asset copying in shell build config.
This commit is contained in:
151
packages/napcat-webui-backend/src/helper/Data.ts
Normal file
151
packages/napcat-webui-backend/src/helper/Data.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import type { LoginRuntimeType } from '../types/data';
|
||||
import packageJson from '../../../../package.json';
|
||||
import store from '@/napcat-common/store';
|
||||
|
||||
const LoginRuntime: LoginRuntimeType = {
|
||||
LoginCurrentTime: Date.now(),
|
||||
LoginCurrentRate: 0,
|
||||
QQLoginStatus: false, // 已实现 但太傻了 得去那边注册个回调刷新
|
||||
QQQRCodeURL: '',
|
||||
QQLoginUin: '',
|
||||
QQLoginInfo: {
|
||||
uid: '',
|
||||
uin: '',
|
||||
nick: '',
|
||||
},
|
||||
QQVersion: 'unknown',
|
||||
onQQLoginStatusChange: async (status: boolean) => {
|
||||
LoginRuntime.QQLoginStatus = status;
|
||||
},
|
||||
onWebUiTokenChange: async (_token: string) => {
|
||||
|
||||
},
|
||||
NapCatHelper: {
|
||||
onOB11ConfigChanged: async () => {
|
||||
|
||||
},
|
||||
onQuickLoginRequested: async () => {
|
||||
return { result: false, message: '' };
|
||||
},
|
||||
QQLoginList: [],
|
||||
NewQQLoginList: [],
|
||||
},
|
||||
packageJson,
|
||||
WebUiConfigQuickFunction: async () => {
|
||||
|
||||
},
|
||||
};
|
||||
|
||||
export const WebUiDataRuntime = {
|
||||
setWebUiTokenChangeCallback (func: (token: string) => Promise<void>): void {
|
||||
LoginRuntime.onWebUiTokenChange = func;
|
||||
},
|
||||
getWebUiTokenChangeCallback (): (token: string) => Promise<void> {
|
||||
return LoginRuntime.onWebUiTokenChange;
|
||||
},
|
||||
checkLoginRate (ip: string, RateLimit: number): boolean {
|
||||
const key = `login_rate:${ip}`;
|
||||
const count = store.get<number>(key) || 0;
|
||||
|
||||
if (count === 0) {
|
||||
// 第一次访问,设置计数器为1,并设置60秒过期
|
||||
store.set(key, 1, 60);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (count >= RateLimit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
store.set(key, count + 1);
|
||||
return true;
|
||||
},
|
||||
|
||||
getQQLoginStatus (): LoginRuntimeType['QQLoginStatus'] {
|
||||
return LoginRuntime.QQLoginStatus;
|
||||
},
|
||||
|
||||
setQQLoginCallback (func: (status: boolean) => Promise<void>): void {
|
||||
LoginRuntime.onQQLoginStatusChange = func;
|
||||
},
|
||||
|
||||
getQQLoginCallback (): (status: boolean) => Promise<void> {
|
||||
return LoginRuntime.onQQLoginStatusChange;
|
||||
},
|
||||
|
||||
setQQLoginStatus (status: LoginRuntimeType['QQLoginStatus']): void {
|
||||
LoginRuntime.QQLoginStatus = status;
|
||||
},
|
||||
|
||||
setQQLoginQrcodeURL (url: LoginRuntimeType['QQQRCodeURL']): void {
|
||||
LoginRuntime.QQQRCodeURL = url;
|
||||
},
|
||||
|
||||
getQQLoginQrcodeURL (): LoginRuntimeType['QQQRCodeURL'] {
|
||||
return LoginRuntime.QQQRCodeURL;
|
||||
},
|
||||
|
||||
setQQLoginInfo (info: LoginRuntimeType['QQLoginInfo']): void {
|
||||
LoginRuntime.QQLoginInfo = info;
|
||||
LoginRuntime.QQLoginUin = info.uin.toString();
|
||||
},
|
||||
|
||||
getQQLoginInfo (): LoginRuntimeType['QQLoginInfo'] {
|
||||
return LoginRuntime.QQLoginInfo;
|
||||
},
|
||||
|
||||
getQQLoginUin (): LoginRuntimeType['QQLoginUin'] {
|
||||
return LoginRuntime.QQLoginUin;
|
||||
},
|
||||
|
||||
getQQQuickLoginList (): LoginRuntimeType['NapCatHelper']['QQLoginList'] {
|
||||
return LoginRuntime.NapCatHelper.QQLoginList;
|
||||
},
|
||||
|
||||
setQQQuickLoginList (list: LoginRuntimeType['NapCatHelper']['QQLoginList']): void {
|
||||
LoginRuntime.NapCatHelper.QQLoginList = list;
|
||||
},
|
||||
|
||||
getQQNewLoginList (): LoginRuntimeType['NapCatHelper']['NewQQLoginList'] {
|
||||
return LoginRuntime.NapCatHelper.NewQQLoginList;
|
||||
},
|
||||
|
||||
setQQNewLoginList (list: LoginRuntimeType['NapCatHelper']['NewQQLoginList']): void {
|
||||
LoginRuntime.NapCatHelper.NewQQLoginList = list;
|
||||
},
|
||||
|
||||
setQuickLoginCall (func: LoginRuntimeType['NapCatHelper']['onQuickLoginRequested']): void {
|
||||
LoginRuntime.NapCatHelper.onQuickLoginRequested = func;
|
||||
},
|
||||
|
||||
requestQuickLogin: function (uin) {
|
||||
return LoginRuntime.NapCatHelper.onQuickLoginRequested(uin);
|
||||
} as LoginRuntimeType['NapCatHelper']['onQuickLoginRequested'],
|
||||
|
||||
setOnOB11ConfigChanged (func: LoginRuntimeType['NapCatHelper']['onOB11ConfigChanged']): void {
|
||||
LoginRuntime.NapCatHelper.onOB11ConfigChanged = func;
|
||||
},
|
||||
|
||||
setOB11Config: function (ob11) {
|
||||
return LoginRuntime.NapCatHelper.onOB11ConfigChanged(ob11);
|
||||
} as LoginRuntimeType['NapCatHelper']['onOB11ConfigChanged'],
|
||||
|
||||
getPackageJson () {
|
||||
return LoginRuntime.packageJson;
|
||||
},
|
||||
|
||||
setQQVersion (version: string) {
|
||||
LoginRuntime.QQVersion = version;
|
||||
},
|
||||
|
||||
getQQVersion () {
|
||||
return LoginRuntime.QQVersion;
|
||||
},
|
||||
|
||||
setWebUiConfigQuickFunction (func: LoginRuntimeType['WebUiConfigQuickFunction']): void {
|
||||
LoginRuntime.WebUiConfigQuickFunction = func;
|
||||
},
|
||||
runWebUiConfigQuickFunction: async function () {
|
||||
await LoginRuntime.WebUiConfigQuickFunction();
|
||||
},
|
||||
};
|
||||
106
packages/napcat-webui-backend/src/helper/SignToken.ts
Normal file
106
packages/napcat-webui-backend/src/helper/SignToken.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import crypto from 'crypto';
|
||||
import store from '@/napcat-common/store';
|
||||
export class AuthHelper {
|
||||
private static readonly secretKey = Math.random().toString(36).slice(2);
|
||||
|
||||
/**
|
||||
* 签名凭证方法。
|
||||
* @param hash 待签名的凭证字符串。
|
||||
* @returns 签名后的凭证对象。
|
||||
*/
|
||||
public static signCredential (hash: string): WebUiCredentialJson {
|
||||
const innerJson: WebUiCredentialInnerJson = {
|
||||
CreatedTime: Date.now(),
|
||||
HashEncoded: hash,
|
||||
};
|
||||
const jsonString = JSON.stringify(innerJson);
|
||||
const hmac = crypto.createHmac('sha256', AuthHelper.secretKey).update(jsonString, 'utf8').digest('hex');
|
||||
return { Data: innerJson, Hmac: hmac };
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查凭证是否被篡改的方法。
|
||||
* @param credentialJson 凭证的JSON对象。
|
||||
* @returns 布尔值,表示凭证是否有效。
|
||||
*/
|
||||
public static checkCredential (credentialJson: WebUiCredentialJson): boolean {
|
||||
try {
|
||||
const jsonString = JSON.stringify(credentialJson.Data);
|
||||
const calculatedHmac = crypto
|
||||
.createHmac('sha256', AuthHelper.secretKey)
|
||||
.update(jsonString, 'utf8')
|
||||
.digest('hex');
|
||||
return calculatedHmac === credentialJson.Hmac;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证凭证在1小时内有效且token与原始token相同。
|
||||
* @param token 待验证的原始token。
|
||||
* @param credentialJson 已签名的凭证JSON对象。
|
||||
* @returns 布尔值,表示凭证是否有效且token匹配。
|
||||
*/
|
||||
public static validateCredentialWithinOneHour (token: string, credentialJson: WebUiCredentialJson): boolean {
|
||||
// 首先检查凭证是否被篡改
|
||||
const isValid = AuthHelper.checkCredential(credentialJson);
|
||||
if (!isValid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查凭证是否在黑名单中
|
||||
if (AuthHelper.isCredentialRevoked(credentialJson)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentTime = Date.now() / 1000;
|
||||
const createdTime = credentialJson.Data.CreatedTime;
|
||||
const timeDifference = currentTime - createdTime;
|
||||
return timeDifference <= 3600 && credentialJson.Data.HashEncoded === AuthHelper.generatePasswordHash(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销指定的Token凭证
|
||||
* @param credentialJson 凭证JSON对象
|
||||
* @returns void
|
||||
*/
|
||||
public static revokeCredential (credentialJson: WebUiCredentialJson): void {
|
||||
const jsonString = JSON.stringify(credentialJson.Data);
|
||||
const hmac = crypto.createHmac('sha256', AuthHelper.secretKey).update(jsonString, 'utf8').digest('hex');
|
||||
|
||||
// 将已注销的凭证添加到黑名单中,有效期1小时
|
||||
store.set(`revoked:${hmac}`, true, 3600);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查凭证是否已被注销
|
||||
* @param credentialJson 凭证JSON对象
|
||||
* @returns 布尔值,表示凭证是否已被注销
|
||||
*/
|
||||
public static isCredentialRevoked (credentialJson: WebUiCredentialJson): boolean {
|
||||
const jsonString = JSON.stringify(credentialJson.Data);
|
||||
const hmac = crypto.createHmac('sha256', AuthHelper.secretKey).update(jsonString, 'utf8').digest('hex');
|
||||
|
||||
return store.exists(`revoked:${hmac}`) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成密码Hash
|
||||
* @param password 密码
|
||||
* @returns 生成的Hash值
|
||||
*/
|
||||
public static generatePasswordHash (password: string): string {
|
||||
return crypto.createHash('sha256').update(password + '.napcat').digest().toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 对比密码和Hash值
|
||||
* @param password 密码
|
||||
* @param hash Hash值
|
||||
* @returns 布尔值,表示密码是否匹配Hash值
|
||||
*/
|
||||
public static comparePasswordHash (password: string, hash: string): boolean {
|
||||
return this.generatePasswordHash(password) === hash;
|
||||
}
|
||||
}
|
||||
240
packages/napcat-webui-backend/src/helper/config.ts
Normal file
240
packages/napcat-webui-backend/src/helper/config.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { webUiPathWrapper, getInitialWebUiToken } from '@/napcat-webui-backend/index'
|
||||
import { Type, Static } from '@sinclair/typebox';
|
||||
import Ajv from 'ajv';
|
||||
import fs, { constants } from 'node:fs/promises';
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { deepMerge } from '../utils/object';
|
||||
import { themeType } from '../types/theme';
|
||||
import { getRandomToken } from '../utils/url';
|
||||
|
||||
// 限制尝试端口的次数,避免死循环
|
||||
// 定义配置的类型
|
||||
const WebUiConfigSchema = Type.Object({
|
||||
host: Type.String({ default: '0.0.0.0' }),
|
||||
port: Type.Number({ default: 6099 }),
|
||||
token: Type.String({ default: getRandomToken(12) }),
|
||||
loginRate: Type.Number({ default: 10 }),
|
||||
autoLoginAccount: Type.String({ default: '' }),
|
||||
theme: themeType,
|
||||
// 是否关闭WebUI
|
||||
disableWebUI: Type.Boolean({ default: false }),
|
||||
// 是否关闭非局域网访问
|
||||
disableNonLANAccess: Type.Boolean({ default: false }),
|
||||
});
|
||||
|
||||
export type WebUiConfigType = Static<typeof WebUiConfigSchema>;
|
||||
|
||||
// 读取当前目录下名为 webui.json 的配置文件,如果不存在则创建初始化配置文件
|
||||
export class WebUiConfigWrapper {
|
||||
WebUiConfigData: WebUiConfigType | undefined = undefined;
|
||||
|
||||
private validateAndApplyDefaults (config: Partial<WebUiConfigType>): WebUiConfigType {
|
||||
new Ajv({ coerceTypes: true, useDefaults: true }).compile(WebUiConfigSchema)(config);
|
||||
return config as WebUiConfigType;
|
||||
}
|
||||
|
||||
private async ensureConfigFileExists (configPath: string): Promise<void> {
|
||||
const configExists = await fs
|
||||
.access(configPath, constants.F_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (!configExists) {
|
||||
await fs.writeFile(configPath, JSON.stringify(this.validateAndApplyDefaults({}), null, 4));
|
||||
}
|
||||
}
|
||||
|
||||
private async readAndValidateConfig (configPath: string): Promise<WebUiConfigType> {
|
||||
const fileContent = await fs.readFile(configPath, 'utf-8');
|
||||
return this.validateAndApplyDefaults(JSON.parse(fileContent));
|
||||
}
|
||||
|
||||
private async writeConfig (configPath: string, config: WebUiConfigType): Promise<void> {
|
||||
const hasWritePermission = await fs
|
||||
.access(configPath, constants.W_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (hasWritePermission) {
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 4));
|
||||
} else {
|
||||
console.warn(`文件: ${configPath} 没有写入权限, 配置的更改部分可能会在重启后还原.`);
|
||||
}
|
||||
}
|
||||
|
||||
async GetWebUIConfig (): Promise<WebUiConfigType> {
|
||||
if (this.WebUiConfigData) {
|
||||
return this.WebUiConfigData;
|
||||
}
|
||||
|
||||
try {
|
||||
const configPath = resolve(webUiPathWrapper.configPath, './webui.json');
|
||||
await this.ensureConfigFileExists(configPath);
|
||||
const parsedConfig = await this.readAndValidateConfig(configPath);
|
||||
// 使用内存中缓存的token进行覆盖,确保强兼容性
|
||||
this.WebUiConfigData = {
|
||||
...parsedConfig,
|
||||
// 首次读取内存中是没有token的,需要进行一层兜底
|
||||
token: getInitialWebUiToken() || parsedConfig.token,
|
||||
};
|
||||
return this.WebUiConfigData;
|
||||
} catch (e) {
|
||||
console.log('读取配置文件失败', e);
|
||||
const defaultConfig = this.validateAndApplyDefaults({});
|
||||
this.WebUiConfigData = {
|
||||
...defaultConfig,
|
||||
token: getInitialWebUiToken() || defaultConfig.token,
|
||||
};
|
||||
return this.WebUiConfigData;
|
||||
}
|
||||
}
|
||||
|
||||
async UpdateWebUIConfig (newConfig: Partial<WebUiConfigType>): Promise<void> {
|
||||
const configPath = resolve(webUiPathWrapper.configPath, './webui.json');
|
||||
// 使用原始配置进行合并,避免内存token覆盖影响配置更新
|
||||
const currentConfig = await this.GetRawWebUIConfig();
|
||||
const mergedConfig = deepMerge({ ...currentConfig }, newConfig);
|
||||
const updatedConfig = this.validateAndApplyDefaults(mergedConfig);
|
||||
await this.writeConfig(configPath, updatedConfig);
|
||||
this.WebUiConfigData = updatedConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置文件中实际存储的配置(不被内存token覆盖)
|
||||
* 主要用于配置更新和特殊场景
|
||||
*/
|
||||
async GetRawWebUIConfig (): Promise<WebUiConfigType> {
|
||||
if (this.WebUiConfigData) {
|
||||
return this.WebUiConfigData;
|
||||
}
|
||||
try {
|
||||
const configPath = resolve(webUiPathWrapper.configPath, './webui.json');
|
||||
await this.ensureConfigFileExists(configPath);
|
||||
const parsedConfig = await this.readAndValidateConfig(configPath);
|
||||
this.WebUiConfigData = parsedConfig;
|
||||
return this.WebUiConfigData;
|
||||
} catch (e) {
|
||||
console.log('读取配置文件失败', e);
|
||||
return this.validateAndApplyDefaults({});
|
||||
}
|
||||
}
|
||||
|
||||
async UpdateToken (oldToken: string, newToken: string): Promise<void> {
|
||||
// 使用内存中缓存的token进行验证,确保强兼容性
|
||||
const cachedToken = getInitialWebUiToken();
|
||||
const tokenToCheck = cachedToken || (await this.GetWebUIConfig()).token;
|
||||
|
||||
if (tokenToCheck !== oldToken) {
|
||||
throw new Error('旧 token 不匹配');
|
||||
}
|
||||
await this.UpdateWebUIConfig({ token: newToken });
|
||||
}
|
||||
|
||||
// 获取日志文件夹路径
|
||||
async GetLogsPath (): Promise<string> {
|
||||
return resolve(webUiPathWrapper.logsPath);
|
||||
}
|
||||
|
||||
// 获取日志列表
|
||||
async GetLogsList (): Promise<string[]> {
|
||||
const logsPath = resolve(webUiPathWrapper.logsPath);
|
||||
const logsExist = await fs
|
||||
.access(logsPath, constants.F_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (logsExist) {
|
||||
return (await fs.readdir(logsPath))
|
||||
.filter((file) => file.endsWith('.log'))
|
||||
.map((file) => file.replace('.log', ''));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// 获取指定日志文件内容
|
||||
async GetLogContent (filename: string): Promise<string> {
|
||||
const logPath = resolve(webUiPathWrapper.logsPath, `${filename}.log`);
|
||||
const logExists = await fs
|
||||
.access(logPath, constants.R_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (logExists) {
|
||||
return await fs.readFile(logPath, 'utf-8');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// 获取字体文件夹内的字体列表
|
||||
async GetFontList (): Promise<string[]> {
|
||||
const fontsPath = resolve(webUiPathWrapper.configPath, './fonts');
|
||||
const fontsExist = await fs
|
||||
.access(fontsPath, constants.F_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (fontsExist) {
|
||||
return (await fs.readdir(fontsPath)).filter((file) => file.endsWith('.ttf'));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// 判断字体是否存在(webui.woff)
|
||||
async CheckWebUIFontExist (): Promise<boolean> {
|
||||
const fontsPath = resolve(webUiPathWrapper.configPath, './fonts');
|
||||
return await fs
|
||||
.access(resolve(fontsPath, './webui.woff'), constants.F_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
// 获取webui字体文件路径
|
||||
GetWebUIFontPath (): string {
|
||||
return resolve(webUiPathWrapper.configPath, './fonts/webui.woff');
|
||||
}
|
||||
|
||||
getAutoLoginAccount (): string | undefined {
|
||||
return this.WebUiConfigData?.autoLoginAccount;
|
||||
}
|
||||
|
||||
// 获取自动登录账号
|
||||
async GetAutoLoginAccount (): Promise<string> {
|
||||
return (await this.GetWebUIConfig()).autoLoginAccount;
|
||||
}
|
||||
|
||||
// 更新自动登录账号
|
||||
async UpdateAutoLoginAccount (uin: string): Promise<void> {
|
||||
await this.UpdateWebUIConfig({ autoLoginAccount: uin });
|
||||
}
|
||||
|
||||
// 获取主题内容
|
||||
async GetTheme (): Promise<WebUiConfigType['theme']> {
|
||||
const config = await this.GetWebUIConfig();
|
||||
|
||||
return config.theme;
|
||||
}
|
||||
|
||||
// 更新主题内容
|
||||
async UpdateTheme (theme: WebUiConfigType['theme']): Promise<void> {
|
||||
await this.UpdateWebUIConfig({ theme });
|
||||
}
|
||||
|
||||
// 获取是否禁用WebUI
|
||||
async GetDisableWebUI (): Promise<boolean> {
|
||||
const config = await this.GetWebUIConfig();
|
||||
return config.disableWebUI;
|
||||
}
|
||||
|
||||
// 更新是否禁用WebUI
|
||||
async UpdateDisableWebUI (disable: boolean): Promise<void> {
|
||||
await this.UpdateWebUIConfig({ disableWebUI: disable });
|
||||
}
|
||||
|
||||
// 获取是否禁用非局域网访问
|
||||
async GetDisableNonLANAccess (): Promise<boolean> {
|
||||
const config = await this.GetWebUIConfig();
|
||||
return config.disableNonLANAccess;
|
||||
}
|
||||
|
||||
// 更新是否禁用非局域网访问
|
||||
async UpdateDisableNonLANAccess (disable: boolean): Promise<void> {
|
||||
await this.UpdateWebUIConfig({ disableNonLANAccess: disable });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user