Merge pull request #153 from Guation/main

feat: WebUI支持放置到二级目录中
This commit is contained in:
手瓜一十雪 2024-07-26 13:04:26 +08:00 committed by GitHub
commit 1dc844435a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 110 additions and 60 deletions

View File

@ -27,18 +27,19 @@ export async function InitWebUi() {
}
app.use(express.json());
// 初始服务
app.all('/', (_req, res) => {
// WebUI只在config.prefix所示路径上提供服务可配合Nginx挂载到子目录中
app.all(config.prefix + '/', (_req, res) => {
res.json({
msg: 'NapCat WebAPI is now running!',
});
});
// 配置静态文件服务,提供./static目录下的文件服务访问路径为/webui
app.use('/webui', express.static(resolve(__dirname, './static')));
app.use(config.prefix + '/webui', express.static(resolve(__dirname, './static')));
//挂载API接口
app.use('/api', ALLRouter);
app.listen(config.port, async () => {
log(`[NapCat] [WebUi] Current WebUi is running at IP:${config.port}`);
app.use(config.prefix + '/api', ALLRouter);
app.listen(config.port, config.host, async () => {
log(`[NapCat] [WebUi] Current WebUi is running at http://${config.host}:${config.port}${config.prefix}`);
log(`[NapCat] [WebUi] Login URL is http://${config.host}:${config.port}${config.prefix}/webui`);
log(`[NapCat] [WebUi] Login Token is ${config.token}`);
});
}
}

View File

@ -12,7 +12,33 @@ const __dirname = dirname(__filename);
// 限制尝试端口的次数,避免死循环
const MAX_PORT_TRY = 100;
async function tryUsePort(port: number, tryCount: number = 0): Promise<number> {
async function tryUseHost(host: string): Promise<string> {
return new Promise(async (resolve, reject) => {
try {
const server = net.createServer();
server.on('listening', () => {
server.close();
resolve(host);
});
server.on('error', (err: any) => {
if (err.code === 'EADDRNOTAVAIL') {
reject("主机地址验证失败,可能为非本机地址");
} else {
reject(`遇到错误: ${err.code}`);
}
});
// 尝试监听 让系统随机分配一个端口
server.listen(0, host);
} catch (error) {
// 这里捕获到的错误应该是启动服务器时的同步错误
reject(`服务器启动时发生错误: ${error}`);
}
});
}
async function tryUsePort(port: number, host: string, tryCount: number = 0): Promise<number> {
return new Promise(async (resolve, reject) => {
try {
const server = net.createServer();
@ -25,7 +51,7 @@ async function tryUsePort(port: number, tryCount: number = 0): Promise<number> {
if (err.code === 'EADDRINUSE') {
if (tryCount < MAX_PORT_TRY) {
// 使用循环代替递归
resolve(tryUsePort(port + 1, tryCount + 1));
resolve(tryUsePort(port + 1, host, tryCount + 1));
} else {
reject(`端口尝试失败,达到最大尝试次数: ${MAX_PORT_TRY}`);
}
@ -35,7 +61,7 @@ async function tryUsePort(port: number, tryCount: number = 0): Promise<number> {
});
// 尝试监听端口
server.listen(port);
server.listen(port, host);
} catch (error) {
// 这里捕获到的错误应该是启动服务器时的同步错误
reject(`服务器启动时发生错误: ${error}`);
@ -44,44 +70,73 @@ async function tryUsePort(port: number, tryCount: number = 0): Promise<number> {
}
export interface WebUiConfigType {
host: string;
port: number;
prefix: string;
token: string;
loginRate: number
}
// 读取当前目录下名为 webui.json 的配置文件,如果不存在则创建初始化配置文件
class WebUiConfigWrapper {
WebUiConfigData: WebUiConfigType | undefined = undefined;
private applyDefaults<T>(obj: Partial<T>, defaults: T): T {
return { ...defaults, ...obj };
}
async GetWebUIConfig(): Promise<WebUiConfigType> {
if (this.WebUiConfigData) {
return this.WebUiConfigData;
}
const defaultconfig: WebUiConfigType = {
host: "0.0.0.0",
port: 6099,
prefix: "",
token: "", // 默认先填空,空密码无法登录
loginRate: 3
};
try {
defaultconfig.token = Math.random().toString(36).slice(2); //生成随机密码
} catch (e) {
logError('随机密码生成失败', e);
}
try {
const configPath = resolve(__dirname, './config/webui.json');
const config: WebUiConfigType = {
port: 6099,
token: Math.random().toString(36).slice(2),//生成随机密码
loginRate: 3
};
if (!existsSync(configPath)) {
writeFileSync(configPath, JSON.stringify(config, null, 4));
writeFileSync(configPath, JSON.stringify(defaultconfig, null, 4));
}
const fileContent = readFileSync(configPath, 'utf-8');
const parsedConfig = JSON.parse(fileContent) as WebUiConfigType;
// 更新配置字段后新增字段可能会缺失,同步一下
const parsedConfig = this.applyDefaults(JSON.parse(fileContent) as Partial<WebUiConfigType>, defaultconfig);
// 修正端口占用情况
const [err, data] = await tryUsePort(parsedConfig.port).then(data => [null, data as number]).catch(err => [err, null]);
parsedConfig.port = data;
if (err) {
//一般没那么离谱 如果真有这么离谱 考虑下 向外抛出异常
if (!parsedConfig.prefix.startsWith("/")) parsedConfig.prefix = "/" + parsedConfig.prefix;
if (parsedConfig.prefix.endsWith("/")) parsedConfig.prefix = parsedConfig.prefix.slice(0, -1);
// 配置已经被操作过了,还是回写一下吧,不然新配置不会出现在配置文件里
writeFileSync(configPath, JSON.stringify(parsedConfig, null, 4));
// 不希望回写的配置放后面
// 查询主机地址是否可用
const [host_err, host] = await tryUseHost(parsedConfig.host).then(data => [null, data as string]).catch(err => [err, null]);
if (host_err) {
logError("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 as number]).catch(err => [err, null]);
if (port_err) {
logError("port不可用", port_err)
parsedConfig.port = 0; // 设置为0禁用WebUI
} else {
parsedConfig.port = port;
}
}
this.WebUiConfigData = parsedConfig;
return this.WebUiConfigData;
} catch (e) {
logError('读取配置文件失败', e);
}
return {} as WebUiConfigType; // 理论上这行代码到不了,为了保持函数完整性而保留
return defaultconfig; // 理论上这行代码到不了,到了只能返回默认配置了
}
}
export const WebUiConfig = new WebUiConfigWrapper();
export const WebUiConfig = new WebUiConfigWrapper();

View File

@ -15,7 +15,7 @@ async function onSettingWindowCreated(view: Element) {
} else if (configKey.length === 3) {
ob11Config[configKey[1]][configKey[2]] = value;
}
OB11ConfigWrapper.SetOB11Config(ob11Config);
// OB11ConfigWrapper.SetOB11Config(ob11Config); // 只有当点保存时才下发配置,而不是在修改值后立即下发
};
const parser = new DOMParser();
@ -192,7 +192,7 @@ async function onSettingWindowCreated(view: Element) {
// 外链按钮
doc.querySelector('#open-github')?.addEventListener('click', () => {
window.open('https://napneko.github.io/', '_blank');
window.open('https://github.com/NapNeko/NapCatQQ', '_blank');
});
doc.querySelector('#open-telegram')?.addEventListener('click', () => {
window.open('https://t.me/+nLZEnpne-pQ1OWFl');
@ -201,7 +201,7 @@ async function onSettingWindowCreated(view: Element) {
window.open('https://qm.qq.com/q/bDnHRG38aI');
});
doc.querySelector('#open-docs')?.addEventListener('click', () => {
window.open('https://github.com/NapNeko/NapCatQQ');
window.open('https://napneko.github.io/', '_blank');
});
// 生成反向地址列表
const buildHostListItem = (

View File

@ -38,7 +38,7 @@ class WebUiApiOB11ConfigWrapper {
this.retCredential = Credential;
}
async GetOB11Config(): Promise<OB11Config> {
const ConfigResponse = await fetch('/api/OB11Config/GetConfig', {
const ConfigResponse = await fetch('../api/OB11Config/GetConfig', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + this.retCredential,
@ -54,7 +54,7 @@ class WebUiApiOB11ConfigWrapper {
return {} as OB11Config;
}
async SetOB11Config(config: OB11Config): Promise<boolean> {
const ConfigResponse = await fetch('/api/OB11Config/SetConfig', {
const ConfigResponse = await fetch('../api/OB11Config/SetConfig', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + this.retCredential,

View File

@ -1,5 +1,7 @@
{
"host": "0.0.0.0",
"port": 6099,
"prefix": "",
"token": "random",
"loginRate": 3

View File

@ -160,7 +160,7 @@
</div>
<script>
async function GetQQLoginQrcode(retCredential) {
let QQLoginResponse = await fetch('/api/QQLogin/GetQQLoginQrcode', {
let QQLoginResponse = await fetch('../api/QQLogin/GetQQLoginQrcode', {
method: 'POST',
headers: {
'Authorization': "Bearer " + retCredential,
@ -180,7 +180,7 @@
return "";
}
async function CheckQQLoginStatus(retCredential) {
let QQLoginResponse = await fetch('/api/QQLogin/CheckLoginStatus', {
let QQLoginResponse = await fetch('../api/QQLogin/CheckLoginStatus', {
method: 'POST',
headers: {
'Authorization': "Bearer " + retCredential,
@ -200,7 +200,7 @@
return false;
}
async function GetQQQucickLoginList(retCredential) {
let QQLoginResponse = await fetch('/api/QQLogin/GetQuickLoginList', {
let QQLoginResponse = await fetch('../api/QQLogin/GetQuickLoginList', {
method: 'POST',
headers: {
'Authorization': "Bearer " + retCredential,
@ -216,7 +216,7 @@
return [];
}
async function SetQuickLogin(uin, retCredential) {
let QQLoginResponse = await fetch('/api/QQLogin/SetQuickLogin', {
let QQLoginResponse = await fetch('../api/QQLogin/SetQuickLogin', {
method: 'POST',
headers: {
'Authorization': "Bearer " + retCredential,

View File

@ -1,7 +1,7 @@
const SettingList = (items, title, isCollapsible = false, direction = "column") => {
return `<setting-section ${title && !isCollapsible ? `data-title="${title}"` : ""}>
return `<setting-section ${""}>
<setting-panel>
<setting-list ${direction ? `data-direction="${direction}"` : ""} ${isCollapsible ? "is-collapsible" : ""} ${title && isCollapsible ? `data-title="${title}"` : ""}>
<setting-list ${direction ? `data-direction="${direction}"` : ""} ${isCollapsible ? "is-collapsible" : ""} ${""}>
${items.join("")}
</setting-list>
</setting-panel>
@ -66,16 +66,13 @@ window.customElements.define(
window[`${isHidden ? "remove" : "add"}EventListener`]("pointerdown", windowPointerDown);
};
const windowPointerDown = ({ target }) => {
if (!this.contains(target))
buttonClick();
if (!this.contains(target)) buttonClick();
};
this._button.addEventListener("click", buttonClick);
this._context.addEventListener("click", ({ target }) => {
if (target.tagName !== "SETTING-OPTION")
return;
if (target.tagName !== "SETTING-OPTION") return;
buttonClick();
if (target.hasAttribute("is-selected"))
return;
if (target.hasAttribute("is-selected")) return;
this.querySelectorAll("setting-option[is-selected]").forEach((dom) => dom.toggleAttribute("is-selected"));
target.toggleAttribute("is-selected");
this._text.value = target.textContent;
@ -95,9 +92,9 @@ window.customElements.define(
}
);
const SettingSelect = (items, configKey, configValue) => {
return `<ob-setting-select ${configKey ? `data-config-key="${configKey}"` : ""}>
return `<ob-setting-select ${`data-config-key="${configKey}"` }>
${items.map((e, i) => {
return SettingOption(e.text, e.value, configKey && configValue ? configValue === e.value : i === 0);
return SettingOption(e.text, e.value, configValue ? configValue === e.value : i === 0);
}).join("")}
</ob-setting-select>`;
};
@ -108,7 +105,7 @@ class WebUiApiOB11ConfigWrapper {
this.retCredential = Credential;
}
async GetOB11Config() {
const ConfigResponse = await fetch("/api/OB11Config/GetConfig", {
const ConfigResponse = await fetch("../api/OB11Config/GetConfig", {
method: "POST",
headers: {
Authorization: "Bearer " + this.retCredential,
@ -124,7 +121,7 @@ class WebUiApiOB11ConfigWrapper {
return {};
}
async SetOB11Config(config) {
const ConfigResponse = await fetch("/api/OB11Config/SetConfig", {
const ConfigResponse = await fetch("../api/OB11Config/SetConfig", {
method: "POST",
headers: {
Authorization: "Bearer " + this.retCredential,
@ -154,7 +151,6 @@ async function onSettingWindowCreated(view) {
} else if (configKey.length === 3) {
ob11Config[configKey[1]][configKey[2]] = value;
}
OB11ConfigWrapper.SetOB11Config(ob11Config);
};
const parser = new DOMParser();
const doc = parser.parseFromString(
@ -326,7 +322,7 @@ async function onSettingWindowCreated(view) {
"text/html"
);
doc.querySelector("#open-github")?.addEventListener("click", () => {
window.open("https://napneko.github.io/", "_blank");
window.open("https://github.com/NapNeko/NapCatQQ", "_blank");
});
doc.querySelector("#open-telegram")?.addEventListener("click", () => {
window.open("https://t.me/+nLZEnpne-pQ1OWFl");
@ -335,7 +331,7 @@ async function onSettingWindowCreated(view) {
window.open("https://qm.qq.com/q/bDnHRG38aI");
});
doc.querySelector("#open-docs")?.addEventListener("click", () => {
window.open("https://github.com/NapNeko/NapCatQQ");
window.open("https://napneko.github.io/", "_blank");
});
const buildHostListItem = (type, host, index, inputAttrs = {}) => {
const dom = {
@ -431,19 +427,15 @@ async function onSettingWindowCreated(view) {
dom.addEventListener("click", () => {
const active = dom.getAttribute("is-active") == void 0;
setOB11Config(dom.dataset.configKey, active);
if (active)
dom.setAttribute("is-active", "");
else
dom.removeAttribute("is-active");
if (active) dom.setAttribute("is-active", "");
else dom.removeAttribute("is-active");
if (!isEmpty(dom.dataset.controlDisplayId)) {
const displayDom = document.querySelector(
//@ts-expect-error 等待修复
`#${dom.dataset.controlDisplayId}`
);
if (active)
displayDom?.removeAttribute("is-hidden");
else
displayDom?.setAttribute("is-hidden", "");
if (active) displayDom?.removeAttribute("is-hidden");
else displayDom?.setAttribute("is-hidden", "");
}
});
});

View File

@ -10,7 +10,7 @@
<body>
<script>
async function CheckQQLoginStatus(retCredential) {
let QQLoginResponse = await fetch('/api/QQLogin/CheckLoginStatus', {
let QQLoginResponse = await fetch('../api/QQLogin/CheckLoginStatus', {
method: 'POST',
headers: {
'Authorization': "Bearer " + retCredential,
@ -30,7 +30,7 @@
return false;
}
async function CheckWebUiLogined(retCredential) {
let LoginResponse = await fetch('/api/auth/check', {
let LoginResponse = await fetch('../api/auth/check', {
method: 'POST',
headers: {
'Authorization': "Bearer " + retCredential,

View File

@ -86,7 +86,7 @@
let data = "";
try {
let loginResponse = await fetch('/api/auth/login', {
let loginResponse = await fetch('../api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@ -99,7 +99,7 @@
//登录成功
let retCredential = loginResponseJson.data.Credential;
localStorage.setItem('auth', retCredential);
let QQLoginResponse = await fetch('/api/QQLogin/CheckLoginStatus', {
let QQLoginResponse = await fetch('../api/QQLogin/CheckLoginStatus', {
method: 'POST',
headers: {
'Authorization': "Bearer " + retCredential,