From f2fdcc9289f607b5d5eb34dd71fbef8f070f865d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=8B=E7=93=9C=E4=B8=80=E5=8D=81=E9=9B=AA?= Date: Fri, 7 Feb 2025 13:56:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20webui=E4=BD=93=E9=AA=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/webui/index.ts | 87 ++++++++++++-- src/webui/src/helper/config.ts | 212 +++++++++------------------------ 2 files changed, 131 insertions(+), 168 deletions(-) diff --git a/src/webui/index.ts b/src/webui/index.ts index daea407d..d202195e 100644 --- a/src/webui/index.ts +++ b/src/webui/index.ts @@ -26,12 +26,25 @@ const server = createServer(app); */ export let WebUiConfig: WebUiConfigWrapper; export let webUiPathWrapper: NapCatPathWrapper; +const MAX_PORT_TRY = 100; +import * as net from 'node:net'; + +export async function InitPort(parsedConfig: WebUiConfigType): Promise<[string, number, string]> { + try { + await tryUseHost(parsedConfig.host); + const port = await tryUsePort(parsedConfig.port, parsedConfig.host); + return [parsedConfig.host, port, parsedConfig.token]; + } catch (error) { + console.log('host或port不可用', error); + return ['', 0, '']; + } +} export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapper) { webUiPathWrapper = pathWrapper; WebUiConfig = new WebUiConfigWrapper(); - const config = await WebUiConfig.GetWebUIConfig(); - if (config.port == 0) { + const [host, port, token] = await InitPort(await WebUiConfig.GetWebUIConfig()); + if (port == 0) { logger.log('[NapCat] [WebUi] Current WebUi is not run.'); return; } @@ -74,7 +87,7 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp // 初始服务(先放个首页) app.all('/', (_req, res) => { - sendSuccess(res, null, 'NapCat WebAPI is now running!'); + res.status(301).header('Location', '/webui').send(); }); // 错误处理中间件,捕获multer的错误 @@ -91,16 +104,72 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp }); // ------------启动服务------------ - server.listen(config.port, config.host, async () => { + server.listen(port, host, async () => { // 启动后打印出相关地址 - const port = config.port.toString(), - searchParams = { token: config.token }; - if (config.host !== '' && config.host !== '0.0.0.0') { + let searchParams = { token: token }; + if (host !== '' && host !== '0.0.0.0') { logger.log( - `[NapCat] [WebUi] WebUi User Panel Url: ${createUrl(config.host, port, '/webui', searchParams)}` + `[NapCat] [WebUi] WebUi User Panel Url: ${createUrl(host, port.toString(), '/webui', searchParams)}` ); } - logger.log(`[NapCat] [WebUi] WebUi Local Panel Url: ${createUrl('127.0.0.1', port, '/webui', searchParams)}`); + logger.log(`[NapCat] [WebUi] WebUi Local Panel Url: ${createUrl('127.0.0.1', port.toString(), '/webui', searchParams)}`); }); // ------------Over!------------ } + +async function tryUseHost(host: string): Promise { + return new Promise((resolve, reject) => { + try { + const server = net.createServer(); + server.on('listening', () => { + server.close(); + resolve(host); + }); + + server.on('error', (err: any) => { + if (err.code === 'EADDRNOTAVAIL') { + reject(new Error('主机地址验证失败,可能为非本机地址')); + } else { + reject(new Error(`遇到错误: ${err.code}`)); + } + }); + + // 尝试监听 让系统随机分配一个端口 + server.listen(0, host); + } catch (error) { + // 这里捕获到的错误应该是启动服务器时的同步错误 + reject(new Error(`服务器启动时发生错误: ${error}`)); + } + }); +} + +async function tryUsePort(port: number, host: string, tryCount: number = 0): Promise { + return new Promise((resolve, reject) => { + try { + const server = net.createServer(); + server.on('listening', () => { + server.close(); + resolve(port); + }); + + server.on('error', (err: any) => { + if (err.code === 'EADDRINUSE') { + if (tryCount < MAX_PORT_TRY) { + // 使用循环代替递归 + resolve(tryUsePort(port + 1, host, tryCount + 1)); + } else { + reject(new Error(`端口尝试失败,达到最大尝试次数: ${MAX_PORT_TRY}`)); + } + } else { + reject(new Error(`遇到错误: ${err.code}`)); + } + }); + + // 尝试监听端口 + server.listen(port, host); + } catch (error) { + // 这里捕获到的错误应该是启动服务器时的同步错误 + reject(new Error(`服务器启动时发生错误: ${error}`)); + } + }); +} \ No newline at end of file diff --git a/src/webui/src/helper/config.ts b/src/webui/src/helper/config.ts index 46e94589..7894d336 100644 --- a/src/webui/src/helper/config.ts +++ b/src/webui/src/helper/config.ts @@ -1,167 +1,75 @@ import { webUiPathWrapper } from '@/webui'; +import { Type, Static } from '@sinclair/typebox'; +import Ajv from 'ajv'; import fs, { constants } from 'node:fs/promises'; -import * as net from 'node:net'; + import { resolve } from 'node:path'; // 限制尝试端口的次数,避免死循环 -const MAX_PORT_TRY = 100; -async function tryUseHost(host: string): Promise { - return new Promise((resolve, reject) => { - try { - const server = net.createServer(); - server.on('listening', () => { - server.close(); - resolve(host); - }); +// 定义配置的类型 +const WebUiConfigSchema = Type.Object({ + host: Type.String({ default: '0.0.0.0' }), + port: Type.Number({ default: 6099 }), + token: Type.String({ default: 'napcat' }), + loginRate: Type.Number({ default: 10 }), +}); - server.on('error', (err: any) => { - if (err.code === 'EADDRNOTAVAIL') { - reject(new Error('主机地址验证失败,可能为非本机地址')); - } else { - reject(new Error(`遇到错误: ${err.code}`)); - } - }); +export type WebUiConfigType = Static; - // 尝试监听 让系统随机分配一个端口 - server.listen(0, host); - } catch (error) { - // 这里捕获到的错误应该是启动服务器时的同步错误 - reject(new Error(`服务器启动时发生错误: ${error}`)); - } - }); -} - -async function tryUsePort(port: number, host: string, tryCount: number = 0): Promise { - return new Promise((resolve, reject) => { - try { - const server = net.createServer(); - server.on('listening', () => { - server.close(); - resolve(port); - }); - - server.on('error', (err: any) => { - if (err.code === 'EADDRINUSE') { - if (tryCount < MAX_PORT_TRY) { - // 使用循环代替递归 - resolve(tryUsePort(port + 1, host, tryCount + 1)); - } else { - reject(new Error(`端口尝试失败,达到最大尝试次数: ${MAX_PORT_TRY}`)); - } - } else { - reject(new Error(`遇到错误: ${err.code}`)); - } - }); - - // 尝试监听端口 - server.listen(port, host); - } catch (error) { - // 这里捕获到的错误应该是启动服务器时的同步错误 - reject(new Error(`服务器启动时发生错误: ${error}`)); - } - }); -} // 读取当前目录下名为 webui.json 的配置文件,如果不存在则创建初始化配置文件 export class WebUiConfigWrapper { WebUiConfigData: WebUiConfigType | undefined = undefined; - private applyDefaults(obj: Partial, defaults: T): T { - const result = { ...defaults } as T; - for (const key in obj) { - if (obj[key] && typeof obj[key] === 'object' && !Array.isArray(obj[key])) { - result[key] = this.applyDefaults(obj[key], defaults[key]); - } else if (obj[key] !== undefined) { - result[key] = obj[key] as T[Extract]; - } + private validateAndApplyDefaults(config: Partial): WebUiConfigType { + new Ajv({ coerceTypes: true, useDefaults: true }).compile(WebUiConfigSchema)(config); + return config as WebUiConfigType; + } + + private async ensureConfigFileExists(configPath: string): Promise { + 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 { + const fileContent = await fs.readFile(configPath, 'utf-8'); + return this.validateAndApplyDefaults(JSON.parse(fileContent)); + } + + private async writeConfig(configPath: string, config: WebUiConfigType): Promise { + 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} 没有写入权限, 配置的更改部分可能会在重启后还原.`); } - return result; } async GetWebUIConfig(): Promise { if (this.WebUiConfigData) { return this.WebUiConfigData; } - const defaultconfig: WebUiConfigType = { - host: '0.0.0.0', - port: 6099, - token: '', // 默认先填空,空密码无法登录 - loginRate: 3, - }; - try { - defaultconfig.token = Math.random().toString(36).slice(2); //生成随机密码 - } catch (e) { - console.log('随机密码生成失败', e); - } try { const configPath = resolve(webUiPathWrapper.configPath, './webui.json'); - - if ( - !(await fs - .access(configPath, constants.F_OK) - .then(() => true) - .catch(() => false)) - ) { - await fs.writeFile(configPath, JSON.stringify(defaultconfig, null, 4)); - } - - const fileContent = await fs.readFile(configPath, 'utf-8'); - const parsedConfig = this.applyDefaults(JSON.parse(fileContent) as Partial, defaultconfig); - - if ( - await fs - .access(configPath, constants.W_OK) - .then(() => true) - .catch(() => false) - ) { - await fs.writeFile(configPath, JSON.stringify(parsedConfig, null, 4)); - } else { - console.warn(`文件: ${configPath} 没有写入权限, 配置的更改部分可能会在重启后还原.`); - } - - const [host_err, host] = await tryUseHost(parsedConfig.host) - .then((data) => [null, data]) - .catch((err) => [err, null]); - if (host_err) { - console.log('host不可用', host_err); - parsedConfig.port = 0; // 设置为0,禁用WebUI - } else { - parsedConfig.host = host; - const [port_err, port] = await tryUsePort(parsedConfig.port, parsedConfig.host) - .then((data) => [null, data]) - .catch((err) => [err, null]); - if (port_err) { - console.log('port不可用', port_err); - parsedConfig.port = 0; // 设置为0,禁用WebUI - } else { - parsedConfig.port = port; - } - } + await this.ensureConfigFileExists(configPath); + const parsedConfig = await this.readAndValidateConfig(configPath); this.WebUiConfigData = parsedConfig; return this.WebUiConfigData; } catch (e) { console.log('读取配置文件失败', e); + return this.validateAndApplyDefaults({}); } - return defaultconfig; // 理论上这行代码到不了,到了只能返回默认配置了 } async UpdateWebUIConfig(newConfig: Partial): Promise { const configPath = resolve(webUiPathWrapper.configPath, './webui.json'); const currentConfig = await this.GetWebUIConfig(); - const updatedConfig = this.applyDefaults(newConfig, currentConfig); - - if ( - await fs - .access(configPath, constants.W_OK) - .then(() => true) - .catch(() => false) - ) { - await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 4)); - this.WebUiConfigData = updatedConfig; - } else { - console.warn(`文件: ${configPath} 没有写入权限, 配置的更改部分可能会在重启后还原.`); - } + const updatedConfig = this.validateAndApplyDefaults({ ...currentConfig, ...newConfig }); + await this.writeConfig(configPath, updatedConfig); + this.WebUiConfigData = updatedConfig; } async UpdateToken(oldToken: string, newToken: string): Promise { @@ -176,29 +84,22 @@ export class WebUiConfigWrapper { public static async GetLogsPath(): Promise { return resolve(webUiPathWrapper.logsPath); } + // 获取日志列表 public static async GetLogsList(): Promise { - if ( - await fs - .access(webUiPathWrapper.logsPath, constants.F_OK) - .then(() => true) - .catch(() => false) - ) { - return (await fs.readdir(webUiPathWrapper.logsPath)) - .filter((file) => file.endsWith('.log')) - .map((file) => file.replace('.log', '')); + 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 []; } + // 获取指定日志文件内容 public static async GetLogContent(filename: string): Promise { const logPath = resolve(webUiPathWrapper.logsPath, `${filename}.log`); - if ( - await fs - .access(logPath, constants.R_OK) - .then(() => true) - .catch(() => false) - ) { + const logExists = await fs.access(logPath, constants.R_OK).then(() => true).catch(() => false); + if (logExists) { return await fs.readFile(logPath, 'utf-8'); } return ''; @@ -207,13 +108,9 @@ export class WebUiConfigWrapper { // 获取字体文件夹内的字体列表 public static async GetFontList(): Promise { const fontsPath = resolve(webUiPathWrapper.configPath, './fonts'); - if ( - await fs - .access(fontsPath, constants.F_OK) - .then(() => true) - .catch(() => false) - ) { - return (await fs.readdir(fontsPath)).filter((file) => file.endsWith('.ttf')); + 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 []; } @@ -221,14 +118,11 @@ export class WebUiConfigWrapper { // 判断字体是否存在(webui.woff) public static async CheckWebUIFontExist(): Promise { const fontsPath = resolve(webUiPathWrapper.configPath, './fonts'); - return await fs - .access(resolve(fontsPath, './webui.woff'), constants.F_OK) - .then(() => true) - .catch(() => false); + return await fs.access(resolve(fontsPath, './webui.woff'), constants.F_OK).then(() => true).catch(() => false); } // 获取webui字体文件路径 public static GetWebUIFontPath(): string { return resolve(webUiPathWrapper.configPath, './fonts/webui.woff'); } -} +} \ No newline at end of file