diff --git a/napcat.webui/src/controllers/webui_manager.ts b/napcat.webui/src/controllers/webui_manager.ts index 6c17b2f9..5a0c3150 100644 --- a/napcat.webui/src/controllers/webui_manager.ts +++ b/napcat.webui/src/controllers/webui_manager.ts @@ -160,4 +160,55 @@ export default class WebUIManager { return eventSource } + + // 获取WebUI基础配置 + public static async getWebUIConfig() { + const { data } = await serverRequest.get>( + '/WebUIConfig/GetConfig' + ) + return data.data + } + + // 更新WebUI基础配置 + public static async updateWebUIConfig(config: Partial) { + const { data } = await serverRequest.post>( + '/WebUIConfig/UpdateConfig', + config + ) + return data.data + } + + // 获取是否禁用WebUI + public static async getDisableWebUI() { + const { data } = await serverRequest.get>( + '/WebUIConfig/GetDisableWebUI' + ) + return data.data + } + + // 更新是否禁用WebUI + public static async updateDisableWebUI(disable: boolean) { + const { data } = await serverRequest.post>( + '/WebUIConfig/UpdateDisableWebUI', + { disable } + ) + return data.data + } + + // 获取是否禁用非局域网访问 + public static async getDisableNonLANAccess() { + const { data } = await serverRequest.get>( + '/WebUIConfig/GetDisableNonLANAccess' + ) + return data.data + } + + // 更新是否禁用非局域网访问 + public static async updateDisableNonLANAccess(disable: boolean) { + const { data } = await serverRequest.post>( + '/WebUIConfig/UpdateDisableNonLANAccess', + { disable } + ) + return data.data + } } diff --git a/napcat.webui/src/pages/dashboard/config/index.tsx b/napcat.webui/src/pages/dashboard/config/index.tsx index 067f6c4d..05b078e4 100644 --- a/napcat.webui/src/pages/dashboard/config/index.tsx +++ b/napcat.webui/src/pages/dashboard/config/index.tsx @@ -7,6 +7,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom' import ChangePasswordCard from './change_password' import LoginConfigCard from './login' import OneBotConfigCard from './onebot' +import ServerConfigCard from './server' import ThemeConfigCard from './theme' import WebUIConfigCard from './webui' @@ -67,6 +68,11 @@ export default function ConfigPage() { + + + + + diff --git a/napcat.webui/src/pages/dashboard/config/server.tsx b/napcat.webui/src/pages/dashboard/config/server.tsx new file mode 100644 index 00000000..55da2013 --- /dev/null +++ b/napcat.webui/src/pages/dashboard/config/server.tsx @@ -0,0 +1,185 @@ +import { Input } from '@heroui/input' +import { Switch } from '@heroui/switch' +import { useRequest } from 'ahooks' +import { useEffect } from 'react' +import { Controller, useForm } from 'react-hook-form' +import toast from 'react-hot-toast' + +import SaveButtons from '@/components/button/save_buttons' +import PageLoading from '@/components/page_loading' + +import WebUIManager from '@/controllers/webui_manager' + +const ServerConfigCard = () => { + const { + data: configData, + loading: configLoading, + error: configError, + refreshAsync: refreshConfig + } = useRequest(WebUIManager.getWebUIConfig) + + const { + control, + handleSubmit: handleConfigSubmit, + formState: { isSubmitting }, + setValue: setConfigValue + } = useForm<{ + host: string + port: number + loginRate: number + disableWebUI: boolean + disableNonLANAccess: boolean + }>({ + defaultValues: { + host: '0.0.0.0', + port: 6099, + loginRate: 10, + disableWebUI: false, + disableNonLANAccess: false + } + }) + + const reset = () => { + if (configData) { + setConfigValue('host', configData.host) + setConfigValue('port', configData.port) + setConfigValue('loginRate', configData.loginRate) + setConfigValue('disableWebUI', configData.disableWebUI) + setConfigValue('disableNonLANAccess', configData.disableNonLANAccess) + } + } + + const onSubmit = handleConfigSubmit(async (data) => { + try { + await WebUIManager.updateWebUIConfig(data) + toast.success('保存成功') + } catch (error) { + const msg = (error as Error).message + toast.error(`保存失败: ${msg}`) + } + }) + + const onRefresh = async () => { + try { + await refreshConfig() + toast.success('刷新成功') + } catch (error) { + const msg = (error as Error).message + toast.error(`刷新失败: ${msg}`) + } + } + + useEffect(() => { + reset() + }, [configData]) + + if (configLoading) return + + return ( + <> + 服务器配置 - NapCat WebUI +
+
+
服务器配置
+ ( + + )} + /> + ( + field.onChange(parseInt(e.target.value) || 0)} + /> + )} + /> + ( + field.onChange(parseInt(e.target.value) || 0)} + /> + )} + /> +
+ +
+
安全配置
+ ( + +
+ 禁用WebUI + + 启用后将完全禁用WebUI服务,需要重启生效 + +
+
+ )} + /> + ( + +
+ 禁用非局域网访问 + + 启用后只允许局域网内的设备访问WebUI,提高安全性 + +
+
+ )} + /> +
+
+ + + + ) +} + +export default ServerConfigCard \ No newline at end of file diff --git a/napcat.webui/src/types/server.d.ts b/napcat.webui/src/types/server.d.ts index 59005c22..0b49a893 100644 --- a/napcat.webui/src/types/server.d.ts +++ b/napcat.webui/src/types/server.d.ts @@ -181,3 +181,11 @@ interface ThemeConfig { dark: ThemeConfigItem light: ThemeConfigItem } + +interface WebUIConfig { + host: string + port: number + loginRate: number + disableWebUI: boolean + disableNonLANAccess: boolean +} diff --git a/src/webui/index.ts b/src/webui/index.ts index e8a6385f..0af704e9 100644 --- a/src/webui/index.ts +++ b/src/webui/index.ts @@ -61,7 +61,15 @@ async function checkCertificates(logger: LogWrapper): Promise<{ key: string, cer export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapper) { webUiPathWrapper = pathWrapper; WebUiConfig = new WebUiConfigWrapper(); - const [host, port, token] = await InitPort(await WebUiConfig.GetWebUIConfig()); + const config = await WebUiConfig.GetWebUIConfig(); + + // 检查是否禁用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.'); diff --git a/src/webui/src/api/WebUIConfig.ts b/src/webui/src/api/WebUIConfig.ts new file mode 100644 index 00000000..0c6bdd5f --- /dev/null +++ b/src/webui/src/api/WebUIConfig.ts @@ -0,0 +1,127 @@ +import { RequestHandler } from 'express'; +import { WebUiConfig } from '@/webui'; +import { sendError, sendSuccess } from '@webapi/utils/response'; +import { isEmpty } from '@webapi/utils/check'; + +// 获取WebUI基础配置 +export const GetWebUIConfigHandler: RequestHandler = async (_, res) => { + try { + const config = await WebUiConfig.GetWebUIConfig(); + return sendSuccess(res, { + host: config.host, + port: config.port, + loginRate: config.loginRate, + disableWebUI: config.disableWebUI, + disableNonLANAccess: config.disableNonLANAccess + }); + } catch (error) { + const msg = (error as Error).message; + return sendError(res, `获取WebUI配置失败: ${msg}`); + } +}; + +// 获取是否禁用WebUI +export const GetDisableWebUIHandler: RequestHandler = async (_, res) => { + try { + const disable = await WebUiConfig.GetDisableWebUI(); + return sendSuccess(res, disable); + } catch (error) { + const msg = (error as Error).message; + return sendError(res, `获取WebUI禁用状态失败: ${msg}`); + } +}; + +// 更新是否禁用WebUI +export const UpdateDisableWebUIHandler: RequestHandler = async (req, res) => { + try { + const { disable } = req.body; + + if (typeof disable !== 'boolean') { + return sendError(res, 'disable参数必须是布尔值'); + } + + await WebUiConfig.UpdateDisableWebUI(disable); + return sendSuccess(res, null); + } catch (error) { + const msg = (error as Error).message; + return sendError(res, `更新WebUI禁用状态失败: ${msg}`); + } +}; + +// 获取是否禁用非局域网访问 +export const GetDisableNonLANAccessHandler: RequestHandler = async (_, res) => { + try { + const disable = await WebUiConfig.GetDisableNonLANAccess(); + return sendSuccess(res, disable); + } catch (error) { + const msg = (error as Error).message; + return sendError(res, `获取非局域网访问禁用状态失败: ${msg}`); + } +}; + +// 更新是否禁用非局域网访问 +export const UpdateDisableNonLANAccessHandler: RequestHandler = async (req, res) => { + try { + const { disable } = req.body; + + if (typeof disable !== 'boolean') { + return sendError(res, 'disable参数必须是布尔值'); + } + + await WebUiConfig.UpdateDisableNonLANAccess(disable); + return sendSuccess(res, null); + } catch (error) { + const msg = (error as Error).message; + return sendError(res, `更新非局域网访问禁用状态失败: ${msg}`); + } +}; + +// 更新WebUI基础配置 +export const UpdateWebUIConfigHandler: RequestHandler = async (req, res) => { + try { + const { host, port, loginRate, disableWebUI, disableNonLANAccess } = req.body; + + const updateConfig: any = {}; + + if (host !== undefined) { + if (isEmpty(host)) { + return sendError(res, 'host不能为空'); + } + updateConfig.host = host; + } + + if (port !== undefined) { + if (!Number.isInteger(port) || port < 1 || port > 65535) { + return sendError(res, 'port必须是1-65535之间的整数'); + } + updateConfig.port = port; + } + + if (loginRate !== undefined) { + if (!Number.isInteger(loginRate) || loginRate < 1) { + return sendError(res, 'loginRate必须是大于0的整数'); + } + updateConfig.loginRate = loginRate; + } + + if (disableWebUI !== undefined) { + if (typeof disableWebUI !== 'boolean') { + return sendError(res, 'disableWebUI必须是布尔值'); + } + updateConfig.disableWebUI = disableWebUI; + } + + if (disableNonLANAccess !== undefined) { + if (typeof disableNonLANAccess !== 'boolean') { + return sendError(res, 'disableNonLANAccess必须是布尔值'); + } + updateConfig.disableNonLANAccess = disableNonLANAccess; + } + + await WebUiConfig.UpdateWebUIConfig(updateConfig); + return sendSuccess(res, null); + } catch (error) { + const msg = (error as Error).message; + return sendError(res, `更新WebUI配置失败: ${msg}`); + } +}; \ No newline at end of file diff --git a/src/webui/src/helper/config.ts b/src/webui/src/helper/config.ts index 41cf5a96..727680c3 100644 --- a/src/webui/src/helper/config.ts +++ b/src/webui/src/helper/config.ts @@ -19,6 +19,10 @@ const WebUiConfigSchema = Type.Object({ autoLoginAccount: Type.String({ default: '' }), theme: themeType, defaultToken: Type.Boolean({ default: true }), + // 是否关闭WebUI + disableWebUI: Type.Boolean({ default: false }), + // 是否关闭非局域网访问 + disableNonLANAccess: Type.Boolean({ default: false }), }); export type WebUiConfigType = Static; @@ -177,4 +181,26 @@ export class WebUiConfigWrapper { async UpdateTheme(theme: WebUiConfigType['theme']): Promise { await this.UpdateWebUIConfig({ theme: theme }); } + + // 获取是否禁用WebUI + async GetDisableWebUI(): Promise { + const config = await this.GetWebUIConfig(); + return config.disableWebUI; + } + + // 更新是否禁用WebUI + async UpdateDisableWebUI(disable: boolean): Promise { + await this.UpdateWebUIConfig({ disableWebUI: disable }); + } + + // 获取是否禁用非局域网访问 + async GetDisableNonLANAccess(): Promise { + const config = await this.GetWebUIConfig(); + return config.disableNonLANAccess; + } + + // 更新是否禁用非局域网访问 + async UpdateDisableNonLANAccess(disable: boolean): Promise { + await this.UpdateWebUIConfig({ disableNonLANAccess: disable }); + } } diff --git a/src/webui/src/middleware/cors.ts b/src/webui/src/middleware/cors.ts index 64a0d955..e144256f 100644 --- a/src/webui/src/middleware/cors.ts +++ b/src/webui/src/middleware/cors.ts @@ -1,7 +1,53 @@ import type { RequestHandler } from 'express'; +import { WebUiConfig } from '@/webui'; + +// 检查是否为局域网IP地址 +function isLANIP(ip: string): boolean { + if (!ip) return false; + + // 移除IPv6的前缀,如果存在 + const cleanIP = ip.replace(/^::ffff:/, ''); + + // 本地回环地址 + if (cleanIP === '127.0.0.1' || cleanIP === 'localhost' || cleanIP === '::1') { + return true; + } + + // 检查IPv4私有网络地址 + const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; + const match = cleanIP.match(ipv4Regex); + + if (match) { + const [, a, b] = match.map(Number); + + // 10.0.0.0/8 + if (a === 10) return true; + + // 172.16.0.0/12 + if (a === 172 && b !== undefined && b >= 16 && b <= 31) return true; + + // 192.168.0.0/16 + if (a === 192 && b === 168) return true; + + // 169.254.0.0/16 (链路本地地址) + if (a === 169 && b === 254) return true; + } + + return false; +} // CORS 中间件,跨域用 -export const cors: RequestHandler = (req, res, next) => { +export const cors: RequestHandler = async (req, res, next) => { + // 检查是否禁用非局域网访问 + const config = await WebUiConfig.GetWebUIConfig(); + if (config.disableNonLANAccess) { + const clientIP = req.ip || req.socket.remoteAddress || ''; + if (!isLANIP(clientIP)) { + res.status(403).json({ error: '非局域网访问被禁止' }); + return; + } + } + const origin = req.headers.origin || '*'; res.header('Access-Control-Allow-Origin', origin); res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); diff --git a/src/webui/src/router/WebUIConfig.ts b/src/webui/src/router/WebUIConfig.ts new file mode 100644 index 00000000..4feafaa2 --- /dev/null +++ b/src/webui/src/router/WebUIConfig.ts @@ -0,0 +1,31 @@ +import { Router } from 'express'; +import { + GetWebUIConfigHandler, + GetDisableWebUIHandler, + UpdateDisableWebUIHandler, + GetDisableNonLANAccessHandler, + UpdateDisableNonLANAccessHandler, + UpdateWebUIConfigHandler +} from '@webapi/api/WebUIConfig'; + +const router = Router(); + +// 获取WebUI基础配置 +router.get('/GetConfig', GetWebUIConfigHandler); + +// 更新WebUI基础配置 +router.post('/UpdateConfig', UpdateWebUIConfigHandler); + +// 获取是否禁用WebUI +router.get('/GetDisableWebUI', GetDisableWebUIHandler); + +// 更新是否禁用WebUI +router.post('/UpdateDisableWebUI', UpdateDisableWebUIHandler); + +// 获取是否禁用非局域网访问 +router.get('/GetDisableNonLANAccess', GetDisableNonLANAccessHandler); + +// 更新是否禁用非局域网访问 +router.post('/UpdateDisableNonLANAccess', UpdateDisableNonLANAccessHandler); + +export { router as WebUIConfigRouter }; \ No newline at end of file diff --git a/src/webui/src/router/index.ts b/src/webui/src/router/index.ts index 8ea50102..dd2c638a 100644 --- a/src/webui/src/router/index.ts +++ b/src/webui/src/router/index.ts @@ -13,6 +13,7 @@ import { AuthRouter } from '@webapi/router/auth'; import { LogRouter } from '@webapi/router/Log'; import { BaseRouter } from '@webapi/router/Base'; import { FileRouter } from './File'; +import { WebUIConfigRouter } from './WebUIConfig'; const router = Router(); @@ -35,5 +36,7 @@ router.use('/OB11Config', OB11ConfigRouter); router.use('/Log', LogRouter); // file:文件相关路由 router.use('/File', FileRouter); +// router:WebUI配置相关路由 +router.use('/WebUIConfig', WebUIConfigRouter); export { router as ALLRouter };