mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2025-12-18 20:30:08 +08:00
287 lines
9.5 KiB
TypeScript
287 lines
9.5 KiB
TypeScript
/**
|
||
* @file WebUI服务入口文件
|
||
*/
|
||
|
||
import express from 'express';
|
||
import type { WebUiConfigType } from './src/types';
|
||
import { createServer } from 'http';
|
||
import { randomUUID } from 'node:crypto';
|
||
import { createServer as createHttpsServer } from 'https';
|
||
import { LogWrapper } from 'napcat-common/src/log';
|
||
import { NapCatPathWrapper } from 'napcat-common/src/path';
|
||
import { WebUiConfigWrapper } from '@/napcat-webui-backend/src/helper/config';
|
||
import { ALLRouter } from '@/napcat-webui-backend/src/router';
|
||
import { cors } from '@/napcat-webui-backend/src/middleware/cors';
|
||
import { createUrl, getRandomToken } from '@/napcat-webui-backend/src/utils/url';
|
||
import { sendError } from '@/napcat-webui-backend/src/utils/response';
|
||
import { join } from 'node:path';
|
||
import { terminalManager } from '@/napcat-webui-backend/src/terminal/terminal_manager';
|
||
import multer from 'multer';
|
||
import * as net from 'node:net';
|
||
import { WebUiDataRuntime } from './src/helper/Data';
|
||
import { existsSync, readFileSync } from 'node:fs'; // 引入multer用于错误捕获
|
||
|
||
// 实例化Express
|
||
const app = express();
|
||
/**
|
||
* 初始化并启动WebUI服务。
|
||
* 该函数配置了Express服务器以支持JSON解析和静态文件服务,并监听6099端口。
|
||
* 无需参数。
|
||
* @returns {Promise<void>} 无返回值。
|
||
*/
|
||
export let WebUiConfig: WebUiConfigWrapper;
|
||
export let webUiPathWrapper: NapCatPathWrapper;
|
||
const MAX_PORT_TRY = 100;
|
||
|
||
export let webUiRuntimePort = 6099;
|
||
// 全局变量:存储需要在QQ登录成功后发送的新token
|
||
export let pendingTokenToSend: string | null = null;
|
||
|
||
/**
|
||
* 存储WebUI启动时的初始token,用于鉴权
|
||
* - 无论是否在运行时修改密码,都应该使用此token进行鉴权
|
||
* - 运行时手动修改的密码将会在下次napcat重启后生效
|
||
* - 如果需要在运行时修改密码并立即生效,则需要在前端调用路由进行修改
|
||
*/
|
||
let initialWebUiToken: string = '';
|
||
|
||
export function setInitialWebUiToken (token: string) {
|
||
initialWebUiToken = token;
|
||
}
|
||
|
||
export function getInitialWebUiToken (): string {
|
||
return initialWebUiToken;
|
||
}
|
||
|
||
export function setPendingTokenToSend (token: string | null) {
|
||
pendingTokenToSend = token;
|
||
}
|
||
|
||
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, randomUUID()];
|
||
}
|
||
}
|
||
|
||
async function checkCertificates (logger: LogWrapper): Promise<{ key: string, cert: string } | null> {
|
||
try {
|
||
const certPath = join(webUiPathWrapper.configPath, 'cert.pem');
|
||
const keyPath = join(webUiPathWrapper.configPath, 'key.pem');
|
||
|
||
if (existsSync(certPath) && existsSync(keyPath)) {
|
||
const cert = readFileSync(certPath, 'utf8');
|
||
const key = readFileSync(keyPath, 'utf8');
|
||
logger.log('[NapCat] [WebUi] 找到SSL证书,将启用HTTPS模式');
|
||
return { cert, key };
|
||
}
|
||
return null;
|
||
} catch (error) {
|
||
logger.log('[NapCat] [WebUi] 检查SSL证书时出错: ' + error);
|
||
return null;
|
||
}
|
||
}
|
||
export async function InitWebUi (logger: LogWrapper, pathWrapper: NapCatPathWrapper) {
|
||
webUiPathWrapper = pathWrapper;
|
||
WebUiConfig = new WebUiConfigWrapper();
|
||
let config = await WebUiConfig.GetWebUIConfig();
|
||
|
||
// 检查并更新默认密码 - 最高优先级
|
||
if (config.token === 'napcat' || !config.token) {
|
||
const randomToken = getRandomToken(8);
|
||
await WebUiConfig.UpdateWebUIConfig({ token: randomToken });
|
||
logger.log('[NapCat] [WebUi] 检测到默认密码,已自动更新为安全密码');
|
||
|
||
// 存储token到全局变量,等待QQ登录成功后发送
|
||
setPendingTokenToSend(randomToken);
|
||
logger.log('[NapCat] [WebUi] 新密码将在QQ登录成功后发送给用户');
|
||
|
||
// 重新获取更新后的配置
|
||
config = await WebUiConfig.GetWebUIConfig();
|
||
}
|
||
|
||
// 存储启动时的初始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);
|
||
webUiRuntimePort = port;
|
||
if (port === 0) {
|
||
logger.log('[NapCat] [WebUi] Current WebUi is not run.');
|
||
return;
|
||
}
|
||
WebUiDataRuntime.setWebUiConfigQuickFunction(
|
||
async () => {
|
||
const autoLoginAccount = process.env['NAPCAT_QUICK_ACCOUNT'] || WebUiConfig.getAutoLoginAccount();
|
||
if (autoLoginAccount) {
|
||
try {
|
||
const { result, message } = await WebUiDataRuntime.requestQuickLogin(autoLoginAccount);
|
||
if (!result) {
|
||
throw new Error(message);
|
||
}
|
||
console.log(`[NapCat] [WebUi] Auto login account: ${autoLoginAccount}`);
|
||
} catch (error) {
|
||
console.log('[NapCat] [WebUi] Auto login account failed.' + error);
|
||
}
|
||
}
|
||
});
|
||
// ------------注册中间件------------
|
||
// 使用express的json中间件
|
||
app.use(express.json());
|
||
|
||
// CORS中间件
|
||
// TODO:
|
||
app.use(cors);
|
||
|
||
// 如果是webui字体文件,挂载字体文件
|
||
app.use('/webui/fonts/AaCute.woff', async (_req, res, next) => {
|
||
const isFontExist = await WebUiConfig.CheckWebUIFontExist();
|
||
if (isFontExist) {
|
||
res.sendFile(WebUiConfig.GetWebUIFontPath());
|
||
} else {
|
||
next();
|
||
}
|
||
});
|
||
|
||
// 如果是自定义色彩,构建一个css文件
|
||
app.use('/files/theme.css', async (_req, res) => {
|
||
const colors = await WebUiConfig.GetTheme();
|
||
|
||
let css = ':root, .light, [data-theme="light"] {';
|
||
for (const key in colors.light) {
|
||
css += `${key}: ${colors.light[key]};`;
|
||
}
|
||
css += '}';
|
||
css += '.dark, [data-theme="dark"] {';
|
||
for (const key in colors.dark) {
|
||
css += `${key}: ${colors.dark[key]};`;
|
||
}
|
||
css += '}';
|
||
|
||
res.send(css);
|
||
});
|
||
|
||
// ------------中间件结束------------
|
||
|
||
// ------------挂载路由------------
|
||
// 挂载静态路由(前端),路径为 /webui
|
||
app.use('/webui', express.static(pathWrapper.staticPath, {
|
||
maxAge: '1d',
|
||
}));
|
||
// 初始化WebSocket服务器
|
||
const sslCerts = await checkCertificates(logger);
|
||
const isHttps = !!sslCerts;
|
||
const server = isHttps && sslCerts ? createHttpsServer(sslCerts, app) : createServer(app);
|
||
server.on('upgrade', (request, socket, head) => {
|
||
terminalManager.initialize(request, socket, head, logger);
|
||
});
|
||
// 挂载API接口
|
||
app.use('/api', ALLRouter);
|
||
// 所有剩下的请求都转到静态页面
|
||
const indexFile = join(pathWrapper.staticPath, 'index.html');
|
||
|
||
app.all(/\/webui\/(.*)/, (_req, res) => {
|
||
res.sendFile(indexFile);
|
||
});
|
||
|
||
// 初始服务(先放个首页)
|
||
app.all('/', (_req, res) => {
|
||
res.status(301).header('Location', '/webui').send();
|
||
});
|
||
|
||
// 错误处理中间件,捕获multer的错误
|
||
app.use((err: Error, _: express.Request, res: express.Response, next: express.NextFunction) => {
|
||
if (err instanceof multer.MulterError) {
|
||
return sendError(res, err.message, true);
|
||
}
|
||
next(err);
|
||
});
|
||
|
||
// 全局错误处理中间件(非multer错误)
|
||
app.use((_: Error, __: express.Request, res: express.Response, ___: express.NextFunction) => {
|
||
sendError(res, 'An unknown error occurred.', true);
|
||
});
|
||
|
||
// ------------启动服务------------
|
||
server.listen(port, host, async () => {
|
||
const searchParams = { token };
|
||
logger.log(`[NapCat] [WebUi] WebUi Token: ${token}`);
|
||
logger.log(
|
||
`[NapCat] [WebUi] WebUi User Panel Url: ${createUrl('127.0.0.1', port.toString(), '/webui', searchParams)}`
|
||
);
|
||
if (host !== '') {
|
||
logger.log(
|
||
`[NapCat] [WebUi] WebUi User Panel Url: ${createUrl(host, port.toString(), '/webui', searchParams)}`
|
||
);
|
||
}
|
||
});
|
||
|
||
// ------------Over!------------
|
||
}
|
||
|
||
async function tryUseHost (host: string): Promise<string> {
|
||
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<number> {
|
||
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}`));
|
||
}
|
||
});
|
||
}
|