diff --git a/package.json b/package.json index 7d183235..23b32f27 100644 --- a/package.json +++ b/package.json @@ -52,9 +52,9 @@ "dependencies": { "express": "^5.0.0", "fluent-ffmpeg": "^2.1.2", + "piscina": "^4.7.0", "qrcode-terminal": "^0.12.0", "silk-wasm": "^3.6.1", - "ws": "^8.18.0", - "piscina": "^4.7.0" + "ws": "^8.18.0" } } diff --git a/src/webui/index.ts b/src/webui/index.ts index f8bf81ac..a450d856 100644 --- a/src/webui/index.ts +++ b/src/webui/index.ts @@ -1,11 +1,20 @@ +/** + * @file WebUI服务入口文件 + */ + import express from 'express'; -import { ALLRouter } from './src/router'; + import { LogWrapper } from '@/common/log'; import { NapCatPathWrapper } from '@/common/path'; -import { WebUiConfigWrapper } from './src/helper/config'; import { RequestUtil } from '@/common/request'; -import { isIP } from "node:net"; +import { WebUiConfigWrapper } from '@webapi/helper/config'; +import { ALLRouter } from '@webapi/router'; +import { cors } from '@webapi/middleware/cors'; +import { createUrl } from '@webapi/utils/url'; +import { sendSuccess } from '@webapi/utils/response'; + +// 实例化Express const app = express(); /** @@ -26,49 +35,51 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp log('[NapCat] [WebUi] Current WebUi is not run.'); return; } + + // ------------注册中间件------------ + // 使用express的json中间件 app.use(express.json()); - // 初始服务 + + // CORS中间件 + // TODO: + app.use(cors); + // ------------中间件结束------------ + + // ------------挂载路由------------ + // 挂载静态路由(前端),路径为 [/前缀]/webui + app.use(config.prefix + '/webui', express.static(pathWrapper.staticPath)); + // 挂载API接口 + app.use(config.prefix + '/api', ALLRouter); + + // 初始服务(先放个首页) // WebUI只在config.prefix所示路径上提供服务,可配合Nginx挂载到子目录中 app.all(config.prefix + '/', (_req, res) => { - res.json({ - msg: 'NapCat WebAPI is now running!', - }); + sendSuccess(res, null, 'NapCat WebAPI is now running!'); }); - // 配置静态文件服务,提供./static目录下的文件服务,访问路径为/webui - app.use(config.prefix + '/webui', express.static(pathWrapper.staticPath)); - //挂载API接口 - // 添加CORS支持 - // TODO: - app.use((req, res, next) => { - res.header('Access-Control-Allow-Origin', '*'); - res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); - res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization'); - next(); - }); - app.use(config.prefix + '/api', ALLRouter); + // ------------路由挂载结束------------ + + // ------------启动服务------------ app.listen(config.port, config.host, async () => { - const normalizeHost = (host: string) => { - if (host === '0.0.0.0') return '127.0.0.1'; - if (isIP(host) === 6) return `[${host}]`; - return host; - }; - const createUrl = (host: string, path: string, token: string) => { - const url = new URL(`http://${normalizeHost(host)}`); - url.port = config.port.toString(); - url.pathname = `${config.prefix}${path}`; - url.searchParams.set('token', token); - return url.toString(); - }; + // 启动后打印出相关地址 + + const port = config.port.toString(), + searchParams = { token: config.token }, + path = `${config.prefix}/webui`; + + // 打印日志(地址、token) log(`[NapCat] [WebUi] Current WebUi is running at http://${config.host}:${config.port}${config.prefix}`); log(`[NapCat] [WebUi] Login Token is ${config.token}`); - log(`[NapCat] [WebUi] WebUi User Panel Url: ${createUrl(config.host, '/webui', config.token)}`); - log(`[NapCat] [WebUi] WebUi Local Panel Url: ${createUrl('127.0.0.1', '/webui', config.token)}`); + log(`[NapCat] [WebUi] WebUi User Panel Url: ${createUrl(config.host, port, path, searchParams)}`); + log(`[NapCat] [WebUi] WebUi Local Panel Url: ${createUrl('127.0.0.1', port, path, searchParams)}`); + + // 获取公网地址 try { const publishUrl = 'https://ip.011102.xyz/'; const data = await RequestUtil.HttpGetJson<{ IP: { IP: string } }>(publishUrl, 'GET', {}, {}, true, true); - log(`[NapCat] [WebUi] WebUi Publish Panel Url: ${createUrl(data.IP.IP, '/webui', config.token)}`); + log(`[NapCat] [WebUi] WebUi Publish Panel Url: ${createUrl(data.IP.IP, port, path, searchParams)}`); } catch (err) { logger.logError(`[NapCat] [WebUi] Get Publish Panel Url Error: ${err}`); } }); + // ------------Over!------------ } diff --git a/src/webui/src/api/Auth.ts b/src/webui/src/api/Auth.ts index 366ded37..62e68e82 100644 --- a/src/webui/src/api/Auth.ts +++ b/src/webui/src/api/Auth.ts @@ -1,69 +1,64 @@ import { RequestHandler } from 'express'; -import { AuthHelper } from '../helper/SignToken'; -import { WebUiDataRuntime } from '../helper/Data'; + import { WebUiConfig } from '@/webui'; -const isEmpty = (data: any) => data === undefined || data === null || data === ''; +import { AuthHelper } from '@webapi/helper/SignToken'; +import { WebUiDataRuntime } from '@webapi/helper/Data'; +import { sendSuccess, sendError } from '@webapi/utils/response'; +import { isEmpty } from '@webapi/utils/check'; + +// 登录 export const LoginHandler: RequestHandler = async (req, res) => { + // 获取WebUI配置 const WebUiConfigData = await WebUiConfig.GetWebUIConfig(); + // 获取请求体中的token const { token } = req.body; + // 如果token为空,返回错误信息 if (isEmpty(token)) { - res.json({ - code: -1, - message: 'token is empty', - }); - return; + return sendError(res, 'token is empty'); } - if (!await WebUiDataRuntime.checkLoginRate(WebUiConfigData.loginRate)) { - res.json({ - code: -1, - message: 'login rate limit', - }); - return; + // 检查登录频率 + if (!(await WebUiDataRuntime.checkLoginRate(WebUiConfigData.loginRate))) { + return sendError(res, 'login rate limit'); } //验证config.token是否等于token if (WebUiConfigData.token !== token) { - res.json({ - code: -1, - message: 'token is invalid', - }); - return; + return sendError(res, 'token is invalid'); } - const signCredential = Buffer.from(JSON.stringify(await AuthHelper.signCredential(WebUiConfigData.token))).toString('base64'); - res.json({ - code: 0, - message: 'success', - data: { - 'Credential': signCredential, - }, + // 签发凭证 + const signCredential = Buffer.from(JSON.stringify(await AuthHelper.signCredential(WebUiConfigData.token))).toString( + 'base64' + ); + // 返回成功信息 + return sendSuccess(res, { + Credential: signCredential, }); - return; }; -export const LogoutHandler: RequestHandler = (req, res) => { - // 这玩意无状态销毁个灯 得想想办法 - res.json({ - code: 0, - message: 'success', - }); - return; + +// 退出登录 +export const LogoutHandler: RequestHandler = (_, res) => { + // TODO: 这玩意无状态销毁个灯 得想想办法 + return sendSuccess(res, null); }; + +// 检查登录状态 export const checkHandler: RequestHandler = async (req, res) => { + // 获取WebUI配置 const WebUiConfigData = await WebUiConfig.GetWebUIConfig(); + // 获取请求头中的Authorization const authorization = req.headers.authorization; + // 检查凭证 try { + // 从Authorization中获取凭证 const CredentialBase64: string = authorization?.split(' ')[1] as string; + // 解析凭证 const Credential = JSON.parse(Buffer.from(CredentialBase64, 'base64').toString()); + // 验证凭证是否在一小时内有效 await AuthHelper.validateCredentialWithinOneHour(WebUiConfigData.token, Credential); - res.json({ - code: 0, - message: 'success', - }); - return; + // 返回成功信息 + return sendSuccess(res, null); } catch (e) { - res.json({ - code: -1, - message: 'failed', - }); + // 返回错误信息 + return sendError(res, 'Authorization Faild'); } - return; }; diff --git a/src/webui/src/api/BaseInfo.ts b/src/webui/src/api/BaseInfo.ts index 53ea87a0..acdcb40f 100644 --- a/src/webui/src/api/BaseInfo.ts +++ b/src/webui/src/api/BaseInfo.ts @@ -1,14 +1,15 @@ import { RequestHandler } from 'express'; -export const LogFileListHandler: RequestHandler = async (req, res) => { - res.send({ - code: 0, - data: { - uin: 0, - nick: 'NapCat', - avatar: 'https://q1.qlogo.cn/g?b=qq&nk=0&s=640', - status: 'online', - boottime: Date.now() - } - }); +import { sendSuccess } from '@webapi/utils/response'; + +// TODO: Implement LogFileListHandler +export const LogFileListHandler: RequestHandler = async (_, res) => { + const fakeData = { + uin: 0, + nick: 'NapCat', + avatar: 'https://q1.qlogo.cn/g?b=qq&nk=0&s=640', + status: 'online', + boottime: Date.now(), + }; + sendSuccess(res, fakeData); }; diff --git a/src/webui/src/api/OB11Config.ts b/src/webui/src/api/OB11Config.ts index 60de2264..15fdc5d4 100644 --- a/src/webui/src/api/OB11Config.ts +++ b/src/webui/src/api/OB11Config.ts @@ -1,79 +1,58 @@ import { RequestHandler } from 'express'; -import { WebUiDataRuntime } from '../helper/Data'; import { existsSync, readFileSync } from 'node:fs'; -import { OneBotConfig } from '@/onebot/config/config'; import { resolve } from 'node:path'; -import { webUiPathWrapper } from '@/webui'; -const isEmpty = (data: any) => data === undefined || data === null || data === ''; -export const OB11GetConfigHandler: RequestHandler = async (req, res) => { +import { OneBotConfig } from '@/onebot/config/config'; + +import { webUiPathWrapper } from '@/webui'; +import { WebUiDataRuntime } from '@webapi/helper/Data'; +import { sendError, sendSuccess } from '@webapi/utils/response'; +import { isEmpty } from '@webapi/utils/check'; + +// 获取OneBot11配置 +export const OB11GetConfigHandler: RequestHandler = async (_, res) => { + // 获取QQ登录状态 const isLogin = await WebUiDataRuntime.getQQLoginStatus(); + // 如果未登录,返回错误 if (!isLogin) { - res.send({ - code: -1, - message: 'Not Login', - }); - return; + return sendError(res, 'Not Login'); } + // 获取登录的QQ号 const uin = await WebUiDataRuntime.getQQLoginUin(); + // 读取配置文件 const configFilePath = resolve(webUiPathWrapper.configPath, `./onebot11_${uin}.json`); - //console.log(configFilePath); - let data: OneBotConfig; + // 尝试解析配置文件 try { - data = JSON.parse( + // 读取配置文件 + const data = JSON.parse( existsSync(configFilePath) ? readFileSync(configFilePath).toString() : readFileSync(resolve(webUiPathWrapper.configPath, './onebot11.json')).toString() - ); + ) as OneBotConfig; + // 返回配置文件 + return sendSuccess(res, data); } catch (e) { - data = {} as OneBotConfig; - res.send({ - code: -1, - message: 'Config Get Error', - }); - return; + return sendError(res, 'Config Get Error'); } - res.send({ - code: 0, - message: 'success', - data: data, - }); - return; }; + +// 写入OneBot11配置 export const OB11SetConfigHandler: RequestHandler = async (req, res) => { + // 获取QQ登录状态 const isLogin = await WebUiDataRuntime.getQQLoginStatus(); + // 如果未登录,返回错误 if (!isLogin) { - res.send({ - code: -1, - message: 'Not Login', - }); - return; + return sendError(res, 'Not Login'); } + // 如果配置为空,返回错误 if (isEmpty(req.body.config)) { - res.send({ - code: -1, - message: 'config is empty', - }); - return; + return sendError(res, 'config is empty'); } - let SetResult; + // 写入配置 try { await WebUiDataRuntime.setOB11Config(JSON.parse(req.body.config)); - SetResult = true; + return sendSuccess(res, null); } catch (e) { - SetResult = false; + return sendError(res, 'Config Set Error'); } - if (SetResult) { - res.send({ - code: 0, - message: 'success', - }); - } else { - res.send({ - code: -1, - message: 'Config Set Error', - }); - } - - return; }; diff --git a/src/webui/src/api/QQLogin.ts b/src/webui/src/api/QQLogin.ts index ba78451f..9dda0aca 100644 --- a/src/webui/src/api/QQLogin.ts +++ b/src/webui/src/api/QQLogin.ts @@ -1,78 +1,64 @@ import { RequestHandler } from 'express'; -import { WebUiDataRuntime } from '../helper/Data'; -const isEmpty = (data: any) => data === undefined || data === null || data === ''; +import { WebUiDataRuntime } from '@webapi/helper/Data'; +import { isEmpty } from '@webapi/utils/check'; +import { sendError, sendSuccess } from '@webapi/utils/response'; + +// 获取QQ登录二维码 export const QQGetQRcodeHandler: RequestHandler = async (req, res) => { + // 判断是否已经登录 if (await WebUiDataRuntime.getQQLoginStatus()) { - res.send({ - code: -1, - message: 'QQ Is Logined', - }); - return; + // 已经登录 + return sendError(res, 'QQ Is Logined'); } + // 获取二维码 const qrcodeUrl = await WebUiDataRuntime.getQQLoginQrcodeURL(); + // 判断二维码是否为空 if (isEmpty(qrcodeUrl)) { - res.send({ - code: -1, - message: 'QRCode Get Error', - }); - return; + return sendError(res, 'QRCode Get Error'); } - res.send({ - code: 0, - message: 'success', - data: { - qrcode: qrcodeUrl, - }, - }); - return; + // 返回二维码URL + const data = { + qrcode: qrcodeUrl, + }; + return sendSuccess(res, data); }; + +// 获取QQ登录状态 export const QQCheckLoginStatusHandler: RequestHandler = async (req, res) => { - res.send({ - code: 0, - message: 'success', - data: { - isLogin: await WebUiDataRuntime.getQQLoginStatus(), - qrcodeurl: await WebUiDataRuntime.getQQLoginQrcodeURL() - }, - }); + const data = { + isLogin: await WebUiDataRuntime.getQQLoginStatus(), + qrcodeurl: await WebUiDataRuntime.getQQLoginQrcodeURL(), + }; + return sendSuccess(res, data); }; + +// 快速登录 export const QQSetQuickLoginHandler: RequestHandler = async (req, res) => { + // 获取QQ号 const { uin } = req.body; + // 判断是否已经登录 const isLogin = await WebUiDataRuntime.getQQLoginStatus(); if (isLogin) { - res.send({ - code: -1, - message: 'QQ Is Logined', - }); - return; + return sendError(res, 'QQ Is Logined'); } + // 判断QQ号是否为空 if (isEmpty(uin)) { - res.send({ - code: -1, - message: 'uin is empty', - }); - return; + return sendError(res, 'uin is empty'); } + + // 获取快速登录状态 const { result, message } = await WebUiDataRuntime.requestQuickLogin(uin); if (!result) { - res.send({ - code: -1, - message: message, - }); - return; + return sendError(res, message); } //本来应该验证 但是http不宜这么搞 建议前端验证 //isLogin = await WebUiDataRuntime.getQQLoginStatus(); - res.send({ - code: 0, - message: 'success', - }); + return sendSuccess(res, null); }; -export const QQGetQuickLoginListHandler: RequestHandler = async (req, res) => { + +// 获取快速登录列表 +export const QQGetQuickLoginListHandler: RequestHandler = async (_, res) => { const quickLoginList = await WebUiDataRuntime.getQQQuickLoginList(); - res.send({ - code: 0, - data: quickLoginList, - }); + return sendSuccess(res, quickLoginList); }; diff --git a/src/webui/src/const/status.ts b/src/webui/src/const/status.ts new file mode 100644 index 00000000..449fb416 --- /dev/null +++ b/src/webui/src/const/status.ts @@ -0,0 +1,13 @@ +export enum HttpStatusCode { + OK = 200, + BadRequest = 400, + Unauthorized = 401, + Forbidden = 403, + NotFound = 404, + InternalServerError = 500, +} + +export enum ResponseCode { + Success = 0, + Error = -1, +} diff --git a/src/webui/src/helper/Data.ts b/src/webui/src/helper/Data.ts index 9563e6f7..8c23153d 100644 --- a/src/webui/src/helper/Data.ts +++ b/src/webui/src/helper/Data.ts @@ -1,18 +1,5 @@ import { OneBotConfig } from '@/onebot/config/config'; -interface LoginRuntimeType { - LoginCurrentTime: number; - LoginCurrentRate: number; - QQLoginStatus: boolean; - QQQRCodeURL: string; - QQLoginUin: string; - NapCatHelper: { - onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string }>; - onOB11ConfigChanged: (ob11: OneBotConfig) => Promise; - QQLoginList: string[]; - }; -} - const LoginRuntime: LoginRuntimeType = { LoginCurrentTime: Date.now(), LoginCurrentRate: 0, diff --git a/src/webui/src/helper/SignToken.ts b/src/webui/src/helper/SignToken.ts index 22cccd70..72860060 100644 --- a/src/webui/src/helper/SignToken.ts +++ b/src/webui/src/helper/SignToken.ts @@ -1,15 +1,5 @@ import crypto from 'crypto'; -interface WebUiCredentialInnerJson { - CreatedTime: number; - TokenEncoded: string; -} - -interface WebUiCredentialJson { - Data: WebUiCredentialInnerJson; - Hmac: string; -} - export class AuthHelper { private static readonly secretKey = Math.random().toString(36).slice(2); @@ -24,9 +14,7 @@ export class AuthHelper { TokenEncoded: token, }; const jsonString = JSON.stringify(innerJson); - const hmac = crypto.createHmac('sha256', AuthHelper.secretKey) - .update(jsonString, 'utf8') - .digest('hex'); + const hmac = crypto.createHmac('sha256', AuthHelper.secretKey).update(jsonString, 'utf8').digest('hex'); return { Data: innerJson, Hmac: hmac }; } @@ -38,7 +26,8 @@ export class AuthHelper { public static async checkCredential(credentialJson: WebUiCredentialJson): Promise { try { const jsonString = JSON.stringify(credentialJson.Data); - const calculatedHmac = crypto.createHmac('sha256', AuthHelper.secretKey) + const calculatedHmac = crypto + .createHmac('sha256', AuthHelper.secretKey) .update(jsonString, 'utf8') .digest('hex'); return calculatedHmac === credentialJson.Hmac; @@ -53,7 +42,10 @@ export class AuthHelper { * @param credentialJson 已签名的凭证JSON对象。 * @returns 布尔值,表示凭证是否有效且token匹配。 */ - public static async validateCredentialWithinOneHour(token: string, credentialJson: WebUiCredentialJson): Promise { + public static async validateCredentialWithinOneHour( + token: string, + credentialJson: WebUiCredentialJson + ): Promise { const isValid = await AuthHelper.checkCredential(credentialJson); if (!isValid) { return false; diff --git a/src/webui/src/helper/config.ts b/src/webui/src/helper/config.ts index 24ef6a63..32ce972c 100644 --- a/src/webui/src/helper/config.ts +++ b/src/webui/src/helper/config.ts @@ -3,7 +3,6 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import * as net from 'node:net'; import { resolve } from 'node:path'; - // 限制尝试端口的次数,避免死循环 const MAX_PORT_TRY = 100; @@ -64,14 +63,6 @@ async function tryUsePort(port: number, host: string, tryCount: number = 0): Pro }); } -export interface WebUiConfigType { - host: string; - port: number; - prefix: string; - token: string; - loginRate: number; -} - // 读取当前目录下名为 webui.json 的配置文件,如果不存在则创建初始化配置文件 export class WebUiConfigWrapper { WebUiConfigData: WebUiConfigType | undefined = undefined; @@ -114,14 +105,18 @@ export class WebUiConfigWrapper { // 不希望回写的配置放后面 // 查询主机地址是否可用 - const [host_err, host] = await tryUseHost(parsedConfig.host).then(data => [null, data]).catch(err => [err, null]); + 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]); + 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 @@ -137,4 +132,3 @@ export class WebUiConfigWrapper { return defaultconfig; // 理论上这行代码到不了,到了只能返回默认配置了 } } - diff --git a/src/webui/src/middleware/auth.ts b/src/webui/src/middleware/auth.ts new file mode 100644 index 00000000..64e21b50 --- /dev/null +++ b/src/webui/src/middleware/auth.ts @@ -0,0 +1,46 @@ +import { NextFunction, Request, Response } from 'express'; + +import { WebUiConfig } from '@/webui'; + +import { AuthHelper } from '@webapi/helper/SignToken'; +import { sendError } from '@webapi/utils/response'; + +// 鉴权中间件 +export async function auth(req: Request, res: Response, next: NextFunction) { + // 判断当前url是否为/login 如果是跳过鉴权 + if (req.url == '/auth/login') { + return next(); + } + + // 判断是否有Authorization头 + if (req.headers?.authorization) { + // 切割参数以获取token + const authorization = req.headers.authorization.split(' '); + // 当Bearer后面没有参数时 + if (authorization.length < 2) { + return sendError(res, 'Unauthorized'); + } + // 获取token + const token = authorization[1]; + // 解析token + let Credential: WebUiCredentialJson; + try { + Credential = JSON.parse(Buffer.from(token, 'base64').toString('utf-8')); + } catch (e) { + return sendError(res, 'Unauthorized'); + } + // 获取配置 + const config = await WebUiConfig.GetWebUIConfig(); + // 验证凭证在1小时内有效且token与原始token相同 + const credentialJson = await AuthHelper.validateCredentialWithinOneHour(config.token, Credential); + if (credentialJson) { + // 通过验证 + return next(); + } + // 验证失败 + return sendError(res, 'Unauthorized'); + } + + // 没有Authorization头 + return sendError(res, 'Unauthorized'); +} diff --git a/src/webui/src/middleware/cors.ts b/src/webui/src/middleware/cors.ts new file mode 100644 index 00000000..243ffc06 --- /dev/null +++ b/src/webui/src/middleware/cors.ts @@ -0,0 +1,9 @@ +import type { RequestHandler } from 'express'; + +// CORS 中间件,跨域用 +export const cors: RequestHandler = (_, res, next) => { + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization'); + next(); +}; diff --git a/src/webui/src/router/OB11Config.ts b/src/webui/src/router/OB11Config.ts index 75eee681..7395dbe0 100644 --- a/src/webui/src/router/OB11Config.ts +++ b/src/webui/src/router/OB11Config.ts @@ -1,7 +1,11 @@ import { Router } from 'express'; -import { OB11GetConfigHandler, OB11SetConfigHandler } from '../api/OB11Config'; + +import { OB11GetConfigHandler, OB11SetConfigHandler } from '@webapi/api/OB11Config'; const router = Router(); +// router:读取配置 router.post('/GetConfig', OB11GetConfigHandler); +// router:写入配置 router.post('/SetConfig', OB11SetConfigHandler); + export { router as OB11ConfigRouter }; diff --git a/src/webui/src/router/QQLogin.ts b/src/webui/src/router/QQLogin.ts index 762e5ed1..7af23dbd 100644 --- a/src/webui/src/router/QQLogin.ts +++ b/src/webui/src/router/QQLogin.ts @@ -1,14 +1,20 @@ import { Router } from 'express'; + import { QQCheckLoginStatusHandler, QQGetQRcodeHandler, QQGetQuickLoginListHandler, QQSetQuickLoginHandler, -} from '../api/QQLogin'; +} from '@webapi/api/QQLogin'; const router = Router(); +// router:获取快速登录列表 router.all('/GetQuickLoginList', QQGetQuickLoginListHandler); +// router:检查QQ登录状态 router.post('/CheckLoginStatus', QQCheckLoginStatusHandler); +// router:获取QQ登录二维码 router.post('/GetQQLoginQrcode', QQGetQRcodeHandler); +// router:设置QQ快速登录 router.post('/SetQuickLogin', QQSetQuickLoginHandler); + export { router as QQLoginRouter }; diff --git a/src/webui/src/router/auth.ts b/src/webui/src/router/auth.ts index f523ef11..45d32dd2 100644 --- a/src/webui/src/router/auth.ts +++ b/src/webui/src/router/auth.ts @@ -1,9 +1,13 @@ import { Router } from 'express'; -import { checkHandler, LoginHandler, LogoutHandler } from '../api/Auth'; + +import { checkHandler, LoginHandler, LogoutHandler } from '@webapi/api/Auth'; const router = Router(); - +// router:登录 router.post('/login', LoginHandler); +// router:检查登录状态 router.post('/check', checkHandler); +// router:注销 router.post('/logout', LogoutHandler); + export { router as AuthRouter }; diff --git a/src/webui/src/router/index.ts b/src/webui/src/router/index.ts index 19b419f2..419165d9 100644 --- a/src/webui/src/router/index.ts +++ b/src/webui/src/router/index.ts @@ -1,67 +1,30 @@ -import { NextFunction, Request, Response, Router } from 'express'; -import { AuthHelper } from '../../src/helper/SignToken'; -import { QQLoginRouter } from './QQLogin'; -import { AuthRouter } from './auth'; -import { OB11ConfigRouter } from './OB11Config'; -import { WebUiConfig } from '@/webui'; +/** + * @file 所有路由的入口文件 + */ + +import { Router } from 'express'; + +import { OB11ConfigRouter } from '@webapi/router/OB11Config'; +import { auth } from '@webapi/middleware/auth'; +import { sendSuccess } from '@webapi/utils/response'; + +import { QQLoginRouter } from '@webapi/router/QQLogin'; +import { AuthRouter } from '@webapi/router/auth'; const router = Router(); -export async function AuthApi(req: Request, res: Response, next: NextFunction) { - //判断当前url是否为/login 如果是跳过鉴权 - if (req.url == '/auth/login') { - next(); - return; - } - if (req.headers?.authorization) { - const authorization = req.headers.authorization.split(' '); - if (authorization.length < 2) { - res.json({ - code: -1, - msg: 'Unauthorized', - }); - return; - } - const token = authorization[1]; - let Credential: any; - try { - Credential = JSON.parse(Buffer.from(token, 'base64').toString('utf-8')); - } catch (e) { - res.json({ - code: -1, - msg: 'Unauthorized', - }); - return; - } - const config = await WebUiConfig.GetWebUIConfig(); - const credentialJson = await AuthHelper.validateCredentialWithinOneHour(config.token, Credential); - if (credentialJson) { - //通过验证 - next(); - return; - } - res.json({ - code: -1, - msg: 'Unauthorized', - }); - return; - } +// 鉴权中间件 +router.use(auth); - res.json({ - code: -1, - msg: 'Server Error', - }); - return; -} - -router.use(AuthApi); -router.all('/test', (req, res) => { - res.json({ - code: 0, - msg: 'ok', - }); +// router:测试用 +router.all('/test', (_, res) => { + return sendSuccess(res); }); +// router:WebUI登录相关路由 router.use('/auth', AuthRouter); +// router:QQ登录相关路由 router.use('/QQLogin', QQLoginRouter); +// router:OB11配置相关路由 router.use('/OB11Config', OB11ConfigRouter); + export { router as ALLRouter }; diff --git a/src/webui/src/types/config.d.ts b/src/webui/src/types/config.d.ts new file mode 100644 index 00000000..432691e3 --- /dev/null +++ b/src/webui/src/types/config.d.ts @@ -0,0 +1,7 @@ +interface WebUiConfigType { + host: string; + port: number; + prefix: string; + token: string; + loginRate: number; +} diff --git a/src/webui/src/types/data.d.ts b/src/webui/src/types/data.d.ts new file mode 100644 index 00000000..b0bb1217 --- /dev/null +++ b/src/webui/src/types/data.d.ts @@ -0,0 +1,12 @@ +interface LoginRuntimeType { + LoginCurrentTime: number; + LoginCurrentRate: number; + QQLoginStatus: boolean; + QQQRCodeURL: string; + QQLoginUin: string; + NapCatHelper: { + onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string }>; + onOB11ConfigChanged: (ob11: OneBotConfig) => Promise; + QQLoginList: string[]; + }; +} diff --git a/src/webui/src/types/server.d.ts b/src/webui/src/types/server.d.ts new file mode 100644 index 00000000..40deb63c --- /dev/null +++ b/src/webui/src/types/server.d.ts @@ -0,0 +1,7 @@ +interface APIResponse { + code: number; + message: string; + data: T; +} + +type Protocol = 'http' | 'https' | 'ws' | 'wss'; diff --git a/src/webui/src/types/sign_token.d.ts b/src/webui/src/types/sign_token.d.ts new file mode 100644 index 00000000..5bd79b69 --- /dev/null +++ b/src/webui/src/types/sign_token.d.ts @@ -0,0 +1,9 @@ +interface WebUiCredentialInnerJson { + CreatedTime: number; + TokenEncoded: string; +} + +interface WebUiCredentialJson { + Data: WebUiCredentialInnerJson; + Hmac: string; +} diff --git a/src/webui/src/utils/check.ts b/src/webui/src/utils/check.ts new file mode 100644 index 00000000..0c532fc7 --- /dev/null +++ b/src/webui/src/utils/check.ts @@ -0,0 +1 @@ +export const isEmpty = (data: T) => data === undefined || data === null || data === ''; diff --git a/src/webui/src/utils/response.ts b/src/webui/src/utils/response.ts new file mode 100644 index 00000000..eca6ee72 --- /dev/null +++ b/src/webui/src/utils/response.ts @@ -0,0 +1,26 @@ +import type { Response } from 'express'; + +import { ResponseCode, HttpStatusCode } from '@webapi/const/status'; + +export const sendResponse = (res: Response, data?: T, code: ResponseCode = 0, message = 'success') => { + res.status(HttpStatusCode.OK).json({ + code, + message, + data, + }); +}; + +export const sendError = (res: Response, message = 'error') => { + res.status(HttpStatusCode.OK).json({ + code: ResponseCode.Error, + message, + }); +}; + +export const sendSuccess = (res: Response, data?: T, message = 'success') => { + res.status(HttpStatusCode.OK).json({ + code: ResponseCode.Success, + data, + message, + }); +}; diff --git a/src/webui/src/utils/url.ts b/src/webui/src/utils/url.ts new file mode 100644 index 00000000..d7e80dbd --- /dev/null +++ b/src/webui/src/utils/url.ts @@ -0,0 +1,47 @@ +/** + * @file URL工具 + */ + +import { isIP } from 'node:net'; + +/** + * 将 host(主机地址) 转换为标准格式 + * @param host 主机地址 + * @returns 标准格式的IP地址 + * @example normalizeHost('10.0.3.2') => '10.0.3.2' + * @example normalizeHost('0.0.0.0') => '127.0.0.1' + * @example normalizeHost('2001:4860:4801:51::27') => '[2001:4860:4801:51::27]' + */ +export const normalizeHost = (host: string) => { + if (host === '0.0.0.0') return '127.0.0.1'; + if (isIP(host) === 6) return `[${host}]`; + return host; +}; + +/** + * 创建URL + * @param host 主机地址 + * @param port 端口 + * @param path URL路径 + * @param search URL参数 + * @returns 完整URL + * @example createUrl('127.0.0.1', '8080', '/api', { token: '123456' }) => 'http://127.0.0.1:8080/api?token=123456' + * @example createUrl('baidu.com', '80', void 0, void 0, 'https') => 'https://baidu.com:80/' + */ +export const createUrl = ( + host: string, + port: string, + path = '/', + search?: Record, + protocol: Protocol = 'http' +) => { + const url = new URL(`${protocol}://${normalizeHost(host)}`); + url.port = port; + url.pathname = path; + if (search) { + for (const key in search) { + url.searchParams.set(key, search[key]); + } + } + return url.toString(); +}; diff --git a/tsconfig.json b/tsconfig.json index 66a54c36..2e018434 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,34 +1,37 @@ -{ - "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "module": "ESNext", - "lib": [ - "ES2020", - "DOM", - "DOM.Iterable" - ], - "skipLibCheck": true, - "moduleResolution": "Node", - "experimentalDecorators": true, - "allowImportingTsExtensions": false, - "allowSyntheticDefaultImports": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "preserve", - "strict": true, - "noUnusedLocals": false, - "noUnusedParameters": false, - "noFallthroughCasesInSwitch": true, - "sourceMap": true, - "paths": { - "@*": [ - "./src*" - ] - } - }, - "include": [ - "src/**/*.ts" - ] -} +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": [ + "ES2020", + "DOM", + "DOM.Iterable" + ], + "skipLibCheck": true, + "moduleResolution": "Node", + "experimentalDecorators": true, + "allowImportingTsExtensions": false, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "sourceMap": true, + "paths": { + "@/*": [ + "./src/*" + ], + "@webapi/*": [ + "./src/webui/src/*" + ], + } + }, + "include": [ + "src/**/*.ts" + ] +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index d83f3deb..d3502f50 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -50,59 +50,63 @@ const ShellBaseConfigPlugin: PluginOption[] = [ nodeResolve(), ]; -const ShellBaseConfig = () => defineConfig({ - resolve: { - conditions: ['node', 'default'], - alias: { - '@/core': resolve(__dirname, './src/core'), - '@': resolve(__dirname, './src'), - './lib-cov/fluent-ffmpeg': './lib/fluent-ffmpeg', - }, - }, - build: { - sourcemap: false, - target: 'esnext', - minify: false, - lib: { - entry: { - 'napcat': 'src/shell/napcat.ts', - 'audio-worker': 'src/common/audio-worker.ts', +const ShellBaseConfig = () => + defineConfig({ + resolve: { + conditions: ['node', 'default'], + alias: { + '@/core': resolve(__dirname, './src/core'), + '@': resolve(__dirname, './src'), + './lib-cov/fluent-ffmpeg': './lib/fluent-ffmpeg', + '@webapi': resolve(__dirname, './src/webui/src'), }, - formats: ['es'], - fileName: (_, entryName) => `${entryName}.mjs`, }, - rollupOptions: { - external: [...nodeModules, ...external], + build: { + sourcemap: false, + target: 'esnext', + minify: false, + lib: { + entry: { + napcat: 'src/shell/napcat.ts', + 'audio-worker': 'src/common/audio-worker.ts', + }, + formats: ['es'], + fileName: (_, entryName) => `${entryName}.mjs`, + }, + rollupOptions: { + external: [...nodeModules, ...external], + }, }, - }, -}); + }); -const FrameworkBaseConfig = () => defineConfig({ - resolve: { - conditions: ['node', 'default'], - alias: { - '@/core': resolve(__dirname, './src/core'), - '@': resolve(__dirname, './src'), - './lib-cov/fluent-ffmpeg': './lib/fluent-ffmpeg', - }, - }, - build: { - sourcemap: false, - target: 'esnext', - minify: false, - lib: { - entry: { - 'napcat': 'src/framework/napcat.ts', - 'audio-worker': 'src/common/audio-worker.ts', +const FrameworkBaseConfig = () => + defineConfig({ + resolve: { + conditions: ['node', 'default'], + alias: { + '@/core': resolve(__dirname, './src/core'), + '@': resolve(__dirname, './src'), + './lib-cov/fluent-ffmpeg': './lib/fluent-ffmpeg', + '@webapi': resolve(__dirname, './src/webui/src'), }, - formats: ['es'], - fileName: (_, entryName) => `${entryName}.mjs`, }, - rollupOptions: { - external: [...nodeModules, ...external], + build: { + sourcemap: false, + target: 'esnext', + minify: false, + lib: { + entry: { + napcat: 'src/framework/napcat.ts', + 'audio-worker': 'src/common/audio-worker.ts', + }, + formats: ['es'], + fileName: (_, entryName) => `${entryName}.mjs`, + }, + rollupOptions: { + external: [...nodeModules, ...external], + }, }, - }, -}); + }); export default defineConfig(({ mode }): UserConfig => { if (mode === 'shell') {