mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-01 16:20:25 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8be7f74e9f | ||
|
|
a05150ebe1 | ||
|
|
5e6b607ded | ||
|
|
df2dabfe76 | ||
|
|
5e032fcc6a | ||
|
|
44200a2208 | ||
|
|
e39bb05f01 | ||
|
|
677731dd70 | ||
|
|
fa8e6f2c59 | ||
|
|
509b23ff04 | ||
|
|
cf1765f5a4 | ||
|
|
c541c7e257 | ||
|
|
298b8b71c8 | ||
|
|
5c120a8231 | ||
|
|
88ee8f89fe | ||
|
|
12b8130372 |
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@@ -9,4 +9,9 @@
|
||||
"css.customData": [
|
||||
".vscode/tailwindcss.json"
|
||||
],
|
||||
}
|
||||
"editor.formatOnPaste": false,
|
||||
"editor.formatOnSave": false,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "never"
|
||||
},
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ _Modern protocol-side framework implemented based on NTQQ._
|
||||
| QQ Group | [](https://qm.qq.com/q/CMmPbGw0jA) | [](https://qm.qq.com/q/8zJMLjqy2Y) | [](https://qm.qq.com/q/HaRcfrHpUk) | [](https://qm.qq.com/q/I6LU87a0Yq) |
|
||||
|:-:|:-:|:-:|:-:|:-:|
|
||||
|
||||
| Telegram | [](https://t.me/MelodicMoonlight) |
|
||||
| Telegram | [](https://t.me/napcatqq) |
|
||||
|:-:|:-:|
|
||||
|
||||
## Thanks
|
||||
@@ -54,7 +54,9 @@ _Modern protocol-side framework implemented based on NTQQ._
|
||||
+ [AstrBot](https://github.com/AstrBotDevs/AstrBot) 是完美适配本项目的LLM Bot框架 在此推荐一下
|
||||
|
||||
+ [MaiBot](https://github.com/MaiM-with-u/MaiBot) 一只赛博群友 麦麦 Bot框架 在此推荐一下
|
||||
|
||||
|
||||
+ [qq-chat-exporter](https://github.com/shuakami/qq-chat-exporter/) 基于NapCat的消息导出工具 在此推荐一下
|
||||
|
||||
+ 不过最最重要的 还是需要感谢屏幕前的你哦~
|
||||
|
||||
---
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "qq-chat",
|
||||
"version": "9.9.19-34740",
|
||||
"verHash": "f31348f2",
|
||||
"linuxVersion": "3.2.17-34740",
|
||||
"linuxVerHash": "5aa2d8d6",
|
||||
"verHash": "cc326038",
|
||||
"version": "9.9.21-39038",
|
||||
"linuxVersion": "3.2.19-39038",
|
||||
"linuxVerHash": "c773cdf7",
|
||||
"private": true,
|
||||
"description": "QQ",
|
||||
"productName": "QQ",
|
||||
@@ -17,7 +17,7 @@
|
||||
"qd": "externals/devtools/cli/index.js"
|
||||
},
|
||||
"main": "./loadNapCat.js",
|
||||
"buildVersion": "34740",
|
||||
"buildVersion": "39038",
|
||||
"isPureShell": true,
|
||||
"isByteCodeShell": true,
|
||||
"platform": "win32",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"name": "NapCatQQ",
|
||||
"slug": "NapCat.Framework",
|
||||
"description": "高性能的 OneBot 11 协议实现",
|
||||
"version": "4.8.105",
|
||||
"version": "4.8.110",
|
||||
"icon": "./logo.png",
|
||||
"authors": [
|
||||
{
|
||||
|
||||
@@ -62,24 +62,39 @@ const NetworkDisplayCard = <T extends keyof NetworkType>({
|
||||
<ButtonGroup
|
||||
fullWidth
|
||||
isDisabled={editing}
|
||||
radius="full"
|
||||
radius="sm"
|
||||
size="sm"
|
||||
variant="shadow"
|
||||
variant="flat"
|
||||
>
|
||||
<Button color="warning" startContent={<FiEdit3 />} onPress={onEdit}>
|
||||
<Button
|
||||
color="warning"
|
||||
startContent={<FiEdit3 size={16} />}
|
||||
onPress={onEdit}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color={debug ? 'success' : 'default'}
|
||||
startContent={<CgDebug />}
|
||||
color={debug ? 'secondary' : 'success'}
|
||||
variant="flat"
|
||||
startContent={
|
||||
<CgDebug
|
||||
style={{
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
minWidth: '16px',
|
||||
minHeight: '16px'
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onPress={handleEnableDebug}
|
||||
>
|
||||
{debug ? '关闭调试' : '开启调试'}
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
startContent={<MdDeleteForever />}
|
||||
className="bg-danger/20 text-danger hover:bg-danger/30 transition-colors"
|
||||
variant="flat"
|
||||
startContent={<MdDeleteForever size={16} />}
|
||||
onPress={handleDelete}
|
||||
>
|
||||
删除
|
||||
|
||||
@@ -33,21 +33,6 @@ export default class WebUIManager {
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async changePasswordFromDefault(newToken: string) {
|
||||
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||
'/auth/update_token',
|
||||
{ newToken, fromDefault: true }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async checkUsingDefaultToken() {
|
||||
const { data } = await serverRequest.get<ServerResponse<boolean>>(
|
||||
'/auth/check_using_default_token'
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async proxy<T>(url = '') {
|
||||
const data = await serverRequest.get<ServerResponse<string>>(
|
||||
'/base/proxy?url=' + encodeURIComponent(url)
|
||||
|
||||
@@ -155,7 +155,7 @@ export default function AboutPage() {
|
||||
shadow="sm"
|
||||
isPressable
|
||||
isExternal
|
||||
href="https://t.me/MelodicMoonlight"
|
||||
href="https://t.me/napcatqq"
|
||||
>
|
||||
<CardBody className="flex-row items-center gap-2">
|
||||
<span className="p-2 rounded-small bg-primary-50 text-primary-500">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Input } from '@heroui/input'
|
||||
import { useLocalStorage } from '@uidotdev/usehooks'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
@@ -12,14 +11,12 @@ import SaveButtons from '@/components/button/save_buttons'
|
||||
import WebUIManager from '@/controllers/webui_manager'
|
||||
|
||||
const ChangePasswordCard = () => {
|
||||
const [isDefaultToken, setIsDefaultToken] = useState<boolean>(false)
|
||||
const [isLoadingCheck, setIsLoadingCheck] = useState<boolean>(true)
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit: handleWebuiSubmit,
|
||||
formState: { isSubmitting },
|
||||
reset
|
||||
formState: { isSubmitting, errors },
|
||||
reset,
|
||||
watch
|
||||
} = useForm<{
|
||||
oldToken: string
|
||||
newToken: string
|
||||
@@ -33,31 +30,13 @@ const ChangePasswordCard = () => {
|
||||
const navigate = useNavigate()
|
||||
const [_, setToken] = useLocalStorage(key.token, '')
|
||||
|
||||
// 检查是否使用默认密码
|
||||
useEffect(() => {
|
||||
const checkDefaultToken = async () => {
|
||||
try {
|
||||
const isDefault = await WebUIManager.checkUsingDefaultToken()
|
||||
setIsDefaultToken(isDefault)
|
||||
} catch (error) {
|
||||
console.error('检查默认密码状态失败:', error)
|
||||
} finally {
|
||||
setIsLoadingCheck(false)
|
||||
}
|
||||
}
|
||||
|
||||
checkDefaultToken()
|
||||
}, [])
|
||||
// 监听旧密码的值
|
||||
const oldTokenValue = watch('oldToken')
|
||||
|
||||
const onSubmit = handleWebuiSubmit(async (data) => {
|
||||
try {
|
||||
if (isDefaultToken) {
|
||||
// 从默认密码更新
|
||||
await WebUIManager.changePasswordFromDefault(data.newToken)
|
||||
} else {
|
||||
// 正常密码更新
|
||||
await WebUIManager.changePassword(data.oldToken, data.newToken)
|
||||
}
|
||||
// 使用正常密码更新流程
|
||||
await WebUIManager.changePassword(data.oldToken, data.newToken)
|
||||
|
||||
toast.success('修改成功')
|
||||
setToken('')
|
||||
@@ -69,53 +48,74 @@ const ChangePasswordCard = () => {
|
||||
}
|
||||
})
|
||||
|
||||
if (isLoadingCheck) {
|
||||
return (
|
||||
<>
|
||||
<title>修改密码 - NapCat WebUI</title>
|
||||
<div className="flex justify-center items-center h-32">
|
||||
<div className="text-center">加载中...</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>修改密码 - NapCat WebUI</title>
|
||||
|
||||
{isDefaultToken && (
|
||||
<div className="mb-4 p-3 bg-warning-50 border border-warning-200 rounded-lg">
|
||||
<p className="text-warning-700 text-sm">
|
||||
检测到您正在使用默认密码,为了安全起见,请立即设置新密码。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isDefaultToken && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="oldToken"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
label="旧密码"
|
||||
placeholder="请输入旧密码"
|
||||
type="password"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="oldToken"
|
||||
rules={{
|
||||
required: '旧密码不能为空',
|
||||
validate: (value) => {
|
||||
if (!value || value.trim().length === 0) {
|
||||
return '旧密码不能为空'
|
||||
}
|
||||
return true
|
||||
}
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
label="旧密码"
|
||||
placeholder="请输入旧密码"
|
||||
type="password"
|
||||
isRequired
|
||||
isInvalid={!!errors.oldToken}
|
||||
errorMessage={errors.oldToken?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="newToken"
|
||||
rules={{
|
||||
required: '新密码不能为空',
|
||||
minLength: {
|
||||
value: 6,
|
||||
message: '新密码至少需要6个字符'
|
||||
},
|
||||
validate: (value) => {
|
||||
if (!value || value.trim().length === 0) {
|
||||
return '新密码不能为空'
|
||||
}
|
||||
if (value.trim().length !== value.length) {
|
||||
return '新密码不能包含前后空格'
|
||||
}
|
||||
if (value === oldTokenValue) {
|
||||
return '新密码不能与旧密码相同'
|
||||
}
|
||||
// 检查是否包含字母
|
||||
if (!/[a-zA-Z]/.test(value)) {
|
||||
return '新密码必须包含字母'
|
||||
}
|
||||
// 检查是否包含数字
|
||||
if (!/[0-9]/.test(value)) {
|
||||
return '新密码必须包含数字'
|
||||
}
|
||||
return true
|
||||
}
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
label={isDefaultToken ? "设置新密码" : "新密码"}
|
||||
placeholder={isDefaultToken ? "请设置一个安全的新密码" : "请输入新密码"}
|
||||
label="新密码"
|
||||
placeholder="至少6位,包含字母和数字"
|
||||
type="password"
|
||||
isRequired
|
||||
isInvalid={!!errors.newToken}
|
||||
errorMessage={errors.newToken?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,52 +1,15 @@
|
||||
import { Spinner } from '@heroui/spinner'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { Suspense, useEffect } from 'react'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Suspense } from 'react'
|
||||
import { Outlet, useLocation } from 'react-router-dom'
|
||||
|
||||
import useAuth from '@/hooks/auth'
|
||||
import useDialog from '@/hooks/use-dialog'
|
||||
|
||||
import WebUIManager from '@/controllers/webui_manager'
|
||||
import DefaultLayout from '@/layouts/default'
|
||||
|
||||
const CheckDefaultPassword = () => {
|
||||
const { isAuth } = useAuth()
|
||||
const dialog = useDialog()
|
||||
const navigate = useNavigate()
|
||||
const checkDefaultPassword = async () => {
|
||||
const data = await WebUIManager.checkUsingDefaultToken()
|
||||
if (data) {
|
||||
dialog.confirm({
|
||||
title: '修改默认密码',
|
||||
content: '检测到当前密码为默认密码,为了您的安全,必须立即修改密码。',
|
||||
confirmText: '前往修改',
|
||||
onConfirm: () => {
|
||||
navigate('/config?tab=token')
|
||||
},
|
||||
onCancel: () => {
|
||||
navigate('/config?tab=token')
|
||||
},
|
||||
onClose() {
|
||||
navigate('/config?tab=token')
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuth) {
|
||||
checkDefaultPassword()
|
||||
}
|
||||
}, [isAuth])
|
||||
return null
|
||||
}
|
||||
|
||||
export default function IndexPage() {
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<CheckDefaultPassword />
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex justify-center px-10">
|
||||
|
||||
@@ -92,42 +92,65 @@ export default function WebLoginPage() {
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="flex gap-5 py-5 px-5 md:px-10">
|
||||
<Input
|
||||
isClearable
|
||||
type="password"
|
||||
classNames={{
|
||||
label: 'text-black/50 dark:text-white/90',
|
||||
input: [
|
||||
'bg-transparent',
|
||||
'text-black/90 dark:text-white/90',
|
||||
'placeholder:text-default-700/50 dark:placeholder:text-white/60'
|
||||
],
|
||||
innerWrapper: 'bg-transparent',
|
||||
inputWrapper: [
|
||||
'shadow-xl',
|
||||
'bg-default-100/70',
|
||||
'dark:bg-default/60',
|
||||
'backdrop-blur-xl',
|
||||
'backdrop-saturate-200',
|
||||
'hover:bg-default-0/70',
|
||||
'dark:hover:bg-default/70',
|
||||
'group-data-[focus=true]:bg-default-100/50',
|
||||
'dark:group-data-[focus=true]:bg-default/60',
|
||||
'!cursor-text'
|
||||
]
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
onSubmit()
|
||||
}}
|
||||
isDisabled={isLoading}
|
||||
label="Token"
|
||||
placeholder="请输入token"
|
||||
radius="lg"
|
||||
size="lg"
|
||||
startContent={
|
||||
<IoKeyOutline className="text-black/50 mb-0.5 dark:text-white/90 text-slate-400 pointer-events-none flex-shrink-0" />
|
||||
}
|
||||
value={tokenValue}
|
||||
onChange={(e) => setTokenValue(e.target.value)}
|
||||
onClear={() => setTokenValue('')}
|
||||
/>
|
||||
>
|
||||
{/* 隐藏的用户名字段,帮助浏览器识别登录表单 */}
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
value="napcat-webui"
|
||||
autoComplete="username"
|
||||
className="absolute -left-[9999px] opacity-0 pointer-events-none"
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
aria-label="Username"
|
||||
/>
|
||||
<Input
|
||||
isClearable
|
||||
type="password"
|
||||
name="password"
|
||||
autoComplete="current-password"
|
||||
classNames={{
|
||||
label: 'text-black/50 dark:text-white/90',
|
||||
input: [
|
||||
'bg-transparent',
|
||||
'text-black/90 dark:text-white/90',
|
||||
'placeholder:text-default-700/50 dark:placeholder:text-white/60'
|
||||
],
|
||||
innerWrapper: 'bg-transparent',
|
||||
inputWrapper: [
|
||||
'shadow-xl',
|
||||
'bg-default-100/70',
|
||||
'dark:bg-default/60',
|
||||
'backdrop-blur-xl',
|
||||
'backdrop-saturate-200',
|
||||
'hover:bg-default-0/70',
|
||||
'dark:hover:bg-default/70',
|
||||
'group-data-[focus=true]:bg-default-100/50',
|
||||
'dark:group-data-[focus=true]:bg-default/60',
|
||||
'!cursor-text'
|
||||
]
|
||||
}}
|
||||
isDisabled={isLoading}
|
||||
label="Token"
|
||||
placeholder="请输入token"
|
||||
radius="lg"
|
||||
size="lg"
|
||||
startContent={
|
||||
<IoKeyOutline className="text-black/50 mb-0.5 dark:text-white/90 text-slate-400 pointer-events-none flex-shrink-0" />
|
||||
}
|
||||
value={tokenValue}
|
||||
onChange={(e) => setTokenValue(e.target.value)}
|
||||
onClear={() => setTokenValue('')}
|
||||
/>
|
||||
</form>
|
||||
<div className="text-center text-small text-default-600 dark:text-default-400 px-2">
|
||||
💡 提示:请从 NapCat 启动日志中查看登录密钥
|
||||
</div>
|
||||
<Button
|
||||
className="mx-10 mt-10 text-lg py-7"
|
||||
color="primary"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "napcat",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "4.8.105",
|
||||
"version": "4.8.110",
|
||||
"scripts": {
|
||||
"build:universal": "npm run build:webui && vite build --mode universal || exit 1",
|
||||
"build:framework": "npm run build:webui && vite build --mode framework || exit 1",
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const napCatVersion = '4.8.105';
|
||||
export const napCatVersion = '4.8.110';
|
||||
|
||||
@@ -130,7 +130,7 @@ export class GoCQHTTPGetForwardMsgAction extends OneBotAction<Payload, {
|
||||
throw new Error('消息不存在或已过期');
|
||||
}
|
||||
// 6. 解析消息内容
|
||||
const resMsg = (await this.obContext.apis.MsgApi.parseMessageV2(singleMsg))?.arrayMsg;
|
||||
const resMsg = (await this.obContext.apis.MsgApi.parseMessage(singleMsg, 'array', true));
|
||||
|
||||
const forwardContent = (resMsg?.message?.[0] as OB11MessageForward)?.data?.content;
|
||||
if (forwardContent) {
|
||||
|
||||
@@ -13,8 +13,11 @@ import {
|
||||
SendStatusType,
|
||||
NTMsgType,
|
||||
MessageElement,
|
||||
ElementType,
|
||||
NTMsgAtType,
|
||||
} from '@/core';
|
||||
import { OB11ConfigLoader } from '@/onebot/config';
|
||||
import { pendingTokenToSend } from '@/webui/index';
|
||||
import {
|
||||
OB11HttpClientAdapter,
|
||||
OB11WebSocketClientAdapter,
|
||||
@@ -62,8 +65,8 @@ export class NapCatOneBot11Adapter {
|
||||
networkManager: OB11NetworkManager;
|
||||
actions: ActionMap;
|
||||
private readonly bootTime = Date.now() / 1000;
|
||||
recallEventCache = new Map<string, any>();
|
||||
constructor(core: NapCatCore, context: InstanceContext, pathWrapper: NapCatPathWrapper) {
|
||||
recallEventCache = new Map<string, NodeJS.Timeout>();
|
||||
constructor (core: NapCatCore, context: InstanceContext, pathWrapper: NapCatPathWrapper) {
|
||||
this.core = core;
|
||||
this.context = context;
|
||||
this.configLoader = new OB11ConfigLoader(core, pathWrapper.configPath, OneBotConfigSchema);
|
||||
@@ -77,7 +80,7 @@ export class NapCatOneBot11Adapter {
|
||||
this.actions = createActionMap(this, core);
|
||||
this.networkManager = new OB11NetworkManager();
|
||||
}
|
||||
async creatOneBotLog(ob11Config: OneBotConfig) {
|
||||
async creatOneBotLog (ob11Config: OneBotConfig) {
|
||||
let log = '[network] 配置加载\n';
|
||||
for (const key of ob11Config.network.httpServers) {
|
||||
log += `HTTP服务: ${key.host}:${key.port}, : ${key.enable ? '已启动' : '未启动'}\n`;
|
||||
@@ -96,14 +99,44 @@ export class NapCatOneBot11Adapter {
|
||||
}
|
||||
return log;
|
||||
}
|
||||
async InitOneBot() {
|
||||
async InitOneBot () {
|
||||
const selfInfo = this.core.selfInfo;
|
||||
const ob11Config = this.configLoader.configData;
|
||||
|
||||
this.core.apis.UserApi.getUserDetailInfo(selfInfo.uid, false)
|
||||
.then((user) => {
|
||||
.then(async (user) => {
|
||||
selfInfo.nick = user.nick;
|
||||
this.context.logger.setLogSelfInfo(selfInfo);
|
||||
|
||||
// 检查是否有待发送的token
|
||||
if (pendingTokenToSend) {
|
||||
this.context.logger.log('[NapCat] [OneBot] 🔐 检测到待发送的WebUI Token,开始发送');
|
||||
try {
|
||||
await this.core.apis.MsgApi.sendMsg(
|
||||
{ chatType: ChatType.KCHATTYPEC2C, peerUid: selfInfo.uid, guildId: '' },
|
||||
[{
|
||||
elementType: ElementType.TEXT,
|
||||
elementId: '',
|
||||
textElement: {
|
||||
content:
|
||||
'[NapCat] 温馨提示:\n'+
|
||||
'WebUI密码为默认密码,已进行强制修改\n'+
|
||||
'新密码: ' +pendingTokenToSend,
|
||||
atType: NTMsgAtType.ATTYPEUNKNOWN,
|
||||
atUid: '',
|
||||
atTinyId: '',
|
||||
atNtUid: '',
|
||||
}
|
||||
}],
|
||||
5000
|
||||
);
|
||||
this.context.logger.log('[NapCat] [OneBot] ✅ WebUI Token 消息发送成功');
|
||||
} catch (error) {
|
||||
this.context.logger.logError('[NapCat] [OneBot] ❌ WebUI Token 消息发送失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
WebUiDataRuntime.getQQLoginCallback()(true);
|
||||
})
|
||||
.catch(e => this.context.logger.logError(e));
|
||||
|
||||
@@ -117,7 +150,7 @@ export class NapCatOneBot11Adapter {
|
||||
// new OB11PluginAdapter('myPlugin', this.core, this,this.actions)
|
||||
// );
|
||||
if (existsSync(this.context.pathWrapper.pluginPath)) {
|
||||
this.context.logger.log(`[Plugins] 插件目录存在,开始加载插件`);
|
||||
this.context.logger.log('[Plugins] 插件目录存在,开始加载插件');
|
||||
this.networkManager.registerAdapter(
|
||||
new OB11PluginMangerAdapter('plugin_manager', this.core, this, this.actions)
|
||||
);
|
||||
@@ -187,7 +220,7 @@ export class NapCatOneBot11Adapter {
|
||||
}
|
||||
|
||||
|
||||
private async reloadNetwork(prev: OneBotConfig, now: OneBotConfig): Promise<void> {
|
||||
private async reloadNetwork (prev: OneBotConfig, now: OneBotConfig): Promise<void> {
|
||||
const prevLog = await this.creatOneBotLog(prev);
|
||||
const newLog = await this.creatOneBotLog(now);
|
||||
this.context.logger.log(`[Notice] [OneBot11] 配置变更前:\n${prevLog}`);
|
||||
@@ -200,7 +233,7 @@ export class NapCatOneBot11Adapter {
|
||||
await this.handleConfigChange(prev.network.websocketClients, now.network.websocketClients, OB11WebSocketClientAdapter);
|
||||
}
|
||||
|
||||
private async handleConfigChange<CT extends NetworkAdapterConfig>(
|
||||
private async handleConfigChange<CT extends NetworkAdapterConfig> (
|
||||
prevConfig: NetworkAdapterConfig[],
|
||||
nowConfig: NetworkAdapterConfig[],
|
||||
adapterClass: new (
|
||||
@@ -232,7 +265,7 @@ export class NapCatOneBot11Adapter {
|
||||
}
|
||||
}
|
||||
|
||||
private initMsgListener() {
|
||||
private initMsgListener () {
|
||||
const msgListener = new NodeIKernelMsgListener();
|
||||
msgListener.onRecvSysMsg = (msg) => {
|
||||
this.apis.MsgApi.parseSysMessage(msg)
|
||||
@@ -346,7 +379,7 @@ export class NapCatOneBot11Adapter {
|
||||
this.context.session.getMsgService().addKernelMsgListener(proxiedListenerOf(msgListener, this.context.logger));
|
||||
}
|
||||
|
||||
private initBuddyListener() {
|
||||
private initBuddyListener () {
|
||||
const buddyListener = new NodeIKernelBuddyListener();
|
||||
|
||||
buddyListener.onBuddyReqChange = async (reqs) => {
|
||||
@@ -377,7 +410,7 @@ export class NapCatOneBot11Adapter {
|
||||
.addKernelBuddyListener(proxiedListenerOf(buddyListener, this.context.logger));
|
||||
}
|
||||
|
||||
private initGroupListener() {
|
||||
private initGroupListener () {
|
||||
const groupListener = new NodeIKernelGroupListener();
|
||||
|
||||
groupListener.onGroupNotifiesUpdated = async (_, notifies) => {
|
||||
@@ -470,7 +503,7 @@ export class NapCatOneBot11Adapter {
|
||||
.addKernelGroupListener(proxiedListenerOf(groupListener, this.context.logger));
|
||||
}
|
||||
|
||||
private async emitMsg(message: RawMessage) {
|
||||
private async emitMsg (message: RawMessage) {
|
||||
const network = await this.networkManager.getAllConfig();
|
||||
this.context.logger.logDebug('收到新消息 RawMessage', message);
|
||||
await Promise.allSettled([
|
||||
@@ -479,7 +512,7 @@ export class NapCatOneBot11Adapter {
|
||||
]);
|
||||
}
|
||||
|
||||
private async handleMsg(message: RawMessage, network: Array<NetworkAdapterConfig>) {
|
||||
private async handleMsg (message: RawMessage, network: Array<NetworkAdapterConfig>) {
|
||||
// 过滤无效消息
|
||||
if (message.msgType === NTMsgType.KMSGTYPENULL) {
|
||||
return;
|
||||
@@ -500,17 +533,17 @@ export class NapCatOneBot11Adapter {
|
||||
}
|
||||
}
|
||||
|
||||
private isSelfMessage(ob11Msg: {
|
||||
stringMsg: OB11Message;
|
||||
arrayMsg: OB11Message;
|
||||
private isSelfMessage (ob11Msg: {
|
||||
stringMsg: OB11Message
|
||||
arrayMsg: OB11Message
|
||||
}): boolean {
|
||||
return ob11Msg.stringMsg.user_id.toString() == this.core.selfInfo.uin ||
|
||||
ob11Msg.arrayMsg.user_id.toString() == this.core.selfInfo.uin;
|
||||
}
|
||||
|
||||
private createMsgMap(network: Array<NetworkAdapterConfig>, ob11Msg: {
|
||||
stringMsg: OB11Message;
|
||||
arrayMsg: OB11Message;
|
||||
private createMsgMap (network: Array<NetworkAdapterConfig>, ob11Msg: {
|
||||
stringMsg: OB11Message
|
||||
arrayMsg: OB11Message
|
||||
}, isSelfMsg: boolean, message: RawMessage): Map<string, OB11Message> {
|
||||
const msgMap: Map<string, OB11Message> = new Map();
|
||||
network.filter(e => e.enable).forEach(e => {
|
||||
@@ -528,7 +561,7 @@ export class NapCatOneBot11Adapter {
|
||||
return msgMap;
|
||||
}
|
||||
|
||||
private handleDebugNetwork(network: Array<NetworkAdapterConfig>, msgMap: Map<string, OB11Message>, message: RawMessage) {
|
||||
private handleDebugNetwork (network: Array<NetworkAdapterConfig>, msgMap: Map<string, OB11Message>, message: RawMessage) {
|
||||
const debugNetwork = network.filter(e => e.enable && e.debug);
|
||||
if (debugNetwork.length > 0) {
|
||||
debugNetwork.forEach(adapter => {
|
||||
@@ -542,7 +575,7 @@ export class NapCatOneBot11Adapter {
|
||||
}
|
||||
}
|
||||
|
||||
private handleNotReportSelfNetwork(network: Array<NetworkAdapterConfig>, msgMap: Map<string, OB11Message>, isSelfMsg: boolean) {
|
||||
private handleNotReportSelfNetwork (network: Array<NetworkAdapterConfig>, msgMap: Map<string, OB11Message>, isSelfMsg: boolean) {
|
||||
if (isSelfMsg) {
|
||||
const notReportSelfNetwork = network.filter(e => e.enable && (('reportSelfMessage' in e && !e.reportSelfMessage) || !('reportSelfMessage' in e)));
|
||||
notReportSelfNetwork.forEach(adapter => {
|
||||
@@ -551,7 +584,7 @@ export class NapCatOneBot11Adapter {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleGroupEvent(message: RawMessage) {
|
||||
private async handleGroupEvent (message: RawMessage) {
|
||||
try {
|
||||
// 群名片修改事件解析 任何都该判断
|
||||
if (message.senderUin && message.senderUin !== '0') {
|
||||
@@ -584,7 +617,7 @@ export class NapCatOneBot11Adapter {
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePrivateMsgEvent(message: RawMessage) {
|
||||
private async handlePrivateMsgEvent (message: RawMessage) {
|
||||
try {
|
||||
if (message.msgType === NTMsgType.KMSGTYPEGRAYTIPS) {
|
||||
// 灰条为单元素消息
|
||||
@@ -602,7 +635,7 @@ export class NapCatOneBot11Adapter {
|
||||
}
|
||||
}
|
||||
|
||||
private async emitRecallMsg(message: RawMessage, element: MessageElement) {
|
||||
private async emitRecallMsg (message: RawMessage, element: MessageElement) {
|
||||
const peer: Peer = { chatType: message.chatType, peerUid: message.peerUid, guildId: '' };
|
||||
const oriMessageId = MessageUnique.getShortIdByMsgId(message.msgId) ?? MessageUnique.createUniqueMsgId(peer, message.msgId);
|
||||
if (message.chatType == ChatType.KCHATTYPEC2C) {
|
||||
@@ -613,7 +646,7 @@ export class NapCatOneBot11Adapter {
|
||||
return;
|
||||
}
|
||||
|
||||
private async emitFriendRecallMsg(message: RawMessage, oriMessageId: number, element: MessageElement) {
|
||||
private async emitFriendRecallMsg (message: RawMessage, oriMessageId: number, element: MessageElement) {
|
||||
const operatorUid = element.grayTipElement?.revokeElement.operatorUid;
|
||||
if (!operatorUid) return undefined;
|
||||
return new OB11FriendRecallNoticeEvent(
|
||||
@@ -623,7 +656,7 @@ export class NapCatOneBot11Adapter {
|
||||
);
|
||||
}
|
||||
|
||||
private async emitGroupRecallMsg(message: RawMessage, oriMessageId: number, element: MessageElement) {
|
||||
private async emitGroupRecallMsg (message: RawMessage, oriMessageId: number, element: MessageElement) {
|
||||
const operatorUid = element.grayTipElement?.revokeElement.operatorUid;
|
||||
if (!operatorUid) return undefined;
|
||||
const operatorId = await this.core.apis.UserApi.getUinByUidV2(operatorUid);
|
||||
|
||||
@@ -203,7 +203,13 @@ export class WindowsPtyAgent {
|
||||
}
|
||||
|
||||
private _getWindowsBuildNumber(): number {
|
||||
const osVersion = (/(\d+)\.(\d+)\.(\d+)/g).exec(os.release());
|
||||
const release = os.release();
|
||||
// Limit input length to prevent potential DoS attacks
|
||||
if (release.length > 50) {
|
||||
return 0;
|
||||
}
|
||||
// Use non-global regex with more specific pattern to prevent backtracking
|
||||
const osVersion = /^(\d{1,5})\.(\d{1,5})\.(\d{1,10})/.exec(release);
|
||||
let buildNumber: number = 0;
|
||||
if (osVersion && osVersion.length === 4) {
|
||||
buildNumber = parseInt(osVersion[3]!);
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
|
||||
import express from 'express';
|
||||
import { createServer } from 'http';
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { createServer as createHttpsServer } from 'https';
|
||||
import { LogWrapper } from '@/common/log';
|
||||
import { NapCatPathWrapper } from '@/common/path';
|
||||
import { WebUiConfigWrapper } from '@webapi/helper/config';
|
||||
import { ALLRouter } from '@webapi/router';
|
||||
import { cors } from '@webapi/middleware/cors';
|
||||
import { createUrl } from '@webapi/utils/url';
|
||||
import { createUrl, getRandomToken } from '@webapi/utils/url';
|
||||
import { sendError } from '@webapi/utils/response';
|
||||
import { join } from 'node:path';
|
||||
import { terminalManager } from '@webapi/terminal/terminal_manager';
|
||||
@@ -30,17 +31,42 @@ const MAX_PORT_TRY = 100;
|
||||
import * as net from 'node:net';
|
||||
import { WebUiDataRuntime } from './src/helper/Data';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
|
||||
export let webUiRuntimePort = 6099;
|
||||
export async function InitPort(parsedConfig: WebUiConfigType): Promise<[string, number, string]> {
|
||||
// 全局变量:存储需要在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, ''];
|
||||
return ['', 0, randomUUID()];
|
||||
}
|
||||
}
|
||||
|
||||
async function checkCertificates(logger: LogWrapper): Promise<{ key: string, cert: string } | null> {
|
||||
try {
|
||||
const certPath = join(webUiPathWrapper.configPath, 'cert.pem');
|
||||
@@ -61,14 +87,34 @@ async function checkCertificates(logger: LogWrapper): Promise<{ key: string, cer
|
||||
export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapper) {
|
||||
webUiPathWrapper = pathWrapper;
|
||||
WebUiConfig = new WebUiConfigWrapper();
|
||||
const config = await WebUiConfig.GetWebUIConfig();
|
||||
|
||||
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();
|
||||
} else {
|
||||
logger.log(`[NapCat] [WebUi] ✅ 当前使用安全密码`);
|
||||
}
|
||||
|
||||
// 存储启动时的初始token用于鉴权
|
||||
setInitialWebUiToken(config.token);
|
||||
logger.log(`[NapCat] [WebUi] 🔑 已缓存启动时的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) {
|
||||
@@ -169,14 +215,18 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp
|
||||
|
||||
// ------------启动服务------------
|
||||
server.listen(port, host, async () => {
|
||||
// 启动后打印出相关地址
|
||||
let searchParams = { token: token };
|
||||
logger.log(`[NapCat] [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!------------
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
import { RequestHandler } from 'express';
|
||||
|
||||
import { WebUiConfig } from '@/webui';
|
||||
import { WebUiConfig, getInitialWebUiToken, setInitialWebUiToken } from '@/webui';
|
||||
|
||||
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';
|
||||
|
||||
// 检查是否使用默认Token
|
||||
export const CheckDefaultTokenHandler: RequestHandler = async (_, res) => {
|
||||
const webuiToken = await WebUiConfig.GetWebUIConfig();
|
||||
if (webuiToken.defaultToken) {
|
||||
return sendSuccess(res, true);
|
||||
}
|
||||
return sendSuccess(res, false);
|
||||
};
|
||||
|
||||
// 登录
|
||||
export const LoginHandler: RequestHandler = async (req, res) => {
|
||||
// 获取WebUI配置
|
||||
@@ -33,8 +24,13 @@ export const LoginHandler: RequestHandler = async (req, res) => {
|
||||
if (!WebUiDataRuntime.checkLoginRate(clientIP, WebUiConfigData.loginRate)) {
|
||||
return sendError(res, 'login rate limit');
|
||||
}
|
||||
//验证config.token hash是否等于token hash
|
||||
if (!AuthHelper.comparePasswordHash(WebUiConfigData.token, hash)) {
|
||||
// 使用启动时缓存的token进行验证,而不是动态读取配置文件
|
||||
const initialToken = getInitialWebUiToken();
|
||||
if (!initialToken) {
|
||||
return sendError(res, 'Server token not initialized');
|
||||
}
|
||||
//验证初始token hash是否等于提交的token hash
|
||||
if (!AuthHelper.comparePasswordHash(initialToken, hash)) {
|
||||
return sendError(res, 'token is invalid');
|
||||
}
|
||||
|
||||
@@ -63,8 +59,6 @@ export const LogoutHandler: RequestHandler = async (req, res) => {
|
||||
|
||||
// 检查登录状态
|
||||
export const checkHandler: RequestHandler = async (req, res) => {
|
||||
// 获取WebUI配置
|
||||
const WebUiConfigData = await WebUiConfig.GetWebUIConfig();
|
||||
// 获取请求头中的Authorization
|
||||
const authorization = req.headers.authorization;
|
||||
// 检查凭证
|
||||
@@ -79,8 +73,13 @@ export const checkHandler: RequestHandler = async (req, res) => {
|
||||
return sendError(res, 'Token has been revoked');
|
||||
}
|
||||
|
||||
// 使用启动时缓存的token进行验证
|
||||
const initialToken = getInitialWebUiToken();
|
||||
if (!initialToken) {
|
||||
return sendError(res, 'Server token not initialized');
|
||||
}
|
||||
// 验证凭证是否在一小时内有效
|
||||
const valid = AuthHelper.validateCredentialWithinOneHour(WebUiConfigData.token, Credential);
|
||||
const valid = AuthHelper.validateCredentialWithinOneHour(initialToken, Credential);
|
||||
// 返回成功信息
|
||||
if (valid) return sendSuccess(res, null);
|
||||
// 返回错误信息
|
||||
@@ -93,16 +92,36 @@ export const checkHandler: RequestHandler = async (req, res) => {
|
||||
|
||||
// 修改密码(token)
|
||||
export const UpdateTokenHandler: RequestHandler = async (req, res) => {
|
||||
const { oldToken, newToken, fromDefault } = req.body;
|
||||
const { oldToken, newToken } = req.body;
|
||||
const authorization = req.headers.authorization;
|
||||
|
||||
if (isEmpty(newToken)) {
|
||||
return sendError(res, 'newToken is empty');
|
||||
}
|
||||
|
||||
// 如果不是从默认密码更新,则需要验证旧密码
|
||||
if (!fromDefault && isEmpty(oldToken)) {
|
||||
return sendError(res, 'oldToken is required when not updating from default password');
|
||||
// 强制要求旧密码
|
||||
if (isEmpty(oldToken)) {
|
||||
return sendError(res, 'oldToken is required');
|
||||
}
|
||||
|
||||
// 检查新旧密码是否相同
|
||||
if (oldToken === newToken) {
|
||||
return sendError(res, '新密码不能与旧密码相同');
|
||||
}
|
||||
|
||||
// 检查新密码强度
|
||||
if (newToken.length < 6) {
|
||||
return sendError(res, '新密码至少需要6个字符');
|
||||
}
|
||||
|
||||
// 检查是否包含字母
|
||||
if (!/[a-zA-Z]/.test(newToken)) {
|
||||
return sendError(res, '新密码必须包含字母');
|
||||
}
|
||||
|
||||
// 检查是否包含数字
|
||||
if (!/[0-9]/.test(newToken)) {
|
||||
return sendError(res, '新密码必须包含数字');
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -113,17 +132,18 @@ export const UpdateTokenHandler: RequestHandler = async (req, res) => {
|
||||
AuthHelper.revokeCredential(Credential);
|
||||
}
|
||||
|
||||
if (fromDefault) {
|
||||
// 从默认密码更新,直接设置新密码
|
||||
const currentConfig = await WebUiConfig.GetWebUIConfig();
|
||||
if (!currentConfig.defaultToken) {
|
||||
return sendError(res, 'Current password is not default password');
|
||||
}
|
||||
await WebUiConfig.UpdateWebUIConfig({ token: newToken, defaultToken: false });
|
||||
} else {
|
||||
// 正常的密码更新流程
|
||||
await WebUiConfig.UpdateToken(oldToken, newToken);
|
||||
// 使用启动时缓存的token进行验证
|
||||
const initialToken = getInitialWebUiToken();
|
||||
if (!initialToken) {
|
||||
return sendError(res, 'Server token not initialized');
|
||||
}
|
||||
if (initialToken !== oldToken) {
|
||||
return sendError(res, '旧 token 不匹配');
|
||||
}
|
||||
// 直接更新配置文件中的token,不需要通过WebUiConfig.UpdateToken方法
|
||||
await WebUiConfig.UpdateWebUIConfig({ token: newToken });
|
||||
// 更新内存中的缓存token,使新密码立即生效
|
||||
setInitialWebUiToken(newToken);
|
||||
|
||||
return sendSuccess(res, 'Token updated successfully');
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -9,10 +9,18 @@ import { PassThrough } from 'stream';
|
||||
import multer from 'multer';
|
||||
import webUIFontUploader from '../uploader/webui_font';
|
||||
import diskUploader from '../uploader/disk';
|
||||
import { WebUiConfig } from '@/webui';
|
||||
import { WebUiConfig, getInitialWebUiToken, webUiPathWrapper } from '@/webui';
|
||||
|
||||
const isWindows = os.platform() === 'win32';
|
||||
|
||||
// 安全地从查询参数中提取字符串值,防止类型混淆
|
||||
const getQueryStringParam = (param: any): string => {
|
||||
if (Array.isArray(param)) {
|
||||
return String(param[0] || '');
|
||||
}
|
||||
return String(param || '');
|
||||
};
|
||||
|
||||
// 获取系统根目录列表(Windows返回盘符列表,其他系统返回['/'])
|
||||
const getRootDirs = async (): Promise<string[]> => {
|
||||
if (!isWindows) return ['/'];
|
||||
@@ -32,14 +40,68 @@ const getRootDirs = async (): Promise<string[]> => {
|
||||
return drives.length > 0 ? drives : ['C:'];
|
||||
};
|
||||
|
||||
// 规范化路径
|
||||
// 规范化路径并进行安全验证
|
||||
const normalizePath = (inputPath: string): string => {
|
||||
if (!inputPath) return isWindows ? 'C:\\' : '/';
|
||||
// 如果是Windows且输入为纯盘符(可能带或不带斜杠),统一返回 "X:\"
|
||||
if (isWindows && /^[A-Z]:[\\/]*$/i.test(inputPath)) {
|
||||
return inputPath.slice(0, 2) + '\\';
|
||||
if (!inputPath) {
|
||||
// 对于空路径,Windows返回用户主目录,其他系统返回根目录
|
||||
return isWindows ? process.env['USERPROFILE'] || 'C:\\' : '/';
|
||||
}
|
||||
return path.normalize(inputPath);
|
||||
|
||||
// 对输入路径进行清理,移除潜在的危险字符
|
||||
const cleanedPath = inputPath.replace(/[\x00-\x1f\x7f]/g, ''); // 移除控制字符
|
||||
|
||||
// 如果是Windows且输入为纯盘符(可能带或不带斜杠),统一返回 "X:\"
|
||||
if (isWindows && /^[A-Z]:[\\/]*$/i.test(cleanedPath)) {
|
||||
return cleanedPath.slice(0, 2) + '\\';
|
||||
}
|
||||
|
||||
// 安全验证:检查是否包含危险的路径遍历模式(在规范化之前)
|
||||
if (containsPathTraversal(cleanedPath)) {
|
||||
throw new Error('Invalid path: path traversal detected');
|
||||
}
|
||||
|
||||
// 进行路径规范化
|
||||
const normalized = path.resolve(cleanedPath);
|
||||
|
||||
// 再次检查规范化后的路径,确保没有绕过安全检查
|
||||
if (containsPathTraversal(normalized)) {
|
||||
throw new Error('Invalid path: path traversal detected after normalization');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保规范化后的路径不包含连续的路径分隔符
|
||||
const finalPath = normalized.replace(/[\\\/]+/g, path.sep);
|
||||
|
||||
return finalPath;
|
||||
};
|
||||
|
||||
// 检查路径是否包含路径遍历攻击模式
|
||||
const containsPathTraversal = (inputPath: string): boolean => {
|
||||
// 对输入进行URL解码,防止编码绕过
|
||||
let decodedPath = inputPath;
|
||||
try {
|
||||
decodedPath = decodeURIComponent(inputPath);
|
||||
} catch {
|
||||
// 如果解码失败,使用原始路径
|
||||
}
|
||||
|
||||
// 将路径统一为正斜杠格式进行检查
|
||||
const normalizedForCheck = decodedPath.replace(/\\/g, '/');
|
||||
|
||||
// 检查危险模式 - 更全面的路径遍历检测
|
||||
const dangerousPatterns = [
|
||||
/\.\.\//, // ../ 模式
|
||||
/\/\.\./, // /.. 模式
|
||||
/^\.\./, // 以.. 开头
|
||||
/\.\.$/, // 以.. 结尾
|
||||
/\.\.\\/, // ..\ 模式(Windows)
|
||||
/\\\.\./, // \.. 模式(Windows)
|
||||
/%2e%2e/i, // URL编码的..
|
||||
/%252e%252e/i, // 双重URL编码的..
|
||||
/\.\.\x00/, // null字节攻击
|
||||
/\0/, // null字节
|
||||
];
|
||||
|
||||
return dangerousPatterns.some(pattern => pattern.test(normalizedForCheck));
|
||||
};
|
||||
|
||||
interface FileInfo {
|
||||
@@ -52,6 +114,35 @@ interface FileInfo {
|
||||
// 添加系统文件黑名单
|
||||
const SYSTEM_FILES = new Set(['pagefile.sys', 'swapfile.sys', 'hiberfil.sys', 'System Volume Information']);
|
||||
|
||||
// 检查是否为WebUI配置文件
|
||||
const isWebUIConfigFile = (filePath: string): boolean => {
|
||||
// 先用字符串快速筛选
|
||||
if (!filePath.includes('webui.json')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 进入更严格的路径判断 - 统一路径分隔符为 /
|
||||
const webUIConfigPath = path.resolve(webUiPathWrapper.configPath, 'webui.json').replace(/\\/g, '/');
|
||||
const targetPath = path.resolve(filePath).replace(/\\/g, '/');
|
||||
|
||||
// 统一分隔符后进行路径比较
|
||||
return targetPath === webUIConfigPath;
|
||||
};
|
||||
|
||||
// WebUI配置文件脱敏处理
|
||||
const sanitizeWebUIConfig = (content: string): string => {
|
||||
try {
|
||||
const config = JSON.parse(content);
|
||||
if (config.token) {
|
||||
config.token = '******';
|
||||
}
|
||||
return JSON.stringify(config, null, 4);
|
||||
} catch {
|
||||
// 如果解析失败,返回原内容
|
||||
return content;
|
||||
}
|
||||
};
|
||||
|
||||
// 检查同类型的文件或目录是否存在
|
||||
const checkSameTypeExists = async (pathToCheck: string, isDirectory: boolean): Promise<boolean> => {
|
||||
try {
|
||||
@@ -65,13 +156,16 @@ const checkSameTypeExists = async (pathToCheck: string, isDirectory: boolean): P
|
||||
|
||||
// 获取目录内容
|
||||
export const ListFilesHandler: RequestHandler = async (req, res) => {
|
||||
const webuiToken = await WebUiConfig.GetWebUIConfig();
|
||||
if (webuiToken.defaultToken) {
|
||||
return sendError(res, '默认密码禁止使用');
|
||||
}
|
||||
try {
|
||||
const requestPath = (req.query['path'] as string) || (isWindows ? 'C:\\' : '/');
|
||||
const normalizedPath = normalizePath(requestPath);
|
||||
const requestPath = getQueryStringParam(req.query['path']) || (isWindows ? process.env['USERPROFILE'] || 'C:\\' : '/');
|
||||
|
||||
let normalizedPath: string;
|
||||
try {
|
||||
normalizedPath = normalizePath(requestPath);
|
||||
} catch (pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
const onlyDirectory = req.query['onlyDirectory'] === 'true';
|
||||
|
||||
// 如果是根路径且在Windows系统上,返回盘符列表
|
||||
@@ -139,7 +233,18 @@ export const ListFilesHandler: RequestHandler = async (req, res) => {
|
||||
export const CreateDirHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { path: dirPath } = req.body;
|
||||
const normalizedPath = normalizePath(dirPath);
|
||||
|
||||
let normalizedPath: string;
|
||||
try {
|
||||
normalizedPath = normalizePath(dirPath);
|
||||
} catch (pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保路径是绝对路径
|
||||
if (!path.isAbsolute(normalizedPath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
// 检查是否已存在同类型(目录)
|
||||
if (await checkSameTypeExists(normalizedPath, true)) {
|
||||
@@ -157,7 +262,19 @@ export const CreateDirHandler: RequestHandler = async (req, res) => {
|
||||
export const DeleteHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { path: targetPath } = req.body;
|
||||
const normalizedPath = normalizePath(targetPath);
|
||||
|
||||
let normalizedPath: string;
|
||||
try {
|
||||
normalizedPath = normalizePath(targetPath);
|
||||
} catch (pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保路径是绝对路径
|
||||
if (!path.isAbsolute(normalizedPath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
const stat = await fsProm.stat(normalizedPath);
|
||||
if (stat.isDirectory()) {
|
||||
await fsProm.rm(normalizedPath, { recursive: true });
|
||||
@@ -175,7 +292,18 @@ export const BatchDeleteHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { paths } = req.body;
|
||||
for (const targetPath of paths) {
|
||||
const normalizedPath = normalizePath(targetPath);
|
||||
let normalizedPath: string;
|
||||
try {
|
||||
normalizedPath = normalizePath(targetPath);
|
||||
} catch (pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保路径是绝对路径
|
||||
if (!path.isAbsolute(normalizedPath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
const stat = await fsProm.stat(normalizedPath);
|
||||
if (stat.isDirectory()) {
|
||||
await fsProm.rm(normalizedPath, { recursive: true });
|
||||
@@ -192,8 +320,25 @@ export const BatchDeleteHandler: RequestHandler = async (req, res) => {
|
||||
// 读取文件内容
|
||||
export const ReadFileHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const filePath = normalizePath(req.query['path'] as string);
|
||||
const content = await fsProm.readFile(filePath, 'utf-8');
|
||||
let filePath: string;
|
||||
try {
|
||||
filePath = normalizePath(getQueryStringParam(req.query['path']));
|
||||
} catch (pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保路径是绝对路径
|
||||
if (!path.isAbsolute(filePath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
let content = await fsProm.readFile(filePath, 'utf-8');
|
||||
|
||||
// 如果是WebUI配置文件,对token进行脱敏处理
|
||||
if (isWebUIConfigFile(filePath)) {
|
||||
content = sanitizeWebUIConfig(content);
|
||||
}
|
||||
|
||||
return sendSuccess(res, content);
|
||||
} catch (error) {
|
||||
return sendError(res, '读取文件失败');
|
||||
@@ -204,8 +349,40 @@ export const ReadFileHandler: RequestHandler = async (req, res) => {
|
||||
export const WriteFileHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { path: filePath, content } = req.body;
|
||||
const normalizedPath = normalizePath(filePath);
|
||||
await fsProm.writeFile(normalizedPath, content, 'utf-8');
|
||||
|
||||
// 安全的路径规范化,如果检测到路径遍历攻击会抛出异常
|
||||
let normalizedPath: string;
|
||||
try {
|
||||
normalizedPath = normalizePath(filePath);
|
||||
} catch (pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保路径是绝对路径
|
||||
if (!path.isAbsolute(normalizedPath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
let finalContent = content;
|
||||
|
||||
// 检查是否为WebUI配置文件
|
||||
if (isWebUIConfigFile(normalizedPath)) {
|
||||
try {
|
||||
// 解析要写入的配置
|
||||
const configToWrite = JSON.parse(content);
|
||||
// 获取内存中的token,覆盖前端传来的token
|
||||
const memoryToken = getInitialWebUiToken();
|
||||
if (memoryToken) {
|
||||
configToWrite.token = memoryToken;
|
||||
finalContent = JSON.stringify(configToWrite, null, 4);
|
||||
}
|
||||
} catch (e) {
|
||||
// 如果解析失败 说明不符合json格式 不允许写入
|
||||
return sendError(res, '写入的WebUI配置文件内容格式错误,必须是合法的JSON');
|
||||
}
|
||||
}
|
||||
|
||||
await fsProm.writeFile(normalizedPath, finalContent, 'utf-8');
|
||||
return sendSuccess(res, true);
|
||||
} catch (error) {
|
||||
return sendError(res, '写入文件失败');
|
||||
@@ -216,7 +393,18 @@ export const WriteFileHandler: RequestHandler = async (req, res) => {
|
||||
export const CreateFileHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { path: filePath } = req.body;
|
||||
const normalizedPath = normalizePath(filePath);
|
||||
|
||||
let normalizedPath: string;
|
||||
try {
|
||||
normalizedPath = normalizePath(filePath);
|
||||
} catch (pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保路径是绝对路径
|
||||
if (!path.isAbsolute(normalizedPath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
// 检查是否已存在同类型(文件)
|
||||
if (await checkSameTypeExists(normalizedPath, false)) {
|
||||
@@ -234,8 +422,21 @@ export const CreateFileHandler: RequestHandler = async (req, res) => {
|
||||
export const RenameHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { oldPath, newPath } = req.body;
|
||||
const normalizedOldPath = normalizePath(oldPath);
|
||||
const normalizedNewPath = normalizePath(newPath);
|
||||
|
||||
let normalizedOldPath: string;
|
||||
let normalizedNewPath: string;
|
||||
try {
|
||||
normalizedOldPath = normalizePath(oldPath);
|
||||
normalizedNewPath = normalizePath(newPath);
|
||||
} catch (pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保路径是绝对路径
|
||||
if (!path.isAbsolute(normalizedOldPath) || !path.isAbsolute(normalizedNewPath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
await fsProm.rename(normalizedOldPath, normalizedNewPath);
|
||||
return sendSuccess(res, true);
|
||||
} catch (error) {
|
||||
@@ -247,8 +448,21 @@ export const RenameHandler: RequestHandler = async (req, res) => {
|
||||
export const MoveHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { sourcePath, targetPath } = req.body;
|
||||
const normalizedSourcePath = normalizePath(sourcePath);
|
||||
const normalizedTargetPath = normalizePath(targetPath);
|
||||
|
||||
let normalizedSourcePath: string;
|
||||
let normalizedTargetPath: string;
|
||||
try {
|
||||
normalizedSourcePath = normalizePath(sourcePath);
|
||||
normalizedTargetPath = normalizePath(targetPath);
|
||||
} catch (pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保路径是绝对路径
|
||||
if (!path.isAbsolute(normalizedSourcePath) || !path.isAbsolute(normalizedTargetPath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
await fsProm.rename(normalizedSourcePath, normalizedTargetPath);
|
||||
return sendSuccess(res, true);
|
||||
} catch (error) {
|
||||
@@ -261,8 +475,20 @@ export const BatchMoveHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { items } = req.body;
|
||||
for (const { sourcePath, targetPath } of items) {
|
||||
const normalizedSourcePath = normalizePath(sourcePath);
|
||||
const normalizedTargetPath = normalizePath(targetPath);
|
||||
let normalizedSourcePath: string;
|
||||
let normalizedTargetPath: string;
|
||||
try {
|
||||
normalizedSourcePath = normalizePath(sourcePath);
|
||||
normalizedTargetPath = normalizePath(targetPath);
|
||||
} catch (pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保路径是绝对路径
|
||||
if (!path.isAbsolute(normalizedSourcePath) || !path.isAbsolute(normalizedTargetPath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
await fsProm.rename(normalizedSourcePath, normalizedTargetPath);
|
||||
}
|
||||
return sendSuccess(res, true);
|
||||
@@ -274,10 +500,21 @@ export const BatchMoveHandler: RequestHandler = async (req, res) => {
|
||||
// 新增:文件下载处理方法(注意流式传输,不将整个文件读入内存)
|
||||
export const DownloadHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const filePath = normalizePath(req.query['path'] as string);
|
||||
let filePath: string;
|
||||
try {
|
||||
filePath = normalizePath(getQueryStringParam(req.query['path']));
|
||||
} catch (pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
return sendError(res, '参数错误');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保路径是绝对路径
|
||||
if (!path.isAbsolute(filePath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
const stat = await fsProm.stat(filePath);
|
||||
|
||||
@@ -316,12 +553,25 @@ export const BatchDownloadHandler: RequestHandler = async (req, res) => {
|
||||
const zipStream = new compressing.zip.Stream();
|
||||
// 修改:根据文件类型设置 relativePath
|
||||
for (const filePath of paths) {
|
||||
const normalizedPath = normalizePath(filePath);
|
||||
let normalizedPath: string;
|
||||
try {
|
||||
normalizedPath = normalizePath(filePath);
|
||||
} catch (pathError) {
|
||||
return sendError(res, '无效的文件路径');
|
||||
}
|
||||
|
||||
// 额外安全检查:确保规范化后的路径是绝对路径
|
||||
if (!path.isAbsolute(normalizedPath)) {
|
||||
return sendError(res, '路径必须是绝对路径');
|
||||
}
|
||||
|
||||
const stat = await fsProm.stat(normalizedPath);
|
||||
if (stat.isDirectory()) {
|
||||
zipStream.addEntry(normalizedPath, { relativePath: '' });
|
||||
} else {
|
||||
zipStream.addEntry(normalizedPath, { relativePath: path.basename(normalizedPath) });
|
||||
// 确保相对路径只使用文件名,防止路径遍历
|
||||
const safeName = path.basename(normalizedPath);
|
||||
zipStream.addEntry(normalizedPath, { relativePath: safeName });
|
||||
}
|
||||
}
|
||||
zipStream.pipe(res);
|
||||
|
||||
@@ -5,6 +5,12 @@ import { terminalManager } from '../terminal/terminal_manager';
|
||||
import { WebUiConfig } from '@/webui';
|
||||
// 判断是否是 macos
|
||||
const isMacOS = process.platform === 'darwin';
|
||||
|
||||
// 日志脱敏函数
|
||||
const sanitizeLog = (log: string): string => {
|
||||
// 脱敏 token 参数,将 token=xxx 替换为 token=***
|
||||
return log.replace(/token=[\w\d]+/gi, 'token=***');
|
||||
};
|
||||
// 日志记录
|
||||
export const LogHandler: RequestHandler = async (req, res) => {
|
||||
const filename = req.query['id'];
|
||||
@@ -16,7 +22,8 @@ export const LogHandler: RequestHandler = async (req, res) => {
|
||||
return sendError(res, 'ID不合法');
|
||||
}
|
||||
const logContent = await WebUiConfig.GetLogContent(filename);
|
||||
return sendSuccess(res, logContent);
|
||||
const sanitizedLogContent = sanitizeLog(logContent);
|
||||
return sendSuccess(res, sanitizedLogContent);
|
||||
};
|
||||
|
||||
// 日志列表
|
||||
@@ -31,7 +38,8 @@ export const LogRealTimeHandler: RequestHandler = async (req, res) => {
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
const listener = (log: string) => {
|
||||
try {
|
||||
res.write(`data: ${log}\n\n`);
|
||||
const sanitizedLog = sanitizeLog(log);
|
||||
res.write(`data: ${sanitizedLog}\n\n`);
|
||||
} catch (error) {
|
||||
console.error('向客户端写入日志数据时出错:', error);
|
||||
}
|
||||
@@ -47,9 +55,6 @@ export const CreateTerminalHandler: RequestHandler = async (req, res) => {
|
||||
if (isMacOS) {
|
||||
return sendError(res, 'MacOS不支持终端');
|
||||
}
|
||||
if ((await WebUiConfig.GetWebUIConfig()).defaultToken) {
|
||||
return sendError(res, '该密码禁止创建终端');
|
||||
}
|
||||
try {
|
||||
const { cols, rows } = req.body;
|
||||
const { id } = terminalManager.createTerminal(cols, rows);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { RequestHandler } from 'express';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { loadConfig, OneBotConfig } from '@/onebot/config/config';
|
||||
import { WebUiConfig, webUiPathWrapper } from '@/webui';
|
||||
import { webUiPathWrapper } from '@/webui';
|
||||
import { WebUiDataRuntime } from '@webapi/helper/Data';
|
||||
import { sendError, sendSuccess } from '@webapi/utils/response';
|
||||
import { isEmpty } from '@webapi/utils/check';
|
||||
@@ -47,10 +47,6 @@ export const OB11SetConfigHandler: RequestHandler = async (req, res) => {
|
||||
if (isEmpty(req.body.config)) {
|
||||
return sendError(res, 'config is empty');
|
||||
}
|
||||
const webuiToken = await WebUiConfig.GetWebUIConfig();
|
||||
if (webuiToken.defaultToken) {
|
||||
return sendError(res, '默认密码禁止写入配置');
|
||||
}
|
||||
// 写入配置
|
||||
try {
|
||||
// 解析并加载配置
|
||||
@@ -61,4 +57,4 @@ export const OB11SetConfigHandler: RequestHandler = async (req, res) => {
|
||||
} catch (e) {
|
||||
return sendError(res, 'Error: ' + e);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -14,6 +14,12 @@ const LoginRuntime: LoginRuntimeType = {
|
||||
nick: '',
|
||||
},
|
||||
QQVersion: 'unknown',
|
||||
onQQLoginStatusChange: async (status: boolean) => {
|
||||
LoginRuntime.QQLoginStatus = status;
|
||||
},
|
||||
onWebUiTokenChange: async (_token: string) => {
|
||||
return;
|
||||
},
|
||||
NapCatHelper: {
|
||||
onOB11ConfigChanged: async () => {
|
||||
return;
|
||||
@@ -31,6 +37,12 @@ const LoginRuntime: LoginRuntimeType = {
|
||||
};
|
||||
|
||||
export const WebUiDataRuntime = {
|
||||
setWebUiTokenChangeCallback(func: (token: string) => Promise<void>): void {
|
||||
LoginRuntime.onWebUiTokenChange = func;
|
||||
},
|
||||
getWebUiTokenChangeCallback(): (token: string) => Promise<void> {
|
||||
return LoginRuntime.onWebUiTokenChange;
|
||||
},
|
||||
checkLoginRate(ip: string, RateLimit: number): boolean {
|
||||
const key = `login_rate:${ip}`;
|
||||
const count = store.get<number>(key) || 0;
|
||||
@@ -53,6 +65,14 @@ export const WebUiDataRuntime = {
|
||||
return LoginRuntime.QQLoginStatus;
|
||||
},
|
||||
|
||||
setQQLoginCallback(func: (status: boolean) => Promise<void>): void {
|
||||
LoginRuntime.onQQLoginStatusChange = func;
|
||||
},
|
||||
|
||||
getQQLoginCallback(): (status: boolean) => Promise<void> {
|
||||
return LoginRuntime.onQQLoginStatusChange;
|
||||
},
|
||||
|
||||
setQQLoginStatus(status: LoginRuntimeType['QQLoginStatus']): void {
|
||||
LoginRuntime.QQLoginStatus = status;
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { webUiPathWrapper } from '@/webui';
|
||||
import { webUiPathWrapper, getInitialWebUiToken } from '@/webui';
|
||||
import { Type, Static } from '@sinclair/typebox';
|
||||
import Ajv from 'ajv';
|
||||
import fs, { constants } from 'node:fs/promises';
|
||||
@@ -7,18 +7,17 @@ import { resolve } from 'node:path';
|
||||
|
||||
import { deepMerge } from '../utils/object';
|
||||
import { themeType } from '../types/theme';
|
||||
import { getRandomToken } from '../utils/url'
|
||||
|
||||
// 限制尝试端口的次数,避免死循环
|
||||
// 定义配置的类型
|
||||
const WebUiConfigSchema = Type.Object({
|
||||
host: Type.String({ default: '0.0.0.0' }),
|
||||
host: Type.String({ default: '127.0.0.1' }),
|
||||
port: Type.Number({ default: 6099 }),
|
||||
// napcat+<月份日时>,例如 napcat062511
|
||||
token: Type.String({ default: 'napcat' + (new Date().getMonth() + 1).toString().padStart(2, '0') + new Date().getDate().toString().padStart(2, '0') + new Date().getHours().toString().padStart(2, '0') }),
|
||||
token: Type.String({ default: getRandomToken(8) }),
|
||||
loginRate: Type.Number({ default: 10 }),
|
||||
autoLoginAccount: Type.String({ default: '' }),
|
||||
theme: themeType,
|
||||
defaultToken: Type.Boolean({ default: true }),
|
||||
// 是否关闭WebUI
|
||||
disableWebUI: Type.Boolean({ default: false }),
|
||||
// 是否关闭非局域网访问
|
||||
@@ -64,6 +63,47 @@ export class WebUiConfigWrapper {
|
||||
}
|
||||
|
||||
async GetWebUIConfig(): Promise<WebUiConfigType> {
|
||||
if (this.WebUiConfigData) {
|
||||
return this.WebUiConfigData
|
||||
}
|
||||
|
||||
try {
|
||||
const configPath = resolve(webUiPathWrapper.configPath, './webui.json');
|
||||
await this.ensureConfigFileExists(configPath);
|
||||
const parsedConfig = await this.readAndValidateConfig(configPath);
|
||||
// 使用内存中缓存的token进行覆盖,确保强兼容性
|
||||
this.WebUiConfigData = {
|
||||
...parsedConfig,
|
||||
// 首次读取内存中是没有token的,需要进行一层兜底
|
||||
token: getInitialWebUiToken() || parsedConfig.token,
|
||||
};
|
||||
return this.WebUiConfigData;
|
||||
} catch (e) {
|
||||
console.log('读取配置文件失败', e);
|
||||
const defaultConfig = this.validateAndApplyDefaults({});
|
||||
this.WebUiConfigData = {
|
||||
...defaultConfig,
|
||||
token: getInitialWebUiToken() || defaultConfig.token,
|
||||
}
|
||||
return this.WebUiConfigData;
|
||||
}
|
||||
}
|
||||
|
||||
async UpdateWebUIConfig(newConfig: Partial<WebUiConfigType>): Promise<void> {
|
||||
const configPath = resolve(webUiPathWrapper.configPath, './webui.json');
|
||||
// 使用原始配置进行合并,避免内存token覆盖影响配置更新
|
||||
const currentConfig = await this.GetRawWebUIConfig();
|
||||
const mergedConfig = deepMerge({ ...currentConfig }, newConfig);
|
||||
const updatedConfig = this.validateAndApplyDefaults(mergedConfig);
|
||||
await this.writeConfig(configPath, updatedConfig);
|
||||
this.WebUiConfigData = updatedConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置文件中实际存储的配置(不被内存token覆盖)
|
||||
* 主要用于配置更新和特殊场景
|
||||
*/
|
||||
async GetRawWebUIConfig(): Promise<WebUiConfigType> {
|
||||
if (this.WebUiConfigData) {
|
||||
return this.WebUiConfigData;
|
||||
}
|
||||
@@ -79,21 +119,15 @@ export class WebUiConfigWrapper {
|
||||
}
|
||||
}
|
||||
|
||||
async UpdateWebUIConfig(newConfig: Partial<WebUiConfigType>): Promise<void> {
|
||||
const configPath = resolve(webUiPathWrapper.configPath, './webui.json');
|
||||
const currentConfig = await this.GetWebUIConfig();
|
||||
const mergedConfig = deepMerge({ ...currentConfig }, newConfig);
|
||||
const updatedConfig = this.validateAndApplyDefaults(mergedConfig);
|
||||
await this.writeConfig(configPath, updatedConfig);
|
||||
this.WebUiConfigData = updatedConfig;
|
||||
}
|
||||
|
||||
async UpdateToken(oldToken: string, newToken: string): Promise<void> {
|
||||
const currentConfig = await this.GetWebUIConfig();
|
||||
if (currentConfig.token !== oldToken) {
|
||||
// 使用内存中缓存的token进行验证,确保强兼容性
|
||||
const cachedToken = getInitialWebUiToken();
|
||||
const tokenToCheck = cachedToken || (await this.GetWebUIConfig()).token;
|
||||
|
||||
if (tokenToCheck !== oldToken) {
|
||||
throw new Error('旧 token 不匹配');
|
||||
}
|
||||
await this.UpdateWebUIConfig({ token: newToken, defaultToken: false });
|
||||
await this.UpdateWebUIConfig({ token: newToken });
|
||||
}
|
||||
|
||||
// 获取日志文件夹路径
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
|
||||
import { WebUiConfig } from '@/webui';
|
||||
import { getInitialWebUiToken } from '@/webui';
|
||||
|
||||
import { AuthHelper } from '@webapi/helper/SignToken';
|
||||
import { sendError } from '@webapi/utils/response';
|
||||
@@ -30,10 +30,13 @@ export async function auth(req: Request, res: Response, next: NextFunction) {
|
||||
} catch (e) {
|
||||
return sendError(res, 'Unauthorized');
|
||||
}
|
||||
// 获取配置
|
||||
const config = await WebUiConfig.GetWebUIConfig();
|
||||
// 使用启动时缓存的token进行验证,而不是动态读取配置文件 因为有可能运行时手动修改了密码
|
||||
const initialToken = getInitialWebUiToken();
|
||||
if (!initialToken) {
|
||||
return sendError(res, 'Server token not initialized');
|
||||
}
|
||||
// 验证凭证在1小时内有效
|
||||
const credentialJson = AuthHelper.validateCredentialWithinOneHour(config.token, Credential);
|
||||
const credentialJson = AuthHelper.validateCredentialWithinOneHour(initialToken, Credential);
|
||||
if (credentialJson) {
|
||||
// 通过验证
|
||||
return next();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
|
||||
import {
|
||||
CheckDefaultTokenHandler,
|
||||
checkHandler,
|
||||
LoginHandler,
|
||||
LogoutHandler,
|
||||
@@ -17,7 +16,5 @@ router.post('/check', checkHandler);
|
||||
router.post('/logout', LogoutHandler);
|
||||
// router:更新token
|
||||
router.post('/update_token', UpdateTokenHandler);
|
||||
// router:检查默认token
|
||||
router.get('/check_using_default_token', CheckDefaultTokenHandler);
|
||||
|
||||
export { router as AuthRouter };
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
import path from 'path';
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
export function callsites () {
|
||||
const _prepareStackTrace = Error.prepareStackTrace
|
||||
try {
|
||||
let result: NodeJS.CallSite[] = []
|
||||
Error.prepareStackTrace = (_, callSites) => {
|
||||
const callSitesWithoutCurrent = callSites.slice(1)
|
||||
result = callSitesWithoutCurrent
|
||||
return callSitesWithoutCurrent
|
||||
}
|
||||
|
||||
new Error().stack
|
||||
return result
|
||||
} finally {
|
||||
Error.prepareStackTrace = _prepareStackTrace
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(global, '__dirname', {
|
||||
get() {
|
||||
const err = new Error();
|
||||
const stack = err.stack?.split('\n') || [];
|
||||
let callerFile = '';
|
||||
// 遍历错误堆栈,跳过当前文件所在行
|
||||
// 注意:堆栈格式可能不同,请根据实际环境调整索引及正则表达式
|
||||
for (const line of stack) {
|
||||
const match = line.match(/\((.*):\d+:\d+\)/);
|
||||
if (match?.[1]) {
|
||||
callerFile = match[1];
|
||||
if (!callerFile.includes('init-dynamic-dirname.ts')) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
get () {
|
||||
const sites = callsites()
|
||||
const file = sites?.[1]?.getFileName()
|
||||
if (file) {
|
||||
return path.dirname(fileURLToPath(file))
|
||||
}
|
||||
return callerFile ? path.dirname(callerFile) : '';
|
||||
return ''
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import './init-dynamic-dirname';
|
||||
// import './init-dynamic-dirname';
|
||||
import { WebUiConfig } from '@/webui';
|
||||
import { AuthHelper } from '../helper/SignToken';
|
||||
import { LogWrapper } from '@/common/log';
|
||||
|
||||
2
src/webui/src/types/data.d.ts
vendored
2
src/webui/src/types/data.d.ts
vendored
@@ -9,6 +9,8 @@ interface LoginRuntimeType {
|
||||
QQLoginUin: string;
|
||||
QQLoginInfo: SelfInfo;
|
||||
QQVersion: string;
|
||||
onQQLoginStatusChange: (status: boolean) => Promise<void>;
|
||||
onWebUiTokenChange: (token: string) => Promise<void>;
|
||||
WebUiConfigQuickFunction: () => Promise<void>;
|
||||
NapCatHelper: {
|
||||
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string }>;
|
||||
|
||||
@@ -66,7 +66,15 @@ export const createDiskStorage = (uploadPath: string) => {
|
||||
};
|
||||
|
||||
export const createDiskUpload = (uploadPath: string) => {
|
||||
const upload = multer({ storage: createDiskStorage(uploadPath) }).array('files');
|
||||
const upload = multer({
|
||||
storage: createDiskStorage(uploadPath),
|
||||
limits: {
|
||||
fileSize: 100 * 1024 * 1024, // 100MB 文件大小限制
|
||||
files: 20, // 最多同时上传20个文件
|
||||
fieldSize: 1024 * 1024, // 1MB 字段大小限制
|
||||
fields: 10 // 最多10个字段
|
||||
}
|
||||
}).array('files');
|
||||
return upload;
|
||||
};
|
||||
|
||||
@@ -76,6 +84,18 @@ const diskUploader = (req: Request, res: Response) => {
|
||||
createDiskUpload(uploadPath)(req, res, (error) => {
|
||||
if (error) {
|
||||
// 错误处理
|
||||
if (error.code === 'LIMIT_FILE_SIZE') {
|
||||
return reject(new Error('文件大小超过限制(最大100MB)'));
|
||||
}
|
||||
if (error.code === 'LIMIT_FILE_COUNT') {
|
||||
return reject(new Error('文件数量超过限制(最多20个文件)'));
|
||||
}
|
||||
if (error.code === 'LIMIT_FIELD_VALUE') {
|
||||
return reject(new Error('字段值大小超过限制'));
|
||||
}
|
||||
if (error.code === 'LIMIT_FIELD_COUNT') {
|
||||
return reject(new Error('字段数量超过限制'));
|
||||
}
|
||||
return reject(error);
|
||||
}
|
||||
return resolve(true);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { isIP } from 'node:net';
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* 将 host(主机地址) 转换为标准格式
|
||||
@@ -44,3 +45,13 @@ export const createUrl = (
|
||||
}
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成随机Token
|
||||
* @param length Token长度 默认8
|
||||
* @returns 随机Token字符串
|
||||
* @example getRandomToken
|
||||
*/
|
||||
export const getRandomToken = (length = 8) => {
|
||||
return randomBytes(36).toString('hex').slice(0, length);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user