mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-01 16:20:25 +00:00
Compare commits
42 Commits
v4.8.109
...
copilot/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
818224578b | ||
|
|
94bc4f8301 | ||
|
|
94676998cc | ||
|
|
2afdb2a0da | ||
|
|
5bfbf92c21 | ||
|
|
a775a0dde9 | ||
|
|
d7f00c0594 | ||
|
|
77c8f874b6 | ||
|
|
fb0a20919b | ||
|
|
0300ba4648 | ||
|
|
d472eee777 | ||
|
|
41bd06e50a | ||
|
|
97334dfbf5 | ||
|
|
e3d8c8e940 | ||
|
|
f2c62db76e | ||
|
|
b1b051c4ce | ||
|
|
a754b2ecc7 | ||
|
|
e0eb625b75 | ||
|
|
937be7678e | ||
|
|
9b88946209 | ||
|
|
74de3d9100 | ||
|
|
42d50014a1 | ||
|
|
a36ae315b0 | ||
|
|
2161ec5fa7 | ||
|
|
32bba007cd | ||
|
|
84d3dc9f40 | ||
|
|
890d032794 | ||
|
|
66f30e1ebf | ||
|
|
ada614d007 | ||
|
|
ea3ab7f13f | ||
|
|
a5e4c24de3 | ||
|
|
bcc7d25b64 | ||
|
|
aae676fdc7 | ||
|
|
0e9aa43476 | ||
|
|
b2ff556aa6 | ||
|
|
69c5b78678 | ||
|
|
8be7f74e9f | ||
|
|
a05150ebe1 | ||
|
|
5e6b607ded | ||
|
|
df2dabfe76 | ||
|
|
5e032fcc6a | ||
|
|
44200a2208 |
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -9,4 +9,9 @@
|
|||||||
"css.customData": [
|
"css.customData": [
|
||||||
".vscode/tailwindcss.json"
|
".vscode/tailwindcss.json"
|
||||||
],
|
],
|
||||||
|
"editor.formatOnPaste": false,
|
||||||
|
"editor.formatOnSave": false,
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": "never"
|
||||||
|
},
|
||||||
}
|
}
|
||||||
17
README.md
17
README.md
@@ -13,6 +13,15 @@ _Modern protocol-side framework implemented based on NTQQ._
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## New Feature
|
||||||
|
在 v4.8.115+ 版本开始
|
||||||
|
|
||||||
|
1. NapCatQQ 支持 [Stream Api](https://napneko.github.io/develop/file)
|
||||||
|
2. NapCatQQ 推荐 message_id/user_id/group_id 均使用字符串类型
|
||||||
|
|
||||||
|
- [1] 解决 Docker/跨设备/大文件 的多媒体上下传问题
|
||||||
|
- [2] 采用字符串可以解决扩展到int64的问题,同时也可以解决部分语言(如JavaScript)对大整数支持不佳的问题,增加极少成本。
|
||||||
|
|
||||||
## Welcome
|
## Welcome
|
||||||
+ NapCatQQ is a modern implementation of the Bot protocol based on NTQQ.
|
+ NapCatQQ is a modern implementation of the Bot protocol based on NTQQ.
|
||||||
- NapCatQQ 是现代化的基于 NTQQ 的 Bot 协议端实现
|
- NapCatQQ 是现代化的基于 NTQQ 的 Bot 协议端实现
|
||||||
@@ -33,6 +42,7 @@ _Modern protocol-side framework implemented based on NTQQ._
|
|||||||
|
|
||||||
**首次使用**请务必查看如下文档看使用教程
|
**首次使用**请务必查看如下文档看使用教程
|
||||||
|
|
||||||
|
> 项目非盈利,对接问题/基础问题/下层框架问题 请自行搜索解决,本项目社区不提供此类解答。
|
||||||
## Link
|
## Link
|
||||||
|
|
||||||
| Docs | [](https://napneko.github.io/) | [](https://doc.napneko.icu/) | [](https://napcat.napneko.icu/) |
|
| Docs | [](https://napneko.github.io/) | [](https://doc.napneko.icu/) | [](https://napcat.napneko.icu/) |
|
||||||
@@ -41,12 +51,17 @@ _Modern protocol-side framework implemented based on NTQQ._
|
|||||||
| Docs | [](https://napneko.pages.dev/) | [](https://napcat.cyou/) | [](https://www.napcat.wiki) |
|
| Docs | [](https://napneko.pages.dev/) | [](https://napcat.cyou/) | [](https://www.napcat.wiki) |
|
||||||
|:-:|:-:|:-:|:-:|
|
|:-:|:-:|:-:|:-:|
|
||||||
|
|
||||||
| 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) |
|
| QQ Group | [](https://qm.qq.com/q/CMmPbGw0jA) | [](https://qm.qq.com/q/8zJMLjqy2Y) | [](https://qm.qq.com/q/CMmPbGw0jA) | [](https://qm.qq.com/q/I6LU87a0Yq) |
|
||||||
|:-:|:-:|:-:|:-:|:-:|
|
|:-:|:-:|:-:|:-:|:-:|
|
||||||
|
|
||||||
| Telegram | [](https://t.me/napcatqq) |
|
| Telegram | [](https://t.me/napcatqq) |
|
||||||
|:-:|:-:|
|
|:-:|:-:|
|
||||||
|
|
||||||
|
| DeepWiki | [](https://deepwiki.com/NapNeko/NapCatQQ) |
|
||||||
|
|:-:|:-:|
|
||||||
|
|
||||||
|
> 请不要在其余社区提及本项目(包括其余协议端/相关应用端项目)引发争论,如有建议到达官方交流群讨论或PR。
|
||||||
|
|
||||||
## Thanks
|
## Thanks
|
||||||
|
|
||||||
+ [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) 对本项目的大力支持 参考部分代码 已获授权
|
+ [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) 对本项目的大力支持 参考部分代码 已获授权
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ set NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe
|
|||||||
set NAPCAT_MAIN_PATH=%cd%\napcat.mjs
|
set NAPCAT_MAIN_PATH=%cd%\napcat.mjs
|
||||||
:loop_read
|
:loop_read
|
||||||
for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do (
|
for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do (
|
||||||
set RetString=%%b
|
set "RetString=%%~b"
|
||||||
goto :napcat_boot
|
goto :napcat_boot
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ for %%a in ("%RetString%") do (
|
|||||||
set "pathWithoutUninstall=%%~dpa"
|
set "pathWithoutUninstall=%%~dpa"
|
||||||
)
|
)
|
||||||
|
|
||||||
SET QQPath=%pathWithoutUninstall%QQ.exe
|
set "QQPath=%pathWithoutUninstall%QQ.exe"
|
||||||
|
|
||||||
if not exist "%QQpath%" (
|
if not exist "%QQpath%" (
|
||||||
echo provided QQ path is invalid
|
echo provided QQ path is invalid
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ set NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe
|
|||||||
set NAPCAT_MAIN_PATH=%cd%\napcat.mjs
|
set NAPCAT_MAIN_PATH=%cd%\napcat.mjs
|
||||||
:loop_read
|
:loop_read
|
||||||
for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do (
|
for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do (
|
||||||
set RetString=%%b
|
set "RetString=%%~b"
|
||||||
goto :napcat_boot
|
goto :napcat_boot
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ for %%a in ("%RetString%") do (
|
|||||||
set "pathWithoutUninstall=%%~dpa"
|
set "pathWithoutUninstall=%%~dpa"
|
||||||
)
|
)
|
||||||
|
|
||||||
SET QQPath=%pathWithoutUninstall%QQ.exe
|
set "QQPath=%pathWithoutUninstall%QQ.exe"
|
||||||
|
|
||||||
if not exist "%QQpath%" (
|
if not exist "%QQpath%" (
|
||||||
echo provided QQ path is invalid
|
echo provided QQ path is invalid
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ set NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe
|
|||||||
set NAPCAT_MAIN_PATH=%cd%\napcat.mjs
|
set NAPCAT_MAIN_PATH=%cd%\napcat.mjs
|
||||||
:loop_read
|
:loop_read
|
||||||
for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do (
|
for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do (
|
||||||
set RetString=%%b
|
set "RetString=%%~b"
|
||||||
goto :napcat_boot
|
goto :napcat_boot
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ for %%a in ("%RetString%") do (
|
|||||||
set "pathWithoutUninstall=%%~dpa"
|
set "pathWithoutUninstall=%%~dpa"
|
||||||
)
|
)
|
||||||
|
|
||||||
SET QQPath=%pathWithoutUninstall%QQ.exe
|
set "QQPath=%pathWithoutUninstall%QQ.exe"
|
||||||
|
|
||||||
if not exist "%QQPath%" (
|
if not exist "%QQPath%" (
|
||||||
echo provided QQ path is invalid
|
echo provided QQ path is invalid
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ set NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe
|
|||||||
set NAPCAT_MAIN_PATH=%cd%\napcat.mjs
|
set NAPCAT_MAIN_PATH=%cd%\napcat.mjs
|
||||||
:loop_read
|
:loop_read
|
||||||
for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do (
|
for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do (
|
||||||
set RetString=%%b
|
set "RetString=%%~b"
|
||||||
goto :napcat_boot
|
goto :napcat_boot
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ for %%a in ("%RetString%") do (
|
|||||||
set "pathWithoutUninstall=%%~dpa"
|
set "pathWithoutUninstall=%%~dpa"
|
||||||
)
|
)
|
||||||
|
|
||||||
SET QQPath=%pathWithoutUninstall%QQ.exe
|
set "QQPath=%pathWithoutUninstall%QQ.exe"
|
||||||
|
|
||||||
if not exist "%QQPath%" (
|
if not exist "%QQPath%" (
|
||||||
echo provided QQ path is invalid
|
echo provided QQ path is invalid
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "qq-chat",
|
"name": "qq-chat",
|
||||||
"version": "9.9.19-34740",
|
"verHash": "cc326038",
|
||||||
"verHash": "f31348f2",
|
"version": "9.9.21-39038",
|
||||||
"linuxVersion": "3.2.17-34740",
|
"linuxVersion": "3.2.19-39038",
|
||||||
"linuxVerHash": "5aa2d8d6",
|
"linuxVerHash": "c773cdf7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "QQ",
|
"description": "QQ",
|
||||||
"productName": "QQ",
|
"productName": "QQ",
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"qd": "externals/devtools/cli/index.js"
|
"qd": "externals/devtools/cli/index.js"
|
||||||
},
|
},
|
||||||
"main": "./loadNapCat.js",
|
"main": "./loadNapCat.js",
|
||||||
"buildVersion": "34740",
|
"buildVersion": "39038",
|
||||||
"isPureShell": true,
|
"isPureShell": true,
|
||||||
"isByteCodeShell": true,
|
"isByteCodeShell": true,
|
||||||
"platform": "win32",
|
"platform": "win32",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"name": "NapCatQQ",
|
"name": "NapCatQQ",
|
||||||
"slug": "NapCat.Framework",
|
"slug": "NapCat.Framework",
|
||||||
"description": "高性能的 OneBot 11 协议实现",
|
"description": "高性能的 OneBot 11 协议实现",
|
||||||
"version": "4.8.108",
|
"version": "4.8.119",
|
||||||
"icon": "./logo.png",
|
"icon": "./logo.png",
|
||||||
"authors": [
|
"authors": [
|
||||||
{
|
{
|
||||||
|
|||||||
24
napcat.webui/package-lock.json
generated
24
napcat.webui/package-lock.json
generated
@@ -93,6 +93,7 @@
|
|||||||
"@types/node": "^22.12.0",
|
"@types/node": "^22.12.0",
|
||||||
"@types/path-browserify": "^1.0.3",
|
"@types/path-browserify": "^1.0.3",
|
||||||
"@types/react": "^19.0.8",
|
"@types/react": "^19.0.8",
|
||||||
|
"@types/react-color": "^3.0.13",
|
||||||
"@types/react-dom": "^19.0.3",
|
"@types/react-dom": "^19.0.3",
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.22.0",
|
"@typescript-eslint/eslint-plugin": "^8.22.0",
|
||||||
@@ -8152,6 +8153,19 @@
|
|||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/react-color": {
|
||||||
|
"version": "3.0.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react-color/-/react-color-3.0.13.tgz",
|
||||||
|
"integrity": "sha512-2c/9FZ4ixC5T3JzN0LP5Cke2Mf0MKOP2Eh0NPDPWmuVH3NjPyhEjqNMQpN1Phr5m74egAy+p2lYNAFrX1z9Yrg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/reactcss": "*"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/react-dom": {
|
"node_modules/@types/react-dom": {
|
||||||
"version": "19.1.6",
|
"version": "19.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz",
|
||||||
@@ -8172,6 +8186,16 @@
|
|||||||
"@types/react": "*"
|
"@types/react": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/reactcss": {
|
||||||
|
"version": "1.2.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/reactcss/-/reactcss-1.2.13.tgz",
|
||||||
|
"integrity": "sha512-gi3S+aUi6kpkF5vdhUsnkwbiSEIU/BEJyD7kBy2SudWBUuKmJk8AQKE0OVcQQeEy40Azh0lV6uynxlikYIJuwg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/unist": {
|
"node_modules/@types/unist": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||||
|
|||||||
@@ -95,6 +95,7 @@
|
|||||||
"@types/node": "^22.12.0",
|
"@types/node": "^22.12.0",
|
||||||
"@types/path-browserify": "^1.0.3",
|
"@types/path-browserify": "^1.0.3",
|
||||||
"@types/react": "^19.0.8",
|
"@types/react": "^19.0.8",
|
||||||
|
"@types/react-color": "^3.0.13",
|
||||||
"@types/react-dom": "^19.0.3",
|
"@types/react-dom": "^19.0.3",
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.22.0",
|
"@typescript-eslint/eslint-plugin": "^8.22.0",
|
||||||
|
|||||||
@@ -62,24 +62,39 @@ const NetworkDisplayCard = <T extends keyof NetworkType>({
|
|||||||
<ButtonGroup
|
<ButtonGroup
|
||||||
fullWidth
|
fullWidth
|
||||||
isDisabled={editing}
|
isDisabled={editing}
|
||||||
radius="full"
|
radius="sm"
|
||||||
size="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>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
color={debug ? 'success' : 'default'}
|
color={debug ? 'secondary' : 'success'}
|
||||||
startContent={<CgDebug />}
|
variant="flat"
|
||||||
|
startContent={
|
||||||
|
<CgDebug
|
||||||
|
style={{
|
||||||
|
width: '16px',
|
||||||
|
height: '16px',
|
||||||
|
minWidth: '16px',
|
||||||
|
minHeight: '16px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
onPress={handleEnableDebug}
|
onPress={handleEnableDebug}
|
||||||
>
|
>
|
||||||
{debug ? '关闭调试' : '开启调试'}
|
{debug ? '关闭调试' : '开启调试'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="primary"
|
className="bg-danger/20 text-danger hover:bg-danger/30 transition-colors"
|
||||||
startContent={<MdDeleteForever />}
|
variant="flat"
|
||||||
|
startContent={<MdDeleteForever size={16} />}
|
||||||
onPress={handleDelete}
|
onPress={handleDelete}
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export default function FileTable({
|
|||||||
setPreviewImages([])
|
setPreviewImages([])
|
||||||
setPreviewIndex(0)
|
setPreviewIndex(0)
|
||||||
setShowImage(false)
|
setShowImage(false)
|
||||||
|
setPage(1)
|
||||||
}, [currentPath])
|
}, [currentPath])
|
||||||
|
|
||||||
const onPreviewImage = (name: string, images: PreviewImage[]) => {
|
const onPreviewImage = (name: string, images: PreviewImage[]) => {
|
||||||
|
|||||||
@@ -171,7 +171,8 @@ const GenericForm = <T extends keyof NetworkConfigType>({
|
|||||||
|
|
||||||
export default GenericForm
|
export default GenericForm
|
||||||
export function random_token(length: number) {
|
export function random_token(length: number) {
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%^&*()-_=+[]{}|;:,.<>?'
|
const chars =
|
||||||
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%^&*()-_=+[]{}|;:,.<>?'
|
||||||
let result = ''
|
let result = ''
|
||||||
for (let i = 0; i < length; i++) {
|
for (let i = 0; i < length; i++) {
|
||||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import CryptoJS from 'crypto-js'
|
||||||
import { EventSourcePolyfill } from 'event-source-polyfill'
|
import { EventSourcePolyfill } from 'event-source-polyfill'
|
||||||
|
|
||||||
import { LogLevel } from '@/const/enum'
|
import { LogLevel } from '@/const/enum'
|
||||||
|
|
||||||
import { serverRequest } from '@/utils/request'
|
import { serverRequest } from '@/utils/request'
|
||||||
import CryptoJS from "crypto-js";
|
|
||||||
export interface Log {
|
export interface Log {
|
||||||
level: LogLevel
|
level: LogLevel
|
||||||
message: string
|
message: string
|
||||||
@@ -17,7 +18,7 @@ export default class WebUIManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static async loginWithToken(token: string) {
|
public static async loginWithToken(token: string) {
|
||||||
const sha256 = CryptoJS.SHA256(token + '.napcat').toString();
|
const sha256 = CryptoJS.SHA256(token + '.napcat').toString()
|
||||||
const { data } = await serverRequest.post<ServerResponse<AuthResponse>>(
|
const { data } = await serverRequest.post<ServerResponse<AuthResponse>>(
|
||||||
'/auth/login',
|
'/auth/login',
|
||||||
{ hash: sha256 }
|
{ hash: sha256 }
|
||||||
@@ -33,21 +34,6 @@ export default class WebUIManager {
|
|||||||
return data.data
|
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 = '') {
|
public static async proxy<T>(url = '') {
|
||||||
const data = await serverRequest.get<ServerResponse<string>>(
|
const data = await serverRequest.get<ServerResponse<string>>(
|
||||||
'/base/proxy?url=' + encodeURIComponent(url)
|
'/base/proxy?url=' + encodeURIComponent(url)
|
||||||
@@ -211,4 +197,13 @@ export default class WebUIManager {
|
|||||||
)
|
)
|
||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清理缓存
|
||||||
|
public static async cleanCache() {
|
||||||
|
const { data } =
|
||||||
|
await serverRequest.post<
|
||||||
|
ServerResponse<{ result: boolean; message: string }>
|
||||||
|
>('/base/CleanCache')
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Input } from '@heroui/input'
|
import { Input } from '@heroui/input'
|
||||||
import { useLocalStorage } from '@uidotdev/usehooks'
|
import { useLocalStorage } from '@uidotdev/usehooks'
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { Controller, useForm } from 'react-hook-form'
|
import { Controller, useForm } from 'react-hook-form'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
@@ -12,14 +11,12 @@ import SaveButtons from '@/components/button/save_buttons'
|
|||||||
import WebUIManager from '@/controllers/webui_manager'
|
import WebUIManager from '@/controllers/webui_manager'
|
||||||
|
|
||||||
const ChangePasswordCard = () => {
|
const ChangePasswordCard = () => {
|
||||||
const [isDefaultToken, setIsDefaultToken] = useState<boolean>(false)
|
|
||||||
const [isLoadingCheck, setIsLoadingCheck] = useState<boolean>(true)
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
handleSubmit: handleWebuiSubmit,
|
handleSubmit: handleWebuiSubmit,
|
||||||
formState: { isSubmitting },
|
formState: { isSubmitting, errors },
|
||||||
reset
|
reset,
|
||||||
|
watch
|
||||||
} = useForm<{
|
} = useForm<{
|
||||||
oldToken: string
|
oldToken: string
|
||||||
newToken: string
|
newToken: string
|
||||||
@@ -33,31 +30,13 @@ const ChangePasswordCard = () => {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [_, setToken] = useLocalStorage(key.token, '')
|
const [_, setToken] = useLocalStorage(key.token, '')
|
||||||
|
|
||||||
// 检查是否使用默认密码
|
// 监听旧密码的值
|
||||||
useEffect(() => {
|
const oldTokenValue = watch('oldToken')
|
||||||
const checkDefaultToken = async () => {
|
|
||||||
try {
|
|
||||||
const isDefault = await WebUIManager.checkUsingDefaultToken()
|
|
||||||
setIsDefaultToken(isDefault)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('检查默认密码状态失败:', error)
|
|
||||||
} finally {
|
|
||||||
setIsLoadingCheck(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkDefaultToken()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const onSubmit = handleWebuiSubmit(async (data) => {
|
const onSubmit = handleWebuiSubmit(async (data) => {
|
||||||
try {
|
try {
|
||||||
if (isDefaultToken) {
|
// 使用正常密码更新流程
|
||||||
// 从默认密码更新
|
await WebUIManager.changePassword(data.oldToken, data.newToken)
|
||||||
await WebUIManager.changePasswordFromDefault(data.newToken)
|
|
||||||
} else {
|
|
||||||
// 正常密码更新
|
|
||||||
await WebUIManager.changePassword(data.oldToken, data.newToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success('修改成功')
|
toast.success('修改成功')
|
||||||
setToken('')
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<title>修改密码 - NapCat WebUI</title>
|
<title>修改密码 - NapCat WebUI</title>
|
||||||
|
|
||||||
{isDefaultToken && (
|
<Controller
|
||||||
<div className="mb-4 p-3 bg-warning-50 border border-warning-200 rounded-lg">
|
control={control}
|
||||||
<p className="text-warning-700 text-sm">
|
name="oldToken"
|
||||||
检测到您正在使用默认密码,为了安全起见,请立即设置新密码。
|
rules={{
|
||||||
</p>
|
required: '旧密码不能为空',
|
||||||
</div>
|
validate: (value) => {
|
||||||
)}
|
if (!value || value.trim().length === 0) {
|
||||||
|
return '旧密码不能为空'
|
||||||
{!isDefaultToken && (
|
}
|
||||||
<Controller
|
return true
|
||||||
control={control}
|
}
|
||||||
name="oldToken"
|
}}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
label="旧密码"
|
label="旧密码"
|
||||||
placeholder="请输入旧密码"
|
placeholder="请输入旧密码"
|
||||||
type="password"
|
type="password"
|
||||||
/>
|
isRequired
|
||||||
)}
|
isInvalid={!!errors.oldToken}
|
||||||
/>
|
errorMessage={errors.oldToken?.message}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="newToken"
|
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 }) => (
|
render={({ field }) => (
|
||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
label={isDefaultToken ? "设置新密码" : "新密码"}
|
label="新密码"
|
||||||
placeholder={isDefaultToken ? "请设置一个安全的新密码" : "请输入新密码"}
|
placeholder="至少6位,包含字母和数字"
|
||||||
type="password"
|
type="password"
|
||||||
|
isRequired
|
||||||
|
isInvalid={!!errors.newToken}
|
||||||
|
errorMessage={errors.newToken?.message}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
|
import { Button } from '@heroui/button'
|
||||||
import { Input } from '@heroui/input'
|
import { Input } from '@heroui/input'
|
||||||
import { Switch } from '@heroui/switch'
|
import { Switch } from '@heroui/switch'
|
||||||
import { useRequest } from 'ahooks'
|
import { useRequest } from 'ahooks'
|
||||||
import { useEffect } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Controller, useForm } from 'react-hook-form'
|
import { Controller, useForm } from 'react-hook-form'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
|
import { MdDeleteSweep } from 'react-icons/md'
|
||||||
|
|
||||||
import SaveButtons from '@/components/button/save_buttons'
|
import SaveButtons from '@/components/button/save_buttons'
|
||||||
import PageLoading from '@/components/page_loading'
|
import PageLoading from '@/components/page_loading'
|
||||||
|
|
||||||
|
import useDialog from '@/hooks/use-dialog'
|
||||||
|
|
||||||
import WebUIManager from '@/controllers/webui_manager'
|
import WebUIManager from '@/controllers/webui_manager'
|
||||||
|
|
||||||
const ServerConfigCard = () => {
|
const ServerConfigCard = () => {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const [isCleaningCache, setIsCleaningCache] = useState(false)
|
||||||
const {
|
const {
|
||||||
data: configData,
|
data: configData,
|
||||||
loading: configLoading,
|
loading: configLoading,
|
||||||
@@ -69,6 +75,42 @@ const ServerConfigCard = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCleanCache = () => {
|
||||||
|
dialog.confirm({
|
||||||
|
title: '清理缓存',
|
||||||
|
content: (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>确定要清理缓存吗?此操作将清理以下内容:</p>
|
||||||
|
<ul className="list-disc list-inside text-sm text-default-600">
|
||||||
|
<li>临时文件夹中的所有文件</li>
|
||||||
|
<li>图片缓存 (Pic)</li>
|
||||||
|
<li>语音缓存 (Ptt)</li>
|
||||||
|
<li>视频缓存 (Video)</li>
|
||||||
|
<li>文件缓存 (File)</li>
|
||||||
|
<li>日志文件 (log)</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-warning text-sm">此操作不可撤销,请谨慎操作。</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
onConfirm: async () => {
|
||||||
|
setIsCleaningCache(true)
|
||||||
|
try {
|
||||||
|
const result = await WebUIManager.cleanCache()
|
||||||
|
if (result.result) {
|
||||||
|
toast.success(result.message || '缓存清理成功')
|
||||||
|
} else {
|
||||||
|
toast.error(result.message || '缓存清理失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const msg = (error as Error).message
|
||||||
|
toast.error(`清理缓存失败: ${msg}`)
|
||||||
|
} finally {
|
||||||
|
setIsCleaningCache(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reset()
|
reset()
|
||||||
}, [configData])
|
}, [configData])
|
||||||
@@ -131,6 +173,30 @@ const ServerConfigCard = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex-shrink-0 w-full">维护操作</div>
|
||||||
|
<div className="flex flex-col gap-2 p-4 rounded-lg bg-default-50">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium">清理缓存</div>
|
||||||
|
<div className="text-sm text-default-500">
|
||||||
|
清理临时文件、图片、语音、视频、文件缓存和日志文件
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
color="danger"
|
||||||
|
variant="flat"
|
||||||
|
startContent={<MdDeleteSweep size={20} />}
|
||||||
|
onPress={handleCleanCache}
|
||||||
|
isLoading={isCleaningCache}
|
||||||
|
isDisabled={!!configError}
|
||||||
|
>
|
||||||
|
清理缓存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex-shrink-0 w-full">安全配置</div>
|
<div className="flex-shrink-0 w-full">安全配置</div>
|
||||||
<Controller
|
<Controller
|
||||||
|
|||||||
@@ -1,52 +1,15 @@
|
|||||||
import { Spinner } from '@heroui/spinner'
|
import { Spinner } from '@heroui/spinner'
|
||||||
import { AnimatePresence, motion } from 'motion/react'
|
import { AnimatePresence, motion } from 'motion/react'
|
||||||
import { Suspense, useEffect } from 'react'
|
import { Suspense } from 'react'
|
||||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
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'
|
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() {
|
export default function IndexPage() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DefaultLayout>
|
<DefaultLayout>
|
||||||
<CheckDefaultPassword />
|
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div className="flex justify-center px-10">
|
<div className="flex justify-center px-10">
|
||||||
|
|||||||
@@ -92,42 +92,65 @@ export default function WebLoginPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardBody className="flex gap-5 py-5 px-5 md:px-10">
|
<CardBody className="flex gap-5 py-5 px-5 md:px-10">
|
||||||
<Input
|
<form
|
||||||
isClearable
|
onSubmit={(e) => {
|
||||||
type="password"
|
e.preventDefault()
|
||||||
classNames={{
|
onSubmit()
|
||||||
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"
|
<input
|
||||||
radius="lg"
|
type="text"
|
||||||
size="lg"
|
name="username"
|
||||||
startContent={
|
value="napcat-webui"
|
||||||
<IoKeyOutline className="text-black/50 mb-0.5 dark:text-white/90 text-slate-400 pointer-events-none flex-shrink-0" />
|
autoComplete="username"
|
||||||
}
|
className="absolute -left-[9999px] opacity-0 pointer-events-none"
|
||||||
value={tokenValue}
|
readOnly
|
||||||
onChange={(e) => setTokenValue(e.target.value)}
|
tabIndex={-1}
|
||||||
onClear={() => setTokenValue('')}
|
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
|
<Button
|
||||||
className="mx-10 mt-10 text-lg py-7"
|
className="mx-10 mt-10 text-lg py-7"
|
||||||
color="primary"
|
color="primary"
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "napcat",
|
"name": "napcat",
|
||||||
"version": "4.8.98",
|
"version": "4.8.119",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "napcat",
|
"name": "napcat",
|
||||||
"version": "4.8.98",
|
"version": "4.8.119",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^5.0.0",
|
"express": "^5.0.0",
|
||||||
"silk-wasm": "^3.6.1",
|
"silk-wasm": "^3.6.1",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "napcat",
|
"name": "napcat",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "4.8.108",
|
"version": "4.8.119",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:universal": "npm run build:webui && vite build --mode universal || exit 1",
|
"build:universal": "npm run build:webui && vite build --mode universal || exit 1",
|
||||||
"build:framework": "npm run build:webui && vite build --mode framework || exit 1",
|
"build:framework": "npm run build:webui && vite build --mode framework || exit 1",
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const napCatVersion = '4.8.108';
|
export const napCatVersion = '4.8.119';
|
||||||
|
|||||||
4
src/core/external/appid.json
vendored
4
src/core/external/appid.json
vendored
@@ -386,5 +386,9 @@
|
|||||||
"9.9.21-39038": {
|
"9.9.21-39038": {
|
||||||
"appid": 537313906,
|
"appid": 537313906,
|
||||||
"qua": "V1_WIN_NQ_9.9.21_39038_GW_B"
|
"qua": "V1_WIN_NQ_9.9.21_39038_GW_B"
|
||||||
|
},
|
||||||
|
"9.9.22-40362": {
|
||||||
|
"appid": 537314212,
|
||||||
|
"qua": "V1_WIN_NQ_9.9.22_40362_GW_B"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
6
src/core/external/offset.json
vendored
6
src/core/external/offset.json
vendored
@@ -507,8 +507,12 @@
|
|||||||
"send": "7B025C8",
|
"send": "7B025C8",
|
||||||
"recv": "7B05F58"
|
"recv": "7B05F58"
|
||||||
},
|
},
|
||||||
"9.9.21-39038-x64": {
|
"9.9.21-39038-x64": {
|
||||||
"send": "313FB58",
|
"send": "313FB58",
|
||||||
"recv": "31432FC"
|
"recv": "31432FC"
|
||||||
|
},
|
||||||
|
"9.9.22-40362-x64": {
|
||||||
|
"send": "31C0EB8",
|
||||||
|
"recv": "31C465C"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,9 +4,10 @@ import { NapCatCore } from '@/core';
|
|||||||
import { NapCatOneBot11Adapter, OB11Return } from '@/onebot';
|
import { NapCatOneBot11Adapter, OB11Return } from '@/onebot';
|
||||||
import { NetworkAdapterConfig } from '../config/config';
|
import { NetworkAdapterConfig } from '../config/config';
|
||||||
import { TSchema } from '@sinclair/typebox';
|
import { TSchema } from '@sinclair/typebox';
|
||||||
|
import { StreamPacket, StreamPacketBasic, StreamStatus } from './stream/StreamBasic';
|
||||||
|
|
||||||
export class OB11Response {
|
export class OB11Response {
|
||||||
private static createResponse<T>(data: T, status: string, retcode: number, message: string = '', echo: unknown = null): OB11Return<T> {
|
private static createResponse<T>(data: T, status: string, retcode: number, message: string = '', echo: unknown = null, useStream: boolean = false): OB11Return<T> {
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
retcode,
|
retcode,
|
||||||
@@ -14,28 +15,32 @@ export class OB11Response {
|
|||||||
message,
|
message,
|
||||||
wording: message,
|
wording: message,
|
||||||
echo,
|
echo,
|
||||||
|
stream: useStream ? 'stream-action' : 'normal-action'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static res<T>(data: T, status: string, retcode: number, message: string = ''): OB11Return<T> {
|
static res<T>(data: T, status: string, retcode: number, message: string = '', echo: unknown = null, useStream: boolean = false): OB11Return<T> {
|
||||||
return this.createResponse(data, status, retcode, message);
|
return this.createResponse(data, status, retcode, message, echo, useStream);
|
||||||
}
|
}
|
||||||
|
|
||||||
static ok<T>(data: T, echo: unknown = null): OB11Return<T> {
|
static ok<T>(data: T, echo: unknown = null, useStream: boolean = false): OB11Return<T> {
|
||||||
return this.createResponse(data, 'ok', 0, '', echo);
|
return this.createResponse(data, 'ok', 0, '', echo, useStream);
|
||||||
}
|
}
|
||||||
|
|
||||||
static error(err: string, retcode: number, echo: unknown = null): OB11Return<null> {
|
static error(err: string, retcode: number, echo: unknown = null, useStream: boolean = false): OB11Return<null | StreamPacketBasic> {
|
||||||
return this.createResponse(null, 'failed', retcode, err, echo);
|
return this.createResponse(useStream ? { type: StreamStatus.Error, data_type: 'error' } : null, 'failed', retcode, err, echo, useStream);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export abstract class OneBotRequestToolkit {
|
||||||
|
abstract send<T>(packet: StreamPacket<T>): Promise<void>;
|
||||||
|
}
|
||||||
export abstract class OneBotAction<PayloadType, ReturnDataType> {
|
export abstract class OneBotAction<PayloadType, ReturnDataType> {
|
||||||
actionName: typeof ActionName[keyof typeof ActionName] = ActionName.Unknown;
|
actionName: typeof ActionName[keyof typeof ActionName] = ActionName.Unknown;
|
||||||
core: NapCatCore;
|
core: NapCatCore;
|
||||||
private validate?: ValidateFunction<unknown> = undefined;
|
private validate?: ValidateFunction<unknown> = undefined;
|
||||||
payloadSchema?: TSchema = undefined;
|
payloadSchema?: TSchema = undefined;
|
||||||
obContext: NapCatOneBot11Adapter;
|
obContext: NapCatOneBot11Adapter;
|
||||||
|
useStream: boolean = false;
|
||||||
|
|
||||||
constructor(obContext: NapCatOneBot11Adapter, core: NapCatCore) {
|
constructor(obContext: NapCatOneBot11Adapter, core: NapCatCore) {
|
||||||
this.obContext = obContext;
|
this.obContext = obContext;
|
||||||
@@ -57,33 +62,33 @@ export abstract class OneBotAction<PayloadType, ReturnDataType> {
|
|||||||
return { valid: true };
|
return { valid: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async handle(payload: PayloadType, adaptername: string, config: NetworkAdapterConfig): Promise<OB11Return<ReturnDataType | null>> {
|
public async handle(payload: PayloadType, adaptername: string, config: NetworkAdapterConfig, req: OneBotRequestToolkit = { send: async () => { } }, echo?: string): Promise<OB11Return<ReturnDataType | StreamPacketBasic | null>> {
|
||||||
const result = await this.check(payload);
|
const result = await this.check(payload);
|
||||||
if (!result.valid) {
|
if (!result.valid) {
|
||||||
return OB11Response.error(result.message, 400);
|
return OB11Response.error(result.message, 400);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const resData = await this._handle(payload, adaptername, config);
|
const resData = await this._handle(payload, adaptername, config, req);
|
||||||
return OB11Response.ok(resData);
|
return OB11Response.ok(resData, echo, this.useStream);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
this.core.context.logger.logError('发生错误', e);
|
this.core.context.logger.logError('发生错误', e);
|
||||||
return OB11Response.error((e as Error).message.toString() || (e as Error)?.stack?.toString() || '未知错误,可能操作超时', 200);
|
return OB11Response.error((e as Error).message.toString() || (e as Error)?.stack?.toString() || '未知错误,可能操作超时', 200, echo, this.useStream);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async websocketHandle(payload: PayloadType, echo: unknown, adaptername: string, config: NetworkAdapterConfig): Promise<OB11Return<ReturnDataType | null>> {
|
public async websocketHandle(payload: PayloadType, echo: unknown, adaptername: string, config: NetworkAdapterConfig, req: OneBotRequestToolkit = { send: async () => { } }): Promise<OB11Return<ReturnDataType | StreamPacketBasic | null>> {
|
||||||
const result = await this.check(payload);
|
const result = await this.check(payload);
|
||||||
if (!result.valid) {
|
if (!result.valid) {
|
||||||
return OB11Response.error(result.message, 1400, echo);
|
return OB11Response.error(result.message, 1400, echo, this.useStream);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const resData = await this._handle(payload, adaptername, config);
|
const resData = await this._handle(payload, adaptername, config, req);
|
||||||
return OB11Response.ok(resData, echo);
|
return OB11Response.ok(resData, echo, this.useStream);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
this.core.context.logger.logError('发生错误', e);
|
this.core.context.logger.logError('发生错误', e);
|
||||||
return OB11Response.error(((e as Error).message.toString() || (e as Error).stack?.toString()) ?? 'Error', 1200, echo);
|
return OB11Response.error(((e as Error).message.toString() || (e as Error).stack?.toString()) ?? 'Error', 1200, echo, this.useStream);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract _handle(payload: PayloadType, adaptername: string, config: NetworkAdapterConfig): Promise<ReturnDataType>;
|
abstract _handle(payload: PayloadType, adaptername: string, config: NetworkAdapterConfig, req: OneBotRequestToolkit): Promise<ReturnDataType>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,12 +41,12 @@ export class GetFileBase extends OneBotAction<GetFilePayload, GetFileResponse> {
|
|||||||
let url = '';
|
let url = '';
|
||||||
if (mixElement?.picElement && rawMessage) {
|
if (mixElement?.picElement && rawMessage) {
|
||||||
const tempData =
|
const tempData =
|
||||||
await this.obContext.apis.MsgApi.rawToOb11Converters.picElement?.(mixElement?.picElement, rawMessage, mixElement, { parseMultMsg: false }) as OB11MessageImage | undefined;
|
await this.obContext.apis.MsgApi.rawToOb11Converters.picElement?.(mixElement?.picElement, rawMessage, mixElement, { parseMultMsg: false, disableGetUrl: false, quick_reply: true }) as OB11MessageImage | undefined;
|
||||||
url = tempData?.data.url ?? '';
|
url = tempData?.data.url ?? '';
|
||||||
}
|
}
|
||||||
if (mixElement?.videoElement && rawMessage) {
|
if (mixElement?.videoElement && rawMessage) {
|
||||||
const tempData =
|
const tempData =
|
||||||
await this.obContext.apis.MsgApi.rawToOb11Converters.videoElement?.(mixElement?.videoElement, rawMessage, mixElement, { parseMultMsg: false }) as OB11MessageVideo | undefined;
|
await this.obContext.apis.MsgApi.rawToOb11Converters.videoElement?.(mixElement?.videoElement, rawMessage, mixElement, { parseMultMsg: false, disableGetUrl: false, quick_reply: true }) as OB11MessageVideo | undefined;
|
||||||
url = tempData?.data.url ?? '';
|
url = tempData?.data.url ?? '';
|
||||||
}
|
}
|
||||||
const res: GetFileResponse = {
|
const res: GetFileResponse = {
|
||||||
|
|||||||
@@ -130,10 +130,18 @@ import { DoGroupAlbumComment } from './extends/DoGroupAlbumComment';
|
|||||||
import { GetGroupAlbumMediaList } from './extends/GetGroupAlbumMediaList';
|
import { GetGroupAlbumMediaList } from './extends/GetGroupAlbumMediaList';
|
||||||
import { SetGroupAlbumMediaLike } from './extends/SetGroupAlbumMediaLike';
|
import { SetGroupAlbumMediaLike } from './extends/SetGroupAlbumMediaLike';
|
||||||
import { DelGroupAlbumMedia } from './extends/DelGroupAlbumMedia';
|
import { DelGroupAlbumMedia } from './extends/DelGroupAlbumMedia';
|
||||||
|
import { CleanStreamTempFile } from './stream/CleanStreamTempFile';
|
||||||
|
import { DownloadFileStream } from './stream/DownloadFileStream';
|
||||||
|
import { TestDownloadStream } from './stream/TestStreamDownload';
|
||||||
|
import { UploadFileStream } from './stream/UploadFileStream';
|
||||||
|
|
||||||
export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCore) {
|
export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCore) {
|
||||||
|
|
||||||
const actionHandlers = [
|
const actionHandlers = [
|
||||||
|
new CleanStreamTempFile(obContext, core),
|
||||||
|
new DownloadFileStream(obContext, core),
|
||||||
|
new TestDownloadStream(obContext, core),
|
||||||
|
new UploadFileStream(obContext, core),
|
||||||
new DelGroupAlbumMedia(obContext, core),
|
new DelGroupAlbumMedia(obContext, core),
|
||||||
new SetGroupAlbumMediaLike(obContext, core),
|
new SetGroupAlbumMediaLike(obContext, core),
|
||||||
new DoGroupAlbumComment(obContext, core),
|
new DoGroupAlbumComment(obContext, core),
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ export interface InvalidCheckResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ActionName = {
|
export const ActionName = {
|
||||||
|
// 所有 Normal Stream Api 表示并未流传输 表示与流传输有关
|
||||||
|
CleanStreamTempFile: 'clean_stream_temp_file',
|
||||||
|
|
||||||
|
// 所有 Upload/Download Stream Api 应当 _stream 结尾
|
||||||
|
TestDownloadStream: 'test_download_stream',
|
||||||
|
UploadFileStream: 'upload_file_stream',
|
||||||
|
DownloadFileStream: 'download_file_stream',
|
||||||
|
|
||||||
DelGroupAlbumMedia: 'del_group_album_media',
|
DelGroupAlbumMedia: 'del_group_album_media',
|
||||||
SetGroupAlbumMediaLike: 'set_group_album_media_like',
|
SetGroupAlbumMediaLike: 'set_group_album_media_like',
|
||||||
DoGroupAlbumComment: 'do_group_album_comment',
|
DoGroupAlbumComment: 'do_group_album_comment',
|
||||||
|
|||||||
33
src/onebot/action/stream/CleanStreamTempFile.ts
Normal file
33
src/onebot/action/stream/CleanStreamTempFile.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { ActionName } from '@/onebot/action/router';
|
||||||
|
import { OneBotAction } from '@/onebot/action/OneBotAction';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { readdir, unlink } from 'node:fs/promises';
|
||||||
|
|
||||||
|
export class CleanStreamTempFile extends OneBotAction<void, void> {
|
||||||
|
override actionName = ActionName.CleanStreamTempFile;
|
||||||
|
|
||||||
|
async _handle(_payload: void): Promise<void> {
|
||||||
|
try {
|
||||||
|
// 获取临时文件夹路径
|
||||||
|
const tempPath = this.core.NapCatTempPath;
|
||||||
|
|
||||||
|
// 读取文件夹中的所有文件
|
||||||
|
const files = await readdir(tempPath);
|
||||||
|
|
||||||
|
// 删除每个文件
|
||||||
|
const deletePromises = files.map(async (file) => {
|
||||||
|
const filePath = join(tempPath, file);
|
||||||
|
try {
|
||||||
|
await unlink(filePath);
|
||||||
|
this.core.context.logger.log(`已删除文件: ${filePath}`);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
this.core.context.logger.log(`删除文件 ${filePath} 失败: ${(err as Error).message}`);
|
||||||
|
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(deletePromises);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
this.core.context.logger.log(`清理流临时文件失败: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
133
src/onebot/action/stream/DownloadFileStream.ts
Normal file
133
src/onebot/action/stream/DownloadFileStream.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { ActionName } from '@/onebot/action/router';
|
||||||
|
import { OneBotAction, OneBotRequestToolkit } from '@/onebot/action/OneBotAction';
|
||||||
|
import { Static, Type } from '@sinclair/typebox';
|
||||||
|
import { NetworkAdapterConfig } from '@/onebot/config/config';
|
||||||
|
import { StreamPacket, StreamStatus } from './StreamBasic';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { FileNapCatOneBotUUID } from '@/common/file-uuid';
|
||||||
|
const SchemaData = Type.Object({
|
||||||
|
file: Type.Optional(Type.String()),
|
||||||
|
file_id: Type.Optional(Type.String()),
|
||||||
|
chunk_size: Type.Optional(Type.Number({ default: 64 * 1024 })) // 默认64KB分块
|
||||||
|
});
|
||||||
|
|
||||||
|
type Payload = Static<typeof SchemaData>;
|
||||||
|
|
||||||
|
// 下载结果类型
|
||||||
|
interface DownloadResult {
|
||||||
|
// 文件信息
|
||||||
|
file_name?: string;
|
||||||
|
file_size?: number;
|
||||||
|
chunk_size?: number;
|
||||||
|
|
||||||
|
// 分片数据
|
||||||
|
index?: number;
|
||||||
|
data?: string;
|
||||||
|
size?: number;
|
||||||
|
progress?: number;
|
||||||
|
base64_size?: number;
|
||||||
|
|
||||||
|
// 完成信息
|
||||||
|
total_chunks?: number;
|
||||||
|
total_bytes?: number;
|
||||||
|
message?: string;
|
||||||
|
data_type?: 'file_info' | 'file_chunk' | 'file_complete';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DownloadFileStream extends OneBotAction<Payload, StreamPacket<DownloadResult>> {
|
||||||
|
override actionName = ActionName.DownloadFileStream;
|
||||||
|
override payloadSchema = SchemaData;
|
||||||
|
override useStream = true;
|
||||||
|
|
||||||
|
async _handle(payload: Payload, _adaptername: string, _config: NetworkAdapterConfig, req: OneBotRequestToolkit): Promise<StreamPacket<DownloadResult>> {
|
||||||
|
try {
|
||||||
|
payload.file ||= payload.file_id || '';
|
||||||
|
const chunkSize = payload.chunk_size || 64 * 1024;
|
||||||
|
let downloadPath = '';
|
||||||
|
let fileName = '';
|
||||||
|
let fileSize = 0;
|
||||||
|
|
||||||
|
//接收消息标记模式
|
||||||
|
const contextMsgFile = FileNapCatOneBotUUID.decode(payload.file);
|
||||||
|
if (contextMsgFile && contextMsgFile.msgId && contextMsgFile.elementId) {
|
||||||
|
const { peer, msgId, elementId } = contextMsgFile;
|
||||||
|
downloadPath = await this.core.apis.FileApi.downloadMedia(msgId, peer.chatType, peer.peerUid, elementId, '', '');
|
||||||
|
const rawMessage = (await this.core.apis.MsgApi.getMsgsByMsgId(peer, [msgId]))?.msgList
|
||||||
|
.find(msg => msg.msgId === msgId);
|
||||||
|
const mixElement = rawMessage?.elements.find(e => e.elementId === elementId);
|
||||||
|
const mixElementInner = mixElement?.videoElement ?? mixElement?.fileElement ?? mixElement?.pttElement ?? mixElement?.picElement;
|
||||||
|
if (!mixElementInner) throw new Error('element not found');
|
||||||
|
fileSize = parseInt(mixElementInner.fileSize?.toString() ?? '0');
|
||||||
|
fileName = mixElementInner.fileName ?? '';
|
||||||
|
}
|
||||||
|
//群文件模式
|
||||||
|
else if (FileNapCatOneBotUUID.decodeModelId(payload.file)) {
|
||||||
|
const contextModelIdFile = FileNapCatOneBotUUID.decodeModelId(payload.file);
|
||||||
|
if (contextModelIdFile && contextModelIdFile.modelId) {
|
||||||
|
const { peer, modelId } = contextModelIdFile;
|
||||||
|
downloadPath = await this.core.apis.FileApi.downloadFileForModelId(peer, modelId, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//搜索名字模式
|
||||||
|
else {
|
||||||
|
const searchResult = (await this.core.apis.FileApi.searchForFile([payload.file]));
|
||||||
|
if (searchResult) {
|
||||||
|
downloadPath = await this.core.apis.FileApi.downloadFileById(searchResult.id, parseInt(searchResult.fileSize));
|
||||||
|
fileSize = parseInt(searchResult.fileSize);
|
||||||
|
fileName = searchResult.fileName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!downloadPath) {
|
||||||
|
throw new Error('file not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取文件大小
|
||||||
|
const stats = await fs.promises.stat(downloadPath);
|
||||||
|
const totalSize = fileSize || stats.size;
|
||||||
|
|
||||||
|
// 发送文件信息
|
||||||
|
await req.send({
|
||||||
|
type: StreamStatus.Stream,
|
||||||
|
data_type: 'file_info',
|
||||||
|
file_name: fileName,
|
||||||
|
file_size: totalSize,
|
||||||
|
chunk_size: chunkSize
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建读取流并分块发送
|
||||||
|
const readStream = fs.createReadStream(downloadPath, { highWaterMark: chunkSize });
|
||||||
|
let chunkIndex = 0;
|
||||||
|
let bytesRead = 0;
|
||||||
|
|
||||||
|
for await (const chunk of readStream) {
|
||||||
|
const base64Chunk = chunk.toString('base64');
|
||||||
|
bytesRead += chunk.length;
|
||||||
|
|
||||||
|
await req.send({
|
||||||
|
type: StreamStatus.Stream,
|
||||||
|
data_type: 'file_chunk',
|
||||||
|
index: chunkIndex,
|
||||||
|
data: base64Chunk,
|
||||||
|
size: chunk.length,
|
||||||
|
progress: Math.round((bytesRead / totalSize) * 100),
|
||||||
|
base64_size: base64Chunk.length
|
||||||
|
});
|
||||||
|
|
||||||
|
chunkIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回完成状态
|
||||||
|
return {
|
||||||
|
type: StreamStatus.Response,
|
||||||
|
data_type: 'file_complete',
|
||||||
|
total_chunks: chunkIndex,
|
||||||
|
total_bytes: bytesRead,
|
||||||
|
message: 'Download completed'
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Download failed: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/onebot/action/stream/Readme.txt
Normal file
3
src/onebot/action/stream/Readme.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Stream-Api
|
||||||
|
|
||||||
|
## 流式接口
|
||||||
16
src/onebot/action/stream/StreamBasic.ts
Normal file
16
src/onebot/action/stream/StreamBasic.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { OneBotAction, OneBotRequestToolkit } from "../OneBotAction";
|
||||||
|
import { NetworkAdapterConfig } from "@/onebot/config/config";
|
||||||
|
export type StreamPacketBasic = {
|
||||||
|
type: StreamStatus;
|
||||||
|
data_type?: string;
|
||||||
|
};
|
||||||
|
export type StreamPacket<T> = T & StreamPacketBasic;
|
||||||
|
export enum StreamStatus {
|
||||||
|
Stream = 'stream', // 分片流数据包
|
||||||
|
Response = 'response', // 流最终响应
|
||||||
|
Reset = 'reset', // 重置流
|
||||||
|
Error = 'error' // 流错误
|
||||||
|
}
|
||||||
|
export abstract class BasicStream<T, R> extends OneBotAction<T, StreamPacket<R>> {
|
||||||
|
abstract override _handle(_payload: T, _adaptername: string, _config: NetworkAdapterConfig, req: OneBotRequestToolkit): Promise<StreamPacket<R>>;
|
||||||
|
}
|
||||||
32
src/onebot/action/stream/TestStreamDownload.ts
Normal file
32
src/onebot/action/stream/TestStreamDownload.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { ActionName } from '@/onebot/action/router';
|
||||||
|
import { OneBotAction, OneBotRequestToolkit } from '@/onebot/action/OneBotAction';
|
||||||
|
import { Static, Type } from '@sinclair/typebox';
|
||||||
|
import { NetworkAdapterConfig } from '@/onebot/config/config';
|
||||||
|
import { StreamPacket, StreamStatus } from './StreamBasic';
|
||||||
|
|
||||||
|
const SchemaData = Type.Object({
|
||||||
|
error: Type.Optional(Type.Boolean({ default: false }))
|
||||||
|
});
|
||||||
|
|
||||||
|
type Payload = Static<typeof SchemaData>;
|
||||||
|
|
||||||
|
export class TestDownloadStream extends OneBotAction<Payload, StreamPacket<{ data: string }>> {
|
||||||
|
override actionName = ActionName.TestDownloadStream;
|
||||||
|
override payloadSchema = SchemaData;
|
||||||
|
override useStream = true;
|
||||||
|
|
||||||
|
async _handle(_payload: Payload, _adaptername: string, _config: NetworkAdapterConfig, req: OneBotRequestToolkit) {
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await req.send({ type: StreamStatus.Stream, data: `Index-> ${i + 1}`, data_type: 'data_chunk' });
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
if( _payload.error ){
|
||||||
|
throw new Error('This is a test error');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: StreamStatus.Response,
|
||||||
|
data_type: 'data_complete',
|
||||||
|
data: 'Stream transmission complete'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
346
src/onebot/action/stream/UploadFileStream.ts
Normal file
346
src/onebot/action/stream/UploadFileStream.ts
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
import { ActionName } from '@/onebot/action/router';
|
||||||
|
import { OneBotAction } from '@/onebot/action/OneBotAction';
|
||||||
|
import { Static, Type } from '@sinclair/typebox';
|
||||||
|
import { NetworkAdapterConfig } from '@/onebot/config/config';
|
||||||
|
import { StreamPacket, StreamStatus } from './StreamBasic';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { join as joinPath } from 'node:path';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import { unlink } from 'node:fs';
|
||||||
|
|
||||||
|
// 简化配置
|
||||||
|
const CONFIG = {
|
||||||
|
TIMEOUT: 10 * 60 * 1000, // 10分钟超时
|
||||||
|
MEMORY_THRESHOLD: 10 * 1024 * 1024, // 10MB,超过使用磁盘
|
||||||
|
MEMORY_LIMIT: 100 * 1024 * 1024 // 100MB内存总限制
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const SchemaData = Type.Object({
|
||||||
|
stream_id: Type.String(),
|
||||||
|
chunk_data: Type.Optional(Type.String()),
|
||||||
|
chunk_index: Type.Optional(Type.Number()),
|
||||||
|
total_chunks: Type.Optional(Type.Number()),
|
||||||
|
file_size: Type.Optional(Type.Number()),
|
||||||
|
expected_sha256: Type.Optional(Type.String()),
|
||||||
|
is_complete: Type.Optional(Type.Boolean()),
|
||||||
|
filename: Type.Optional(Type.String()),
|
||||||
|
reset: Type.Optional(Type.Boolean()),
|
||||||
|
verify_only: Type.Optional(Type.Boolean()),
|
||||||
|
file_retention: Type.Number({ default: 5 * 60 * 1000 }) // 默认5分钟 回收 不设置或0为不回收
|
||||||
|
});
|
||||||
|
|
||||||
|
type Payload = Static<typeof SchemaData>;
|
||||||
|
|
||||||
|
// 简化流状态接口
|
||||||
|
interface StreamState {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
totalChunks: number;
|
||||||
|
receivedChunks: number;
|
||||||
|
missingChunks: Set<number>;
|
||||||
|
|
||||||
|
// 可选属性
|
||||||
|
fileSize?: number;
|
||||||
|
expectedSha256?: string;
|
||||||
|
|
||||||
|
// 存储策略
|
||||||
|
useMemory: boolean;
|
||||||
|
memoryChunks?: Map<number, Buffer>;
|
||||||
|
tempDir?: string;
|
||||||
|
finalPath?: string;
|
||||||
|
fileRetention?: number;
|
||||||
|
|
||||||
|
// 管理
|
||||||
|
createdAt: number;
|
||||||
|
timeoutId: NodeJS.Timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StreamResult {
|
||||||
|
stream_id: string;
|
||||||
|
status: 'file_created' | 'chunk_received' | 'file_complete';
|
||||||
|
received_chunks: number;
|
||||||
|
total_chunks: number;
|
||||||
|
file_path?: string;
|
||||||
|
file_size?: number;
|
||||||
|
sha256?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UploadFileStream extends OneBotAction<Payload, StreamPacket<StreamResult>> {
|
||||||
|
override actionName = ActionName.UploadFileStream;
|
||||||
|
override payloadSchema = SchemaData;
|
||||||
|
override useStream = true;
|
||||||
|
|
||||||
|
private static streams = new Map<string, StreamState>();
|
||||||
|
private static memoryUsage = 0;
|
||||||
|
|
||||||
|
async _handle(payload: Payload, _adaptername: string, _config: NetworkAdapterConfig): Promise<StreamPacket<StreamResult>> {
|
||||||
|
const { stream_id, reset, verify_only } = payload;
|
||||||
|
|
||||||
|
if (reset) {
|
||||||
|
this.cleanupStream(stream_id);
|
||||||
|
throw new Error('Stream reset completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verify_only) {
|
||||||
|
const stream = UploadFileStream.streams.get(stream_id);
|
||||||
|
if (!stream) throw new Error('Stream not found');
|
||||||
|
return this.getStreamStatus(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = this.getOrCreateStream(payload);
|
||||||
|
|
||||||
|
if (payload.chunk_data && payload.chunk_index !== undefined) {
|
||||||
|
return await this.processChunk(stream, payload.chunk_data, payload.chunk_index);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.is_complete || stream.receivedChunks === stream.totalChunks) {
|
||||||
|
return await this.completeStream(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getStreamStatus(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private getOrCreateStream(payload: Payload): StreamState {
|
||||||
|
let stream = UploadFileStream.streams.get(payload.stream_id);
|
||||||
|
|
||||||
|
if (!stream) {
|
||||||
|
if (!payload.total_chunks) {
|
||||||
|
throw new Error('total_chunks required for new stream');
|
||||||
|
}
|
||||||
|
stream = this.createStream(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createStream(payload: Payload): StreamState {
|
||||||
|
const { stream_id, total_chunks, file_size, filename, expected_sha256 } = payload;
|
||||||
|
|
||||||
|
const useMemory = this.shouldUseMemory(file_size);
|
||||||
|
if (useMemory && file_size && (UploadFileStream.memoryUsage + file_size) > CONFIG.MEMORY_LIMIT) {
|
||||||
|
throw new Error('Memory limit exceeded');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream: StreamState = {
|
||||||
|
id: stream_id,
|
||||||
|
filename: filename || `upload_${randomUUID()}`,
|
||||||
|
totalChunks: total_chunks!,
|
||||||
|
receivedChunks: 0,
|
||||||
|
missingChunks: new Set(Array.from({ length: total_chunks! }, (_, i) => i)),
|
||||||
|
fileSize: file_size,
|
||||||
|
expectedSha256: expected_sha256,
|
||||||
|
useMemory,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
timeoutId: this.setupTimeout(stream_id),
|
||||||
|
fileRetention: payload.file_retention
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (useMemory) {
|
||||||
|
stream.memoryChunks = new Map();
|
||||||
|
if (file_size) UploadFileStream.memoryUsage += file_size;
|
||||||
|
} else {
|
||||||
|
this.setupDiskStorage(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
UploadFileStream.streams.set(stream_id, stream);
|
||||||
|
return stream;
|
||||||
|
} catch (error) {
|
||||||
|
// 如果设置存储失败,清理已创建的资源
|
||||||
|
clearTimeout(stream.timeoutId);
|
||||||
|
if (stream.tempDir && fs.existsSync(stream.tempDir)) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(stream.tempDir, { recursive: true, force: true });
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.error(`Failed to cleanup temp dir during creation error:`, cleanupError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldUseMemory(fileSize?: number): boolean {
|
||||||
|
return fileSize !== undefined && fileSize <= CONFIG.MEMORY_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupDiskStorage(stream: StreamState): void {
|
||||||
|
const tempDir = joinPath(this.core.NapCatTempPath, `upload_${stream.id}`);
|
||||||
|
const finalPath = joinPath(this.core.NapCatTempPath, stream.filename);
|
||||||
|
|
||||||
|
fs.mkdirSync(tempDir, { recursive: true });
|
||||||
|
|
||||||
|
stream.tempDir = tempDir;
|
||||||
|
stream.finalPath = finalPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupTimeout(streamId: string): NodeJS.Timeout {
|
||||||
|
return setTimeout(() => {
|
||||||
|
console.log(`Stream ${streamId} timeout`);
|
||||||
|
this.cleanupStream(streamId);
|
||||||
|
}, CONFIG.TIMEOUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processChunk(stream: StreamState, chunkData: string, chunkIndex: number): Promise<StreamPacket<StreamResult>> {
|
||||||
|
// 验证索引
|
||||||
|
if (chunkIndex < 0 || chunkIndex >= stream.totalChunks) {
|
||||||
|
throw new Error(`Invalid chunk index: ${chunkIndex}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查重复
|
||||||
|
if (!stream.missingChunks.has(chunkIndex)) {
|
||||||
|
return this.getStreamStatus(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(chunkData, 'base64');
|
||||||
|
|
||||||
|
// 存储分片
|
||||||
|
if (stream.useMemory) {
|
||||||
|
stream.memoryChunks!.set(chunkIndex, buffer);
|
||||||
|
} else {
|
||||||
|
const chunkPath = joinPath(stream.tempDir!, `${chunkIndex}.chunk`);
|
||||||
|
await fs.promises.writeFile(chunkPath, buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新状态
|
||||||
|
stream.missingChunks.delete(chunkIndex);
|
||||||
|
stream.receivedChunks++;
|
||||||
|
this.refreshTimeout(stream);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: StreamStatus.Stream,
|
||||||
|
stream_id: stream.id,
|
||||||
|
status: 'chunk_received',
|
||||||
|
received_chunks: stream.receivedChunks,
|
||||||
|
total_chunks: stream.totalChunks
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private refreshTimeout(stream: StreamState): void {
|
||||||
|
clearTimeout(stream.timeoutId);
|
||||||
|
stream.timeoutId = this.setupTimeout(stream.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getStreamStatus(stream: StreamState): StreamPacket<StreamResult> {
|
||||||
|
return {
|
||||||
|
type: StreamStatus.Stream,
|
||||||
|
stream_id: stream.id,
|
||||||
|
status: 'file_created',
|
||||||
|
received_chunks: stream.receivedChunks,
|
||||||
|
total_chunks: stream.totalChunks
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async completeStream(stream: StreamState): Promise<StreamPacket<StreamResult>> {
|
||||||
|
// 合并分片
|
||||||
|
const finalBuffer = stream.useMemory ?
|
||||||
|
await this.mergeMemoryChunks(stream) :
|
||||||
|
await this.mergeDiskChunks(stream);
|
||||||
|
|
||||||
|
// 验证SHA256
|
||||||
|
const sha256 = this.validateSha256(stream, finalBuffer);
|
||||||
|
|
||||||
|
// 保存文件
|
||||||
|
const finalPath = stream.finalPath || joinPath(this.core.NapCatTempPath, stream.filename);
|
||||||
|
await fs.promises.writeFile(finalPath, finalBuffer);
|
||||||
|
|
||||||
|
// 清理资源但保留文件
|
||||||
|
this.cleanupStream(stream.id, false);
|
||||||
|
if (stream.fileRetention && stream.fileRetention > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
unlink(finalPath, err => {
|
||||||
|
if (err) this.core.context.logger.logError(`Failed to delete retained file ${finalPath}:`, err);
|
||||||
|
});
|
||||||
|
}, stream.fileRetention);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: StreamStatus.Response,
|
||||||
|
stream_id: stream.id,
|
||||||
|
status: 'file_complete',
|
||||||
|
received_chunks: stream.receivedChunks,
|
||||||
|
total_chunks: stream.totalChunks,
|
||||||
|
file_path: finalPath,
|
||||||
|
file_size: finalBuffer.length,
|
||||||
|
sha256
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mergeMemoryChunks(stream: StreamState): Promise<Buffer> {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for (let i = 0; i < stream.totalChunks; i++) {
|
||||||
|
const chunk = stream.memoryChunks!.get(i);
|
||||||
|
if (!chunk) throw new Error(`Missing memory chunk ${i}`);
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mergeDiskChunks(stream: StreamState): Promise<Buffer> {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for (let i = 0; i < stream.totalChunks; i++) {
|
||||||
|
const chunkPath = joinPath(stream.tempDir!, `${i}.chunk`);
|
||||||
|
if (!fs.existsSync(chunkPath)) throw new Error(`Missing chunk file ${i}`);
|
||||||
|
chunks.push(await fs.promises.readFile(chunkPath));
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateSha256(stream: StreamState, buffer: Buffer): string | undefined {
|
||||||
|
if (!stream.expectedSha256) return undefined;
|
||||||
|
|
||||||
|
const actualSha256 = createHash('sha256').update(buffer).digest('hex');
|
||||||
|
if (actualSha256 !== stream.expectedSha256) {
|
||||||
|
throw new Error(`SHA256 mismatch. Expected: ${stream.expectedSha256}, Got: ${actualSha256}`);
|
||||||
|
}
|
||||||
|
return actualSha256;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupStream(streamId: string, deleteFinalFile = true): void {
|
||||||
|
const stream = UploadFileStream.streams.get(streamId);
|
||||||
|
if (!stream) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 清理超时
|
||||||
|
clearTimeout(stream.timeoutId);
|
||||||
|
|
||||||
|
// 清理内存
|
||||||
|
if (stream.useMemory) {
|
||||||
|
if (stream.fileSize) {
|
||||||
|
UploadFileStream.memoryUsage = Math.max(0, UploadFileStream.memoryUsage - stream.fileSize);
|
||||||
|
}
|
||||||
|
stream.memoryChunks?.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理临时文件夹及其所有内容
|
||||||
|
if (stream.tempDir) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(stream.tempDir)) {
|
||||||
|
fs.rmSync(stream.tempDir, { recursive: true, force: true });
|
||||||
|
console.log(`Cleaned up temp directory: ${stream.tempDir}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to cleanup temp directory ${stream.tempDir}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除最终文件(如果需要)
|
||||||
|
if (deleteFinalFile && stream.finalPath) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(stream.finalPath)) {
|
||||||
|
fs.unlinkSync(stream.finalPath);
|
||||||
|
console.log(`Deleted final file: ${stream.finalPath}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to delete final file ${stream.finalPath}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Cleanup error for stream ${streamId}:`, error);
|
||||||
|
} finally {
|
||||||
|
UploadFileStream.streams.delete(streamId);
|
||||||
|
console.log(`Stream ${streamId} cleaned up`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
239
src/onebot/action/stream/test_upload_stream.py
Normal file
239
src/onebot/action/stream/test_upload_stream.py
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
NapCat OneBot WebSocket 文件流上传测试脚本
|
||||||
|
用于测试 UploadFileStream 接口的一次性分片上传功能
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from typing import List, Optional
|
||||||
|
import websockets
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
class OneBotUploadTester:
|
||||||
|
def __init__(self, ws_url: str = "ws://localhost:3001", access_token: Optional[str] = None):
|
||||||
|
self.ws_url = ws_url
|
||||||
|
self.access_token = access_token
|
||||||
|
self.websocket = None
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
"""连接到 OneBot WebSocket"""
|
||||||
|
headers = {}
|
||||||
|
if self.access_token:
|
||||||
|
headers["Authorization"] = f"Bearer {self.access_token}"
|
||||||
|
|
||||||
|
print(f"连接到 {self.ws_url}")
|
||||||
|
self.websocket = await websockets.connect(self.ws_url, additional_headers=headers)
|
||||||
|
print("WebSocket 连接成功")
|
||||||
|
|
||||||
|
async def disconnect(self):
|
||||||
|
"""断开 WebSocket 连接"""
|
||||||
|
if self.websocket:
|
||||||
|
await self.websocket.close()
|
||||||
|
print("WebSocket 连接已断开")
|
||||||
|
|
||||||
|
def calculate_file_chunks(self, file_path: str, chunk_size: int = 64) -> tuple[List[bytes], str, int]:
|
||||||
|
"""
|
||||||
|
计算文件分片和 SHA256
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: 文件路径
|
||||||
|
chunk_size: 分片大小(默认64KB)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(chunks, sha256_hash, total_size)
|
||||||
|
"""
|
||||||
|
chunks = []
|
||||||
|
hasher = hashlib.sha256()
|
||||||
|
total_size = 0
|
||||||
|
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
while True:
|
||||||
|
chunk = f.read(chunk_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
chunks.append(chunk)
|
||||||
|
hasher.update(chunk)
|
||||||
|
total_size += len(chunk)
|
||||||
|
|
||||||
|
sha256_hash = hasher.hexdigest()
|
||||||
|
print(f"文件分析完成:")
|
||||||
|
print(f" - 文件大小: {total_size} 字节")
|
||||||
|
print(f" - 分片数量: {len(chunks)}")
|
||||||
|
print(f" - SHA256: {sha256_hash}")
|
||||||
|
|
||||||
|
return chunks, sha256_hash, total_size
|
||||||
|
|
||||||
|
async def send_action(self, action: str, params: dict, echo: str = None) -> dict:
|
||||||
|
"""发送 OneBot 动作请求"""
|
||||||
|
if not echo:
|
||||||
|
echo = str(uuid.uuid4())
|
||||||
|
|
||||||
|
message = {
|
||||||
|
"action": action,
|
||||||
|
"params": params,
|
||||||
|
"echo": echo
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"发送请求: {action}")
|
||||||
|
await self.websocket.send(json.dumps(message))
|
||||||
|
|
||||||
|
# 等待响应
|
||||||
|
while True:
|
||||||
|
response = await self.websocket.recv()
|
||||||
|
data = json.loads(response)
|
||||||
|
|
||||||
|
# 检查是否是我们的响应
|
||||||
|
if data.get("echo") == echo:
|
||||||
|
return data
|
||||||
|
else:
|
||||||
|
# 可能是其他消息,继续等待
|
||||||
|
print(f"收到其他消息: {data}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
async def upload_file_stream_batch(self, file_path: str, chunk_size: int = 64 ) -> str:
|
||||||
|
"""
|
||||||
|
一次性批量上传文件流
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: 要上传的文件路径
|
||||||
|
chunk_size: 分片大小
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
上传完成后的文件路径
|
||||||
|
"""
|
||||||
|
file_path = Path(file_path)
|
||||||
|
if not file_path.exists():
|
||||||
|
raise FileNotFoundError(f"文件不存在: {file_path}")
|
||||||
|
|
||||||
|
# 分析文件
|
||||||
|
chunks, sha256_hash, total_size = self.calculate_file_chunks(str(file_path), chunk_size)
|
||||||
|
stream_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
print(f"\n开始上传文件: {file_path.name}")
|
||||||
|
print(f"流ID: {stream_id}")
|
||||||
|
|
||||||
|
# 一次性发送所有分片
|
||||||
|
total_chunks = len(chunks)
|
||||||
|
|
||||||
|
for chunk_index, chunk_data in enumerate(chunks):
|
||||||
|
# 将分片数据编码为 base64
|
||||||
|
chunk_base64 = base64.b64encode(chunk_data).decode('utf-8')
|
||||||
|
|
||||||
|
# 构建参数
|
||||||
|
params = {
|
||||||
|
"stream_id": stream_id,
|
||||||
|
"chunk_data": chunk_base64,
|
||||||
|
"chunk_index": chunk_index,
|
||||||
|
"total_chunks": total_chunks,
|
||||||
|
"file_size": total_size,
|
||||||
|
"expected_sha256": sha256_hash,
|
||||||
|
"filename": file_path.name,
|
||||||
|
"file_retention": 30 * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
# 发送分片
|
||||||
|
response = await self.send_action("upload_file_stream", params)
|
||||||
|
|
||||||
|
if response.get("status") != "ok":
|
||||||
|
raise Exception(f"上传分片 {chunk_index} 失败: {response}")
|
||||||
|
|
||||||
|
# 解析流响应
|
||||||
|
stream_data = response.get("data", {})
|
||||||
|
print(f"分片 {chunk_index + 1}/{total_chunks} 上传成功 "
|
||||||
|
f"(接收: {stream_data.get('received_chunks', 0)}/{stream_data.get('total_chunks', 0)})")
|
||||||
|
|
||||||
|
# 发送完成信号
|
||||||
|
print(f"\n所有分片发送完成,请求文件合并...")
|
||||||
|
complete_params = {
|
||||||
|
"stream_id": stream_id,
|
||||||
|
"is_complete": True
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await self.send_action("upload_file_stream", complete_params)
|
||||||
|
|
||||||
|
if response.get("status") != "ok":
|
||||||
|
raise Exception(f"文件合并失败: {response}")
|
||||||
|
|
||||||
|
result = response.get("data", {})
|
||||||
|
|
||||||
|
if result.get("status") == "file_complete":
|
||||||
|
print(f"✅ 文件上传成功!")
|
||||||
|
print(f" - 文件路径: {result.get('file_path')}")
|
||||||
|
print(f" - 文件大小: {result.get('file_size')} 字节")
|
||||||
|
print(f" - SHA256: {result.get('sha256')}")
|
||||||
|
return result.get('file_path')
|
||||||
|
else:
|
||||||
|
raise Exception(f"文件状态异常: {result}")
|
||||||
|
|
||||||
|
async def test_upload(self, file_path: str, chunk_size: int = 64 ):
|
||||||
|
"""测试文件上传"""
|
||||||
|
try:
|
||||||
|
await self.connect()
|
||||||
|
|
||||||
|
# 执行上传
|
||||||
|
uploaded_path = await self.upload_file_stream_batch(file_path, chunk_size)
|
||||||
|
|
||||||
|
print(f"\n🎉 测试完成! 上传后的文件路径: {uploaded_path}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 测试失败: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await self.disconnect()
|
||||||
|
|
||||||
|
def create_test_file(file_path: str, size_mb: float = 1):
|
||||||
|
"""创建测试文件"""
|
||||||
|
size_bytes = int(size_mb * 1024 * 1024)
|
||||||
|
|
||||||
|
with open(file_path, 'wb') as f:
|
||||||
|
# 写入一些有意义的测试数据
|
||||||
|
test_data = b"NapCat Upload Test Data - " * 100
|
||||||
|
written = 0
|
||||||
|
while written < size_bytes:
|
||||||
|
write_size = min(len(test_data), size_bytes - written)
|
||||||
|
f.write(test_data[:write_size])
|
||||||
|
written += write_size
|
||||||
|
|
||||||
|
print(f"创建测试文件: {file_path} ({size_mb}MB)")
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
parser = argparse.ArgumentParser(description="NapCat OneBot 文件流上传测试")
|
||||||
|
parser.add_argument("--url", default="ws://localhost:3001", help="WebSocket URL")
|
||||||
|
parser.add_argument("--token", help="访问令牌")
|
||||||
|
parser.add_argument("--file", help="要上传的文件路径")
|
||||||
|
parser.add_argument("--chunk-size", type=int, default=64*1024, help="分片大小(字节)")
|
||||||
|
parser.add_argument("--create-test", type=float, help="创建测试文件(MB)")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# 创建测试文件
|
||||||
|
if args.create_test:
|
||||||
|
test_file = "test_upload_file.bin"
|
||||||
|
create_test_file(test_file, args.create_test)
|
||||||
|
if not args.file:
|
||||||
|
args.file = test_file
|
||||||
|
|
||||||
|
if not args.file:
|
||||||
|
print("请指定要上传的文件路径,或使用 --create-test 创建测试文件")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 创建测试器并运行
|
||||||
|
tester = OneBotUploadTester(args.url, args.token)
|
||||||
|
await tester.test_upload(args.file, args.chunk_size)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 安装依赖提示
|
||||||
|
try:
|
||||||
|
import websockets
|
||||||
|
except ImportError:
|
||||||
|
print("请先安装依赖: pip install websockets")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
@@ -578,7 +578,7 @@ export class OneBotMsgApi {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!context.peer || context.peer.chatType == ChatType.KCHATTYPEC2C) return undefined;
|
if (!context.peer || !atQQ || context.peer.chatType == ChatType.KCHATTYPEC2C) return undefined; // 过滤掉空atQQ
|
||||||
if (atQQ === 'all') return at(atQQ, atQQ, NTMsgAtType.ATTYPEALL, '全体成员');
|
if (atQQ === 'all') return at(atQQ, atQQ, NTMsgAtType.ATTYPEALL, '全体成员');
|
||||||
const atMember = await this.core.apis.GroupApi.getGroupMember(context.peer.peerUid, atQQ);
|
const atMember = await this.core.apis.GroupApi.getGroupMember(context.peer.peerUid, atQQ);
|
||||||
if (atMember) {
|
if (atMember) {
|
||||||
@@ -1124,10 +1124,13 @@ export class OneBotMsgApi {
|
|||||||
if (ignoreTypes.includes(sendMsg.type)) {
|
if (ignoreTypes.includes(sendMsg.type)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const converter = this.ob11ToRawConverters[sendMsg.type] as (
|
const converter = this.ob11ToRawConverters[sendMsg.type] as ((
|
||||||
sendMsg: Extract<OB11MessageData, { type: OB11MessageData['type'] }>,
|
sendMsg: Extract<OB11MessageData, { type: OB11MessageData['type'] }>,
|
||||||
context: SendMessageContext,
|
context: SendMessageContext,
|
||||||
) => Promise<SendMessageElement | undefined>;
|
) => Promise<SendMessageElement | undefined>) | undefined;
|
||||||
|
if (converter == undefined) {
|
||||||
|
throw new Error('未知的消息类型:' + sendMsg.type);
|
||||||
|
}
|
||||||
const callResult = converter(
|
const callResult = converter(
|
||||||
sendMsg,
|
sendMsg,
|
||||||
{ peer, deleteAfterSentFiles },
|
{ peer, deleteAfterSentFiles },
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
NTMsgAtType,
|
NTMsgAtType,
|
||||||
} from '@/core';
|
} from '@/core';
|
||||||
import { OB11ConfigLoader } from '@/onebot/config';
|
import { OB11ConfigLoader } from '@/onebot/config';
|
||||||
|
import { pendingTokenToSend } from '@/webui/index';
|
||||||
import {
|
import {
|
||||||
OB11HttpClientAdapter,
|
OB11HttpClientAdapter,
|
||||||
OB11WebSocketClientAdapter,
|
OB11WebSocketClientAdapter,
|
||||||
@@ -64,8 +65,8 @@ export class NapCatOneBot11Adapter {
|
|||||||
networkManager: OB11NetworkManager;
|
networkManager: OB11NetworkManager;
|
||||||
actions: ActionMap;
|
actions: ActionMap;
|
||||||
private readonly bootTime = Date.now() / 1000;
|
private readonly bootTime = Date.now() / 1000;
|
||||||
recallEventCache = new Map<string, any>();
|
recallEventCache = new Map<string, NodeJS.Timeout>();
|
||||||
constructor(core: NapCatCore, context: InstanceContext, pathWrapper: NapCatPathWrapper) {
|
constructor (core: NapCatCore, context: InstanceContext, pathWrapper: NapCatPathWrapper) {
|
||||||
this.core = core;
|
this.core = core;
|
||||||
this.context = context;
|
this.context = context;
|
||||||
this.configLoader = new OB11ConfigLoader(core, pathWrapper.configPath, OneBotConfigSchema);
|
this.configLoader = new OB11ConfigLoader(core, pathWrapper.configPath, OneBotConfigSchema);
|
||||||
@@ -79,7 +80,7 @@ export class NapCatOneBot11Adapter {
|
|||||||
this.actions = createActionMap(this, core);
|
this.actions = createActionMap(this, core);
|
||||||
this.networkManager = new OB11NetworkManager();
|
this.networkManager = new OB11NetworkManager();
|
||||||
}
|
}
|
||||||
async creatOneBotLog(ob11Config: OneBotConfig) {
|
async creatOneBotLog (ob11Config: OneBotConfig) {
|
||||||
let log = '[network] 配置加载\n';
|
let log = '[network] 配置加载\n';
|
||||||
for (const key of ob11Config.network.httpServers) {
|
for (const key of ob11Config.network.httpServers) {
|
||||||
log += `HTTP服务: ${key.host}:${key.port}, : ${key.enable ? '已启动' : '未启动'}\n`;
|
log += `HTTP服务: ${key.host}:${key.port}, : ${key.enable ? '已启动' : '未启动'}\n`;
|
||||||
@@ -98,15 +99,44 @@ export class NapCatOneBot11Adapter {
|
|||||||
}
|
}
|
||||||
return log;
|
return log;
|
||||||
}
|
}
|
||||||
async InitOneBot() {
|
async InitOneBot () {
|
||||||
const selfInfo = this.core.selfInfo;
|
const selfInfo = this.core.selfInfo;
|
||||||
const ob11Config = this.configLoader.configData;
|
const ob11Config = this.configLoader.configData;
|
||||||
|
|
||||||
this.core.apis.UserApi.getUserDetailInfo(selfInfo.uid, false)
|
this.core.apis.UserApi.getUserDetailInfo(selfInfo.uid, false)
|
||||||
.then((user) => {
|
.then(async (user) => {
|
||||||
selfInfo.nick = user.nick;
|
selfInfo.nick = user.nick;
|
||||||
this.context.logger.setLogSelfInfo(selfInfo);
|
this.context.logger.setLogSelfInfo(selfInfo);
|
||||||
WebUiDataRuntime.getQQLoginCallback()(true);
|
|
||||||
|
// 检查是否有待发送的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));
|
.catch(e => this.context.logger.logError(e));
|
||||||
|
|
||||||
@@ -120,7 +150,7 @@ export class NapCatOneBot11Adapter {
|
|||||||
// new OB11PluginAdapter('myPlugin', this.core, this,this.actions)
|
// new OB11PluginAdapter('myPlugin', this.core, this,this.actions)
|
||||||
// );
|
// );
|
||||||
if (existsSync(this.context.pathWrapper.pluginPath)) {
|
if (existsSync(this.context.pathWrapper.pluginPath)) {
|
||||||
this.context.logger.log(`[Plugins] 插件目录存在,开始加载插件`);
|
this.context.logger.log('[Plugins] 插件目录存在,开始加载插件');
|
||||||
this.networkManager.registerAdapter(
|
this.networkManager.registerAdapter(
|
||||||
new OB11PluginMangerAdapter('plugin_manager', this.core, this, this.actions)
|
new OB11PluginMangerAdapter('plugin_manager', this.core, this, this.actions)
|
||||||
);
|
);
|
||||||
@@ -181,35 +211,25 @@ export class NapCatOneBot11Adapter {
|
|||||||
WebUiDataRuntime.setQQVersion(this.core.context.basicInfoWrapper.getFullQQVersion());
|
WebUiDataRuntime.setQQVersion(this.core.context.basicInfoWrapper.getFullQQVersion());
|
||||||
WebUiDataRuntime.setQQLoginInfo(selfInfo);
|
WebUiDataRuntime.setQQLoginInfo(selfInfo);
|
||||||
WebUiDataRuntime.setQQLoginStatus(true);
|
WebUiDataRuntime.setQQLoginStatus(true);
|
||||||
|
|
||||||
let sendWebUiToken = async (token: string) => {
|
|
||||||
await this.core.apis.MsgApi.sendMsg(
|
|
||||||
{ chatType: ChatType.KCHATTYPEC2C, peerUid: selfInfo.uid, guildId: '' },
|
|
||||||
[{
|
|
||||||
elementType: ElementType.TEXT,
|
|
||||||
elementId: '',
|
|
||||||
textElement: {
|
|
||||||
content: 'Update WebUi Token: ' + token,
|
|
||||||
atType: NTMsgAtType.ATTYPEUNKNOWN,
|
|
||||||
atUid: '',
|
|
||||||
atTinyId: '',
|
|
||||||
atNtUid: '',
|
|
||||||
}
|
|
||||||
}],
|
|
||||||
5000
|
|
||||||
)
|
|
||||||
};
|
|
||||||
WebUiDataRuntime.setWebUiTokenChangeCallback(sendWebUiToken);
|
|
||||||
WebUiDataRuntime.setOnOB11ConfigChanged(async (newConfig) => {
|
WebUiDataRuntime.setOnOB11ConfigChanged(async (newConfig) => {
|
||||||
const prev = this.configLoader.configData;
|
const prev = this.configLoader.configData;
|
||||||
this.configLoader.save(newConfig);
|
this.configLoader.save(newConfig);
|
||||||
//this.context.logger.log(`OneBot11 配置更改:${JSON.stringify(prev)} -> ${JSON.stringify(newConfig)}`);
|
//this.context.logger.log(`OneBot11 配置更改:${JSON.stringify(prev)} -> ${JSON.stringify(newConfig)}`);
|
||||||
await this.reloadNetwork(prev, newConfig);
|
await this.reloadNetwork(prev, newConfig);
|
||||||
});
|
});
|
||||||
|
WebUiDataRuntime.setCleanCacheCall(async () => {
|
||||||
|
try {
|
||||||
|
await this.actions.get('clean_cache')?.handle({});
|
||||||
|
return { result: true, message: '缓存清理成功' };
|
||||||
|
} catch (error) {
|
||||||
|
this.context.logger.logError('清理缓存失败:', error);
|
||||||
|
return { result: false, message: `清理缓存失败: ${(error as Error).message}` };
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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 prevLog = await this.creatOneBotLog(prev);
|
||||||
const newLog = await this.creatOneBotLog(now);
|
const newLog = await this.creatOneBotLog(now);
|
||||||
this.context.logger.log(`[Notice] [OneBot11] 配置变更前:\n${prevLog}`);
|
this.context.logger.log(`[Notice] [OneBot11] 配置变更前:\n${prevLog}`);
|
||||||
@@ -222,7 +242,7 @@ export class NapCatOneBot11Adapter {
|
|||||||
await this.handleConfigChange(prev.network.websocketClients, now.network.websocketClients, OB11WebSocketClientAdapter);
|
await this.handleConfigChange(prev.network.websocketClients, now.network.websocketClients, OB11WebSocketClientAdapter);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleConfigChange<CT extends NetworkAdapterConfig>(
|
private async handleConfigChange<CT extends NetworkAdapterConfig> (
|
||||||
prevConfig: NetworkAdapterConfig[],
|
prevConfig: NetworkAdapterConfig[],
|
||||||
nowConfig: NetworkAdapterConfig[],
|
nowConfig: NetworkAdapterConfig[],
|
||||||
adapterClass: new (
|
adapterClass: new (
|
||||||
@@ -254,7 +274,7 @@ export class NapCatOneBot11Adapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private initMsgListener() {
|
private initMsgListener () {
|
||||||
const msgListener = new NodeIKernelMsgListener();
|
const msgListener = new NodeIKernelMsgListener();
|
||||||
msgListener.onRecvSysMsg = (msg) => {
|
msgListener.onRecvSysMsg = (msg) => {
|
||||||
this.apis.MsgApi.parseSysMessage(msg)
|
this.apis.MsgApi.parseSysMessage(msg)
|
||||||
@@ -368,7 +388,7 @@ export class NapCatOneBot11Adapter {
|
|||||||
this.context.session.getMsgService().addKernelMsgListener(proxiedListenerOf(msgListener, this.context.logger));
|
this.context.session.getMsgService().addKernelMsgListener(proxiedListenerOf(msgListener, this.context.logger));
|
||||||
}
|
}
|
||||||
|
|
||||||
private initBuddyListener() {
|
private initBuddyListener () {
|
||||||
const buddyListener = new NodeIKernelBuddyListener();
|
const buddyListener = new NodeIKernelBuddyListener();
|
||||||
|
|
||||||
buddyListener.onBuddyReqChange = async (reqs) => {
|
buddyListener.onBuddyReqChange = async (reqs) => {
|
||||||
@@ -399,7 +419,7 @@ export class NapCatOneBot11Adapter {
|
|||||||
.addKernelBuddyListener(proxiedListenerOf(buddyListener, this.context.logger));
|
.addKernelBuddyListener(proxiedListenerOf(buddyListener, this.context.logger));
|
||||||
}
|
}
|
||||||
|
|
||||||
private initGroupListener() {
|
private initGroupListener () {
|
||||||
const groupListener = new NodeIKernelGroupListener();
|
const groupListener = new NodeIKernelGroupListener();
|
||||||
|
|
||||||
groupListener.onGroupNotifiesUpdated = async (_, notifies) => {
|
groupListener.onGroupNotifiesUpdated = async (_, notifies) => {
|
||||||
@@ -492,7 +512,7 @@ export class NapCatOneBot11Adapter {
|
|||||||
.addKernelGroupListener(proxiedListenerOf(groupListener, this.context.logger));
|
.addKernelGroupListener(proxiedListenerOf(groupListener, this.context.logger));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async emitMsg(message: RawMessage) {
|
private async emitMsg (message: RawMessage) {
|
||||||
const network = await this.networkManager.getAllConfig();
|
const network = await this.networkManager.getAllConfig();
|
||||||
this.context.logger.logDebug('收到新消息 RawMessage', message);
|
this.context.logger.logDebug('收到新消息 RawMessage', message);
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
@@ -501,7 +521,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) {
|
if (message.msgType === NTMsgType.KMSGTYPENULL) {
|
||||||
return;
|
return;
|
||||||
@@ -522,17 +542,17 @@ export class NapCatOneBot11Adapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private isSelfMessage(ob11Msg: {
|
private isSelfMessage (ob11Msg: {
|
||||||
stringMsg: OB11Message;
|
stringMsg: OB11Message
|
||||||
arrayMsg: OB11Message;
|
arrayMsg: OB11Message
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return ob11Msg.stringMsg.user_id.toString() == this.core.selfInfo.uin ||
|
return ob11Msg.stringMsg.user_id.toString() == this.core.selfInfo.uin ||
|
||||||
ob11Msg.arrayMsg.user_id.toString() == this.core.selfInfo.uin;
|
ob11Msg.arrayMsg.user_id.toString() == this.core.selfInfo.uin;
|
||||||
}
|
}
|
||||||
|
|
||||||
private createMsgMap(network: Array<NetworkAdapterConfig>, ob11Msg: {
|
private createMsgMap (network: Array<NetworkAdapterConfig>, ob11Msg: {
|
||||||
stringMsg: OB11Message;
|
stringMsg: OB11Message
|
||||||
arrayMsg: OB11Message;
|
arrayMsg: OB11Message
|
||||||
}, isSelfMsg: boolean, message: RawMessage): Map<string, OB11Message> {
|
}, isSelfMsg: boolean, message: RawMessage): Map<string, OB11Message> {
|
||||||
const msgMap: Map<string, OB11Message> = new Map();
|
const msgMap: Map<string, OB11Message> = new Map();
|
||||||
network.filter(e => e.enable).forEach(e => {
|
network.filter(e => e.enable).forEach(e => {
|
||||||
@@ -550,7 +570,7 @@ export class NapCatOneBot11Adapter {
|
|||||||
return msgMap;
|
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);
|
const debugNetwork = network.filter(e => e.enable && e.debug);
|
||||||
if (debugNetwork.length > 0) {
|
if (debugNetwork.length > 0) {
|
||||||
debugNetwork.forEach(adapter => {
|
debugNetwork.forEach(adapter => {
|
||||||
@@ -564,7 +584,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) {
|
if (isSelfMsg) {
|
||||||
const notReportSelfNetwork = network.filter(e => e.enable && (('reportSelfMessage' in e && !e.reportSelfMessage) || !('reportSelfMessage' in e)));
|
const notReportSelfNetwork = network.filter(e => e.enable && (('reportSelfMessage' in e && !e.reportSelfMessage) || !('reportSelfMessage' in e)));
|
||||||
notReportSelfNetwork.forEach(adapter => {
|
notReportSelfNetwork.forEach(adapter => {
|
||||||
@@ -573,7 +593,7 @@ export class NapCatOneBot11Adapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleGroupEvent(message: RawMessage) {
|
private async handleGroupEvent (message: RawMessage) {
|
||||||
try {
|
try {
|
||||||
// 群名片修改事件解析 任何都该判断
|
// 群名片修改事件解析 任何都该判断
|
||||||
if (message.senderUin && message.senderUin !== '0') {
|
if (message.senderUin && message.senderUin !== '0') {
|
||||||
@@ -606,7 +626,7 @@ export class NapCatOneBot11Adapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handlePrivateMsgEvent(message: RawMessage) {
|
private async handlePrivateMsgEvent (message: RawMessage) {
|
||||||
try {
|
try {
|
||||||
if (message.msgType === NTMsgType.KMSGTYPEGRAYTIPS) {
|
if (message.msgType === NTMsgType.KMSGTYPEGRAYTIPS) {
|
||||||
// 灰条为单元素消息
|
// 灰条为单元素消息
|
||||||
@@ -624,7 +644,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 peer: Peer = { chatType: message.chatType, peerUid: message.peerUid, guildId: '' };
|
||||||
const oriMessageId = MessageUnique.getShortIdByMsgId(message.msgId) ?? MessageUnique.createUniqueMsgId(peer, message.msgId);
|
const oriMessageId = MessageUnique.getShortIdByMsgId(message.msgId) ?? MessageUnique.createUniqueMsgId(peer, message.msgId);
|
||||||
if (message.chatType == ChatType.KCHATTYPEC2C) {
|
if (message.chatType == ChatType.KCHATTYPEC2C) {
|
||||||
@@ -635,7 +655,7 @@ export class NapCatOneBot11Adapter {
|
|||||||
return;
|
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;
|
const operatorUid = element.grayTipElement?.revokeElement.operatorUid;
|
||||||
if (!operatorUid) return undefined;
|
if (!operatorUid) return undefined;
|
||||||
return new OB11FriendRecallNoticeEvent(
|
return new OB11FriendRecallNoticeEvent(
|
||||||
@@ -645,7 +665,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;
|
const operatorUid = element.grayTipElement?.revokeElement.operatorUid;
|
||||||
if (!operatorUid) return undefined;
|
if (!operatorUid) return undefined;
|
||||||
const operatorId = await this.core.apis.UserApi.getUinByUidV2(operatorUid);
|
const operatorId = await this.core.apis.UserApi.getUinByUidV2(operatorUid);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export abstract class IOB11NetworkAdapter<CT extends NetworkAdapterConfig> {
|
|||||||
this.logger = core.context.logger;
|
this.logger = core.context.logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract onEvent<T extends OB11EmitEventContent>(event: T): void;
|
abstract onEvent<T extends OB11EmitEventContent>(event: T): Promise<void>;
|
||||||
|
|
||||||
abstract open(): void | Promise<void>;
|
abstract open(): void | Promise<void>;
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export class OB11HttpClientAdapter extends IOB11NetworkAdapter<HttpClientConfig>
|
|||||||
super(name, config, core, obContext, actions);
|
super(name, config, core, obContext, actions);
|
||||||
}
|
}
|
||||||
|
|
||||||
onEvent<T extends OB11EmitEventContent>(event: T) {
|
async onEvent<T extends OB11EmitEventContent>(event: T) {
|
||||||
this.emitEventAsync(event).catch(e => this.logger.logError('[OneBot] [Http Client] 新消息事件HTTP上报返回快速操作失败', e));
|
this.emitEventAsync(event).catch(e => this.logger.logError('[OneBot] [Http Client] 新消息事件HTTP上报返回快速操作失败', e));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export class OB11HttpSSEServerAdapter extends OB11HttpServerAdapter {
|
|||||||
if (req.path === '/_events') {
|
if (req.path === '/_events') {
|
||||||
this.createSseSupport(req, res);
|
this.createSseSupport(req, res);
|
||||||
} else {
|
} else {
|
||||||
super.httpApiRequest(req, res);
|
super.httpApiRequest(req, res, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,11 +23,22 @@ export class OB11HttpSSEServerAdapter extends OB11HttpServerAdapter {
|
|||||||
req.on('close', () => {
|
req.on('close', () => {
|
||||||
this.sseClients = this.sseClients.filter((client) => client !== res);
|
this.sseClients = this.sseClients.filter((client) => client !== res);
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override onEvent<T extends OB11EmitEventContent>(event: T) {
|
override async onEvent<T extends OB11EmitEventContent>(event: T) {
|
||||||
|
let promises: Promise<void>[] = [];
|
||||||
this.sseClients.forEach((res) => {
|
this.sseClients.forEach((res) => {
|
||||||
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
promises.push(new Promise<void>((resolve, reject) => {
|
||||||
|
res.write(`data: ${JSON.stringify(event)}\n\n`, (err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
await Promise.allSettled(promises);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
override onEvent<T extends OB11EmitEventContent>(_event: T) {
|
override async onEvent<T extends OB11EmitEventContent>(_event: T) {
|
||||||
// http server is passive, no need to emit event
|
// http server is passive, no need to emit event
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async httpApiRequest(req: Request, res: Response) {
|
async httpApiRequest(req: Request, res: Response, request_sse: boolean = false) {
|
||||||
let payload = req.body;
|
let payload = req.body;
|
||||||
if (req.method == 'get') {
|
if (req.method == 'get') {
|
||||||
payload = req.query;
|
payload = req.query;
|
||||||
@@ -117,17 +117,35 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
|
|||||||
return res.json(hello);
|
return res.json(hello);
|
||||||
}
|
}
|
||||||
const actionName = req.path.split('/')[1];
|
const actionName = req.path.split('/')[1];
|
||||||
|
const payload_echo = payload['echo'];
|
||||||
|
const real_echo = payload_echo ?? Math.random().toString(36).substring(2, 15);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const action = this.actions.get(actionName as any);
|
const action = this.actions.get(actionName as any);
|
||||||
if (action) {
|
if (action) {
|
||||||
|
const useStream = action.useStream;
|
||||||
try {
|
try {
|
||||||
const result = await action.handle(payload, this.name, this.config);
|
const result = await action.handle(payload, this.name, this.config, {
|
||||||
|
send: request_sse ? async (data: object) => {
|
||||||
|
await this.onEvent({ ...OB11Response.ok(data, real_echo, true) } as unknown as OB11EmitEventContent);
|
||||||
|
} : async (data: object) => {
|
||||||
|
let newPromise = new Promise<void>((resolve, _reject) => {
|
||||||
|
res.write(JSON.stringify({ ...OB11Response.ok(data, real_echo, true) }) + "\r\n\r\n", () => {
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return newPromise;
|
||||||
|
}
|
||||||
|
}, real_echo);
|
||||||
|
if (useStream) {
|
||||||
|
res.write(JSON.stringify({ ...result }) + "\r\n\r\n");
|
||||||
|
return res.end();
|
||||||
|
};
|
||||||
return res.json(result);
|
return res.json(result);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
return res.json(OB11Response.error((error as Error)?.stack?.toString() || (error as Error)?.message || 'Error Handle', 200));
|
return res.json(OB11Response.error((error as Error)?.stack?.toString() || (error as Error)?.message || 'Error Handle', 200, real_echo));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return res.json(OB11Response.error('不支持的Api ' + actionName, 200));
|
return res.json(OB11Response.error('不支持的Api ' + actionName, 200, real_echo));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ export class OB11NetworkManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async emitEvent(event: OB11EmitEventContent) {
|
async emitEvent(event: OB11EmitEventContent) {
|
||||||
return Promise.all(Array.from(this.adapters.values()).map(adapter => {
|
return Promise.all(Array.from(this.adapters.values()).map(async adapter => {
|
||||||
if (adapter.isEnable) {
|
if (adapter.isEnable) {
|
||||||
return adapter.onEvent(event);
|
return await adapter.onEvent(event);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -32,19 +32,19 @@ export class OB11NetworkManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async emitEventByName(names: string[], event: OB11EmitEventContent) {
|
async emitEventByName(names: string[], event: OB11EmitEventContent) {
|
||||||
return Promise.all(names.map(name => {
|
return Promise.all(names.map(async name => {
|
||||||
const adapter = this.adapters.get(name);
|
const adapter = this.adapters.get(name);
|
||||||
if (adapter && adapter.isEnable) {
|
if (adapter && adapter.isEnable) {
|
||||||
return adapter.onEvent(event);
|
return await adapter.onEvent(event);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async emitEventByNames(map: Map<string, OB11EmitEventContent>) {
|
async emitEventByNames(map: Map<string, OB11EmitEventContent>) {
|
||||||
return Promise.all(Array.from(map.entries()).map(([name, event]) => {
|
return Promise.all(Array.from(map.entries()).map(async ([name, event]) => {
|
||||||
const adapter = this.adapters.get(name);
|
const adapter = this.adapters.get(name);
|
||||||
if (adapter && adapter.isEnable) {
|
if (adapter && adapter.isEnable) {
|
||||||
return adapter.onEvent(event);
|
return await adapter.onEvent(event);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -251,14 +251,20 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
|||||||
this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`);
|
this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
onEvent<T extends OB11EmitEventContent>(event: T) {
|
async onEvent<T extends OB11EmitEventContent>(event: T) {
|
||||||
if (!this.isEnable) {
|
if (!this.isEnable) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 遍历所有已加载的插件,调用它们的事件处理方法
|
// 遍历所有已加载的插件,调用它们的事件处理方法
|
||||||
for (const [, plugin] of this.loadedPlugins) {
|
try {
|
||||||
this.callPluginEventHandler(plugin, event);
|
await Promise.allSettled(
|
||||||
|
Array.from(this.loadedPlugins.values()).map((plugin) =>
|
||||||
|
this.callPluginEventHandler(plugin, event)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.logError('[Plugin Adapter] Error handling event:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ export class OB11PluginAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
|||||||
this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`);
|
this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
onEvent<T extends OB11EmitEventContent>(event: T) {
|
async onEvent<T extends OB11EmitEventContent>(event: T) {
|
||||||
if (!this.isEnable) {
|
if (!this.isEnable) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
|
|||||||
super(name, config, core, obContext, actions);
|
super(name, config, core, obContext, actions);
|
||||||
}
|
}
|
||||||
|
|
||||||
onEvent<T extends OB11EmitEventContent>(event: T) {
|
async onEvent<T extends OB11EmitEventContent>(event: T) {
|
||||||
if (this.connection && this.connection.readyState === WebSocket.OPEN) {
|
if (this.connection && this.connection.readyState === WebSocket.OPEN) {
|
||||||
this.connection.send(JSON.stringify(event));
|
this.connection.send(JSON.stringify(event));
|
||||||
}
|
}
|
||||||
@@ -62,10 +62,15 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private checkStateAndReply<T>(data: T) {
|
private async checkStateAndReply<T>(data: T) {
|
||||||
if (this.connection && this.connection.readyState === WebSocket.OPEN) {
|
return new Promise<void>((resolve, reject) => {
|
||||||
this.connection.send(JSON.stringify(data));
|
if (this.connection && this.connection.readyState === WebSocket.OPEN) {
|
||||||
}
|
this.connection.send(JSON.stringify(data));
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error('WebSocket is not open'));
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async tryConnect() {
|
private async tryConnect() {
|
||||||
@@ -92,7 +97,7 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
|
|||||||
});
|
});
|
||||||
this.connection.on('open', () => {
|
this.connection.on('open', () => {
|
||||||
try {
|
try {
|
||||||
this.connectEvent(this.core);
|
this.connectEvent(this.core).catch(e => this.logger.logError('[OneBot] [WebSocket Client] 发送连接生命周期失败', e));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.logError('[OneBot] [WebSocket Client] 发送连接生命周期失败', e);
|
this.logger.logError('[OneBot] [WebSocket Client] 发送连接生命周期失败', e);
|
||||||
}
|
}
|
||||||
@@ -123,9 +128,9 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
connectEvent(core: NapCatCore) {
|
async connectEvent(core: NapCatCore) {
|
||||||
try {
|
try {
|
||||||
this.checkStateAndReply<unknown>(new OB11LifeCycleEvent(core, LifeCycleSubType.CONNECT));
|
await this.checkStateAndReply<unknown>(new OB11LifeCycleEvent(core, LifeCycleSubType.CONNECT));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.logError('[OneBot] [WebSocket Client] 发送生命周期失败', e);
|
this.logger.logError('[OneBot] [WebSocket Client] 发送生命周期失败', e);
|
||||||
}
|
}
|
||||||
@@ -140,7 +145,7 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
|
|||||||
echo = receiveData.echo;
|
echo = receiveData.echo;
|
||||||
this.logger.logDebug('[OneBot] [WebSocket Client] 收到正向Websocket消息', receiveData);
|
this.logger.logDebug('[OneBot] [WebSocket Client] 收到正向Websocket消息', receiveData);
|
||||||
} catch {
|
} catch {
|
||||||
this.checkStateAndReply<unknown>(OB11Response.error('json解析失败,请检查数据格式', 1400, echo));
|
await this.checkStateAndReply<unknown>(OB11Response.error('json解析失败,请检查数据格式', 1400, echo));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
receiveData.params = (receiveData?.params) ? receiveData.params : {};// 兼容类型验证
|
receiveData.params = (receiveData?.params) ? receiveData.params : {};// 兼容类型验证
|
||||||
@@ -148,11 +153,15 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
|
|||||||
const action = this.actions.get(receiveData.action as any);
|
const action = this.actions.get(receiveData.action as any);
|
||||||
if (!action) {
|
if (!action) {
|
||||||
this.logger.logError('[OneBot] [WebSocket Client] 发生错误', '不支持的Api ' + receiveData.action);
|
this.logger.logError('[OneBot] [WebSocket Client] 发生错误', '不支持的Api ' + receiveData.action);
|
||||||
this.checkStateAndReply<unknown>(OB11Response.error('不支持的Api ' + receiveData.action, 1404, echo));
|
await this.checkStateAndReply<unknown>(OB11Response.error('不支持的Api ' + receiveData.action, 1404, echo));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config);
|
const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config, {
|
||||||
this.checkStateAndReply<unknown>({ ...retdata });
|
send: async (data: object) => {
|
||||||
|
await this.checkStateAndReply<unknown>({ ...OB11Response.ok(data, echo ?? '', true) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await this.checkStateAndReply<unknown>({ ...retdata });
|
||||||
}
|
}
|
||||||
async reload(newConfig: WebsocketClientConfig) {
|
async reload(newConfig: WebsocketClientConfig) {
|
||||||
const wasEnabled = this.isEnable;
|
const wasEnabled = this.isEnable;
|
||||||
|
|||||||
@@ -83,17 +83,25 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
|||||||
}
|
}
|
||||||
connectEvent(core: NapCatCore, wsClient: WebSocket) {
|
connectEvent(core: NapCatCore, wsClient: WebSocket) {
|
||||||
try {
|
try {
|
||||||
this.checkStateAndReply<unknown>(new OB11LifeCycleEvent(core, LifeCycleSubType.CONNECT), wsClient);
|
this.checkStateAndReply<unknown>(new OB11LifeCycleEvent(core, LifeCycleSubType.CONNECT), wsClient).catch(e => this.logger.logError('[OneBot] [WebSocket Server] 发送生命周期失败', e));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.logError('[OneBot] [WebSocket Server] 发送生命周期失败', e);
|
this.logger.logError('[OneBot] [WebSocket Server] 发送生命周期失败', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onEvent<T extends OB11EmitEventContent>(event: T) {
|
async onEvent<T extends OB11EmitEventContent>(event: T) {
|
||||||
this.wsClientsMutex.runExclusive(async () => {
|
this.wsClientsMutex.runExclusive(async () => {
|
||||||
this.wsClientWithEvent.forEach((wsClient) => {
|
let promises = this.wsClientWithEvent.map((wsClient) => {
|
||||||
wsClient.send(JSON.stringify(event));
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
if (wsClient.readyState === WebSocket.OPEN) {
|
||||||
|
wsClient.send(JSON.stringify(event));
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error('WebSocket is not open'));
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
await Promise.allSettled(promises);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,10 +168,15 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private checkStateAndReply<T>(data: T, wsClient: WebSocket) {
|
private async checkStateAndReply<T>(data: T, wsClient: WebSocket) {
|
||||||
if (wsClient.readyState === WebSocket.OPEN) {
|
return await new Promise<void>((resolve, reject) => {
|
||||||
wsClient.send(JSON.stringify(data));
|
if (wsClient.readyState === WebSocket.OPEN) {
|
||||||
}
|
wsClient.send(JSON.stringify(data));
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error('WebSocket is not open'));
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleMessage(wsClient: WebSocket, message: RawData) {
|
private async handleMessage(wsClient: WebSocket, message: RawData) {
|
||||||
@@ -175,7 +188,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
|||||||
echo = receiveData.echo;
|
echo = receiveData.echo;
|
||||||
//this.logger.logDebug('收到正向Websocket消息', receiveData);
|
//this.logger.logDebug('收到正向Websocket消息', receiveData);
|
||||||
} catch {
|
} catch {
|
||||||
this.checkStateAndReply<unknown>(OB11Response.error('json解析失败,请检查数据格式', 1400, echo), wsClient);
|
await this.checkStateAndReply<unknown>(OB11Response.error('json解析失败,请检查数据格式', 1400, echo), wsClient);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
receiveData.params = (receiveData?.params) ? receiveData.params : {};//兼容类型验证 不然类型校验爆炸
|
receiveData.params = (receiveData?.params) ? receiveData.params : {};//兼容类型验证 不然类型校验爆炸
|
||||||
@@ -183,11 +196,15 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
|||||||
const action = this.actions.get(receiveData.action as any);
|
const action = this.actions.get(receiveData.action as any);
|
||||||
if (!action) {
|
if (!action) {
|
||||||
this.logger.logError('[OneBot] [WebSocket Client] 发生错误', '不支持的API ' + receiveData.action);
|
this.logger.logError('[OneBot] [WebSocket Client] 发生错误', '不支持的API ' + receiveData.action);
|
||||||
this.checkStateAndReply<unknown>(OB11Response.error('不支持的API ' + receiveData.action, 1404, echo), wsClient);
|
await this.checkStateAndReply<unknown>(OB11Response.error('不支持的API ' + receiveData.action, 1404, echo), wsClient);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config);
|
const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config, {
|
||||||
this.checkStateAndReply<unknown>({ ...retdata }, wsClient);
|
send: async (data: object) => {
|
||||||
|
await this.checkStateAndReply<unknown>({ ...OB11Response.ok(data, echo ?? '', true) }, wsClient);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await this.checkStateAndReply<unknown>({ ...retdata }, wsClient);
|
||||||
}
|
}
|
||||||
|
|
||||||
async reload(newConfig: WebsocketServerConfig) {
|
async reload(newConfig: WebsocketServerConfig) {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export interface OB11Return<DataType> {
|
|||||||
message: string;
|
message: string;
|
||||||
echo?: unknown; // ws调用api才有此字段
|
echo?: unknown; // ws调用api才有此字段
|
||||||
wording?: string; // go-cqhttp字段,错误信息
|
wording?: string; // go-cqhttp字段,错误信息
|
||||||
|
stream?: 'stream-action' | 'normal-action' ; // 流式返回标记
|
||||||
}
|
}
|
||||||
|
|
||||||
// 消息数据类型枚举
|
// 消息数据类型枚举
|
||||||
|
|||||||
@@ -203,7 +203,13 @@ export class WindowsPtyAgent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _getWindowsBuildNumber(): number {
|
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;
|
let buildNumber: number = 0;
|
||||||
if (osVersion && osVersion.length === 4) {
|
if (osVersion && osVersion.length === 4) {
|
||||||
buildNumber = parseInt(osVersion[3]!);
|
buildNumber = parseInt(osVersion[3]!);
|
||||||
|
|||||||
@@ -4,13 +4,14 @@
|
|||||||
|
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { createServer } from 'http';
|
import { createServer } from 'http';
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
import { createServer as createHttpsServer } from 'https';
|
import { createServer as createHttpsServer } from 'https';
|
||||||
import { LogWrapper } from '@/common/log';
|
import { LogWrapper } from '@/common/log';
|
||||||
import { NapCatPathWrapper } from '@/common/path';
|
import { NapCatPathWrapper } from '@/common/path';
|
||||||
import { WebUiConfigWrapper } from '@webapi/helper/config';
|
import { WebUiConfigWrapper } from '@webapi/helper/config';
|
||||||
import { ALLRouter } from '@webapi/router';
|
import { ALLRouter } from '@webapi/router';
|
||||||
import { cors } from '@webapi/middleware/cors';
|
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 { sendError } from '@webapi/utils/response';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { terminalManager } from '@webapi/terminal/terminal_manager';
|
import { terminalManager } from '@webapi/terminal/terminal_manager';
|
||||||
@@ -30,17 +31,42 @@ const MAX_PORT_TRY = 100;
|
|||||||
import * as net from 'node:net';
|
import * as net from 'node:net';
|
||||||
import { WebUiDataRuntime } from './src/helper/Data';
|
import { WebUiDataRuntime } from './src/helper/Data';
|
||||||
import { existsSync, readFileSync } from 'node:fs';
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
|
||||||
export let webUiRuntimePort = 6099;
|
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 {
|
try {
|
||||||
await tryUseHost(parsedConfig.host);
|
await tryUseHost(parsedConfig.host);
|
||||||
const port = await tryUsePort(parsedConfig.port, parsedConfig.host);
|
const port = await tryUsePort(parsedConfig.port, parsedConfig.host);
|
||||||
return [parsedConfig.host, port, parsedConfig.token];
|
return [parsedConfig.host, port, parsedConfig.token];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('host或port不可用', error);
|
console.log('host或port不可用', error);
|
||||||
return ['', 0, ''];
|
return ['', 0, randomUUID()];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkCertificates(logger: LogWrapper): Promise<{ key: string, cert: string } | null> {
|
async function checkCertificates(logger: LogWrapper): Promise<{ key: string, cert: string } | null> {
|
||||||
try {
|
try {
|
||||||
const certPath = join(webUiPathWrapper.configPath, 'cert.pem');
|
const certPath = join(webUiPathWrapper.configPath, 'cert.pem');
|
||||||
@@ -61,7 +87,27 @@ async function checkCertificates(logger: LogWrapper): Promise<{ key: string, cer
|
|||||||
export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapper) {
|
export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapper) {
|
||||||
webUiPathWrapper = pathWrapper;
|
webUiPathWrapper = pathWrapper;
|
||||||
WebUiConfig = new WebUiConfigWrapper();
|
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
|
// 检查是否禁用WebUI
|
||||||
if (config.disableWebUI) {
|
if (config.disableWebUI) {
|
||||||
@@ -90,19 +136,6 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
WebUiDataRuntime.setQQLoginCallback(async (_status: boolean) => {
|
|
||||||
try {
|
|
||||||
if ((await WebUiConfig.GetWebUIConfig()).defaultToken) {
|
|
||||||
let randomToken = Math.random().toString(36).slice(-8);
|
|
||||||
await WebUiConfig.UpdateWebUIConfig({ token: randomToken });
|
|
||||||
console.log(`[NapCat] [WebUi] Update WebUi Token: ${randomToken}`);
|
|
||||||
await WebUiDataRuntime.getWebUiTokenChangeCallback()(randomToken);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log(`[NapCat] [WebUi] Update WebUi Token failed.` + error);
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
// ------------注册中间件------------
|
// ------------注册中间件------------
|
||||||
// 使用express的json中间件
|
// 使用express的json中间件
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@@ -182,8 +215,8 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp
|
|||||||
|
|
||||||
// ------------启动服务------------
|
// ------------启动服务------------
|
||||||
server.listen(port, host, async () => {
|
server.listen(port, host, async () => {
|
||||||
// 启动后打印出相关地址
|
|
||||||
let searchParams = { token: token };
|
let searchParams = { token: token };
|
||||||
|
logger.log(`[NapCat] [WebUi] 🔑 token=${token}`);
|
||||||
logger.log(
|
logger.log(
|
||||||
`[NapCat] [WebUi] WebUi User Panel Url: ${createUrl('127.0.0.1', port.toString(), '/webui', searchParams)}`
|
`[NapCat] [WebUi] WebUi User Panel Url: ${createUrl('127.0.0.1', port.toString(), '/webui', searchParams)}`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,21 +1,12 @@
|
|||||||
import { RequestHandler } from 'express';
|
import { RequestHandler } from 'express';
|
||||||
|
|
||||||
import { WebUiConfig } from '@/webui';
|
import { WebUiConfig, getInitialWebUiToken, setInitialWebUiToken } from '@/webui';
|
||||||
|
|
||||||
import { AuthHelper } from '@webapi/helper/SignToken';
|
import { AuthHelper } from '@webapi/helper/SignToken';
|
||||||
import { WebUiDataRuntime } from '@webapi/helper/Data';
|
import { WebUiDataRuntime } from '@webapi/helper/Data';
|
||||||
import { sendSuccess, sendError } from '@webapi/utils/response';
|
import { sendSuccess, sendError } from '@webapi/utils/response';
|
||||||
import { isEmpty } from '@webapi/utils/check';
|
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) => {
|
export const LoginHandler: RequestHandler = async (req, res) => {
|
||||||
// 获取WebUI配置
|
// 获取WebUI配置
|
||||||
@@ -33,8 +24,13 @@ export const LoginHandler: RequestHandler = async (req, res) => {
|
|||||||
if (!WebUiDataRuntime.checkLoginRate(clientIP, WebUiConfigData.loginRate)) {
|
if (!WebUiDataRuntime.checkLoginRate(clientIP, WebUiConfigData.loginRate)) {
|
||||||
return sendError(res, 'login rate limit');
|
return sendError(res, 'login rate limit');
|
||||||
}
|
}
|
||||||
//验证config.token hash是否等于token hash
|
// 使用启动时缓存的token进行验证,而不是动态读取配置文件
|
||||||
if (!AuthHelper.comparePasswordHash(WebUiConfigData.token, hash)) {
|
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');
|
return sendError(res, 'token is invalid');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,8 +59,6 @@ export const LogoutHandler: RequestHandler = async (req, res) => {
|
|||||||
|
|
||||||
// 检查登录状态
|
// 检查登录状态
|
||||||
export const checkHandler: RequestHandler = async (req, res) => {
|
export const checkHandler: RequestHandler = async (req, res) => {
|
||||||
// 获取WebUI配置
|
|
||||||
const WebUiConfigData = await WebUiConfig.GetWebUIConfig();
|
|
||||||
// 获取请求头中的Authorization
|
// 获取请求头中的Authorization
|
||||||
const authorization = req.headers.authorization;
|
const authorization = req.headers.authorization;
|
||||||
// 检查凭证
|
// 检查凭证
|
||||||
@@ -79,8 +73,13 @@ export const checkHandler: RequestHandler = async (req, res) => {
|
|||||||
return sendError(res, 'Token has been revoked');
|
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);
|
if (valid) return sendSuccess(res, null);
|
||||||
// 返回错误信息
|
// 返回错误信息
|
||||||
@@ -93,16 +92,36 @@ export const checkHandler: RequestHandler = async (req, res) => {
|
|||||||
|
|
||||||
// 修改密码(token)
|
// 修改密码(token)
|
||||||
export const UpdateTokenHandler: RequestHandler = async (req, res) => {
|
export const UpdateTokenHandler: RequestHandler = async (req, res) => {
|
||||||
const { oldToken, newToken, fromDefault } = req.body;
|
const { oldToken, newToken } = req.body;
|
||||||
const authorization = req.headers.authorization;
|
const authorization = req.headers.authorization;
|
||||||
|
|
||||||
if (isEmpty(newToken)) {
|
if (isEmpty(newToken)) {
|
||||||
return sendError(res, 'newToken is empty');
|
return sendError(res, 'newToken is empty');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果不是从默认密码更新,则需要验证旧密码
|
// 强制要求旧密码
|
||||||
if (!fromDefault && isEmpty(oldToken)) {
|
if (isEmpty(oldToken)) {
|
||||||
return sendError(res, 'oldToken is required when not updating from default password');
|
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 {
|
try {
|
||||||
@@ -113,17 +132,18 @@ export const UpdateTokenHandler: RequestHandler = async (req, res) => {
|
|||||||
AuthHelper.revokeCredential(Credential);
|
AuthHelper.revokeCredential(Credential);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fromDefault) {
|
// 使用启动时缓存的token进行验证
|
||||||
// 从默认密码更新,直接设置新密码
|
const initialToken = getInitialWebUiToken();
|
||||||
const currentConfig = await WebUiConfig.GetWebUIConfig();
|
if (!initialToken) {
|
||||||
if (!currentConfig.defaultToken) {
|
return sendError(res, 'Server token not initialized');
|
||||||
return sendError(res, 'Current password is not default password');
|
|
||||||
}
|
|
||||||
await WebUiConfig.UpdateWebUIConfig({ token: newToken, defaultToken: false });
|
|
||||||
} else {
|
|
||||||
// 正常的密码更新流程
|
|
||||||
await WebUiConfig.UpdateToken(oldToken, newToken);
|
|
||||||
}
|
}
|
||||||
|
if (initialToken !== oldToken) {
|
||||||
|
return sendError(res, '旧 token 不匹配');
|
||||||
|
}
|
||||||
|
// 直接更新配置文件中的token,不需要通过WebUiConfig.UpdateToken方法
|
||||||
|
await WebUiConfig.UpdateWebUIConfig({ token: newToken });
|
||||||
|
// 更新内存中的缓存token,使新密码立即生效
|
||||||
|
setInitialWebUiToken(newToken);
|
||||||
|
|
||||||
return sendSuccess(res, 'Token updated successfully');
|
return sendSuccess(res, 'Token updated successfully');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@@ -24,3 +24,8 @@ export const SetThemeConfigHandler: RequestHandler = async (req, res) => {
|
|||||||
await WebUiConfig.UpdateTheme(theme);
|
await WebUiConfig.UpdateTheme(theme);
|
||||||
sendSuccess(res, { message: '更新成功' });
|
sendSuccess(res, { message: '更新成功' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const CleanCacheHandler: RequestHandler = async (_, res) => {
|
||||||
|
const result = await WebUiDataRuntime.requestCleanCache();
|
||||||
|
sendSuccess(res, result);
|
||||||
|
};
|
||||||
|
|||||||
@@ -9,10 +9,18 @@ import { PassThrough } from 'stream';
|
|||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import webUIFontUploader from '../uploader/webui_font';
|
import webUIFontUploader from '../uploader/webui_font';
|
||||||
import diskUploader from '../uploader/disk';
|
import diskUploader from '../uploader/disk';
|
||||||
import { WebUiConfig } from '@/webui';
|
import { WebUiConfig, getInitialWebUiToken, webUiPathWrapper } from '@/webui';
|
||||||
|
|
||||||
const isWindows = os.platform() === 'win32';
|
const isWindows = os.platform() === 'win32';
|
||||||
|
|
||||||
|
// 安全地从查询参数中提取字符串值,防止类型混淆
|
||||||
|
const getQueryStringParam = (param: any): string => {
|
||||||
|
if (Array.isArray(param)) {
|
||||||
|
return String(param[0] || '');
|
||||||
|
}
|
||||||
|
return String(param || '');
|
||||||
|
};
|
||||||
|
|
||||||
// 获取系统根目录列表(Windows返回盘符列表,其他系统返回['/'])
|
// 获取系统根目录列表(Windows返回盘符列表,其他系统返回['/'])
|
||||||
const getRootDirs = async (): Promise<string[]> => {
|
const getRootDirs = async (): Promise<string[]> => {
|
||||||
if (!isWindows) return ['/'];
|
if (!isWindows) return ['/'];
|
||||||
@@ -32,14 +40,68 @@ const getRootDirs = async (): Promise<string[]> => {
|
|||||||
return drives.length > 0 ? drives : ['C:'];
|
return drives.length > 0 ? drives : ['C:'];
|
||||||
};
|
};
|
||||||
|
|
||||||
// 规范化路径
|
// 规范化路径并进行安全验证
|
||||||
const normalizePath = (inputPath: string): string => {
|
const normalizePath = (inputPath: string): string => {
|
||||||
if (!inputPath) return isWindows ? 'C:\\' : '/';
|
if (!inputPath) {
|
||||||
// 如果是Windows且输入为纯盘符(可能带或不带斜杠),统一返回 "X:\"
|
// 对于空路径,Windows返回用户主目录,其他系统返回根目录
|
||||||
if (isWindows && /^[A-Z]:[\\/]*$/i.test(inputPath)) {
|
return isWindows ? process.env['USERPROFILE'] || 'C:\\' : '/';
|
||||||
return inputPath.slice(0, 2) + '\\';
|
|
||||||
}
|
}
|
||||||
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 {
|
interface FileInfo {
|
||||||
@@ -52,6 +114,35 @@ interface FileInfo {
|
|||||||
// 添加系统文件黑名单
|
// 添加系统文件黑名单
|
||||||
const SYSTEM_FILES = new Set(['pagefile.sys', 'swapfile.sys', 'hiberfil.sys', 'System Volume Information']);
|
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> => {
|
const checkSameTypeExists = async (pathToCheck: string, isDirectory: boolean): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
@@ -65,13 +156,16 @@ const checkSameTypeExists = async (pathToCheck: string, isDirectory: boolean): P
|
|||||||
|
|
||||||
// 获取目录内容
|
// 获取目录内容
|
||||||
export const ListFilesHandler: RequestHandler = async (req, res) => {
|
export const ListFilesHandler: RequestHandler = async (req, res) => {
|
||||||
const webuiToken = await WebUiConfig.GetWebUIConfig();
|
|
||||||
if (webuiToken.defaultToken) {
|
|
||||||
return sendError(res, '默认密码禁止使用');
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const requestPath = (req.query['path'] as string) || (isWindows ? 'C:\\' : '/');
|
const requestPath = getQueryStringParam(req.query['path']) || (isWindows ? process.env['USERPROFILE'] || 'C:\\' : '/');
|
||||||
const normalizedPath = normalizePath(requestPath);
|
|
||||||
|
let normalizedPath: string;
|
||||||
|
try {
|
||||||
|
normalizedPath = normalizePath(requestPath);
|
||||||
|
} catch (pathError) {
|
||||||
|
return sendError(res, '无效的文件路径');
|
||||||
|
}
|
||||||
|
|
||||||
const onlyDirectory = req.query['onlyDirectory'] === 'true';
|
const onlyDirectory = req.query['onlyDirectory'] === 'true';
|
||||||
|
|
||||||
// 如果是根路径且在Windows系统上,返回盘符列表
|
// 如果是根路径且在Windows系统上,返回盘符列表
|
||||||
@@ -139,7 +233,18 @@ export const ListFilesHandler: RequestHandler = async (req, res) => {
|
|||||||
export const CreateDirHandler: RequestHandler = async (req, res) => {
|
export const CreateDirHandler: RequestHandler = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { path: dirPath } = req.body;
|
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)) {
|
if (await checkSameTypeExists(normalizedPath, true)) {
|
||||||
@@ -157,7 +262,19 @@ export const CreateDirHandler: RequestHandler = async (req, res) => {
|
|||||||
export const DeleteHandler: RequestHandler = async (req, res) => {
|
export const DeleteHandler: RequestHandler = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { path: targetPath } = req.body;
|
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);
|
const stat = await fsProm.stat(normalizedPath);
|
||||||
if (stat.isDirectory()) {
|
if (stat.isDirectory()) {
|
||||||
await fsProm.rm(normalizedPath, { recursive: true });
|
await fsProm.rm(normalizedPath, { recursive: true });
|
||||||
@@ -175,7 +292,18 @@ export const BatchDeleteHandler: RequestHandler = async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { paths } = req.body;
|
const { paths } = req.body;
|
||||||
for (const targetPath of paths) {
|
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);
|
const stat = await fsProm.stat(normalizedPath);
|
||||||
if (stat.isDirectory()) {
|
if (stat.isDirectory()) {
|
||||||
await fsProm.rm(normalizedPath, { recursive: true });
|
await fsProm.rm(normalizedPath, { recursive: true });
|
||||||
@@ -192,8 +320,25 @@ export const BatchDeleteHandler: RequestHandler = async (req, res) => {
|
|||||||
// 读取文件内容
|
// 读取文件内容
|
||||||
export const ReadFileHandler: RequestHandler = async (req, res) => {
|
export const ReadFileHandler: RequestHandler = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const filePath = normalizePath(req.query['path'] as string);
|
let filePath: string;
|
||||||
const content = await fsProm.readFile(filePath, 'utf-8');
|
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);
|
return sendSuccess(res, content);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return sendError(res, '读取文件失败');
|
return sendError(res, '读取文件失败');
|
||||||
@@ -204,8 +349,40 @@ export const ReadFileHandler: RequestHandler = async (req, res) => {
|
|||||||
export const WriteFileHandler: RequestHandler = async (req, res) => {
|
export const WriteFileHandler: RequestHandler = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { path: filePath, content } = req.body;
|
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);
|
return sendSuccess(res, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return sendError(res, '写入文件失败');
|
return sendError(res, '写入文件失败');
|
||||||
@@ -216,7 +393,18 @@ export const WriteFileHandler: RequestHandler = async (req, res) => {
|
|||||||
export const CreateFileHandler: RequestHandler = async (req, res) => {
|
export const CreateFileHandler: RequestHandler = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { path: filePath } = req.body;
|
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)) {
|
if (await checkSameTypeExists(normalizedPath, false)) {
|
||||||
@@ -234,8 +422,21 @@ export const CreateFileHandler: RequestHandler = async (req, res) => {
|
|||||||
export const RenameHandler: RequestHandler = async (req, res) => {
|
export const RenameHandler: RequestHandler = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { oldPath, newPath } = req.body;
|
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);
|
await fsProm.rename(normalizedOldPath, normalizedNewPath);
|
||||||
return sendSuccess(res, true);
|
return sendSuccess(res, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -247,8 +448,21 @@ export const RenameHandler: RequestHandler = async (req, res) => {
|
|||||||
export const MoveHandler: RequestHandler = async (req, res) => {
|
export const MoveHandler: RequestHandler = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { sourcePath, targetPath } = req.body;
|
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);
|
await fsProm.rename(normalizedSourcePath, normalizedTargetPath);
|
||||||
return sendSuccess(res, true);
|
return sendSuccess(res, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -261,8 +475,20 @@ export const BatchMoveHandler: RequestHandler = async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { items } = req.body;
|
const { items } = req.body;
|
||||||
for (const { sourcePath, targetPath } of items) {
|
for (const { sourcePath, targetPath } of items) {
|
||||||
const normalizedSourcePath = normalizePath(sourcePath);
|
let normalizedSourcePath: string;
|
||||||
const normalizedTargetPath = normalizePath(targetPath);
|
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);
|
await fsProm.rename(normalizedSourcePath, normalizedTargetPath);
|
||||||
}
|
}
|
||||||
return sendSuccess(res, true);
|
return sendSuccess(res, true);
|
||||||
@@ -274,11 +500,22 @@ export const BatchMoveHandler: RequestHandler = async (req, res) => {
|
|||||||
// 新增:文件下载处理方法(注意流式传输,不将整个文件读入内存)
|
// 新增:文件下载处理方法(注意流式传输,不将整个文件读入内存)
|
||||||
export const DownloadHandler: RequestHandler = async (req, res) => {
|
export const DownloadHandler: RequestHandler = async (req, res) => {
|
||||||
try {
|
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) {
|
if (!filePath) {
|
||||||
return sendError(res, '参数错误');
|
return sendError(res, '参数错误');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 额外安全检查:确保路径是绝对路径
|
||||||
|
if (!path.isAbsolute(filePath)) {
|
||||||
|
return sendError(res, '路径必须是绝对路径');
|
||||||
|
}
|
||||||
|
|
||||||
const stat = await fsProm.stat(filePath);
|
const stat = await fsProm.stat(filePath);
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'application/octet-stream');
|
res.setHeader('Content-Type', 'application/octet-stream');
|
||||||
@@ -316,12 +553,25 @@ export const BatchDownloadHandler: RequestHandler = async (req, res) => {
|
|||||||
const zipStream = new compressing.zip.Stream();
|
const zipStream = new compressing.zip.Stream();
|
||||||
// 修改:根据文件类型设置 relativePath
|
// 修改:根据文件类型设置 relativePath
|
||||||
for (const filePath of paths) {
|
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);
|
const stat = await fsProm.stat(normalizedPath);
|
||||||
if (stat.isDirectory()) {
|
if (stat.isDirectory()) {
|
||||||
zipStream.addEntry(normalizedPath, { relativePath: '' });
|
zipStream.addEntry(normalizedPath, { relativePath: '' });
|
||||||
} else {
|
} else {
|
||||||
zipStream.addEntry(normalizedPath, { relativePath: path.basename(normalizedPath) });
|
// 确保相对路径只使用文件名,防止路径遍历
|
||||||
|
const safeName = path.basename(normalizedPath);
|
||||||
|
zipStream.addEntry(normalizedPath, { relativePath: safeName });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
zipStream.pipe(res);
|
zipStream.pipe(res);
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import { terminalManager } from '../terminal/terminal_manager';
|
|||||||
import { WebUiConfig } from '@/webui';
|
import { WebUiConfig } from '@/webui';
|
||||||
// 判断是否是 macos
|
// 判断是否是 macos
|
||||||
const isMacOS = process.platform === 'darwin';
|
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) => {
|
export const LogHandler: RequestHandler = async (req, res) => {
|
||||||
const filename = req.query['id'];
|
const filename = req.query['id'];
|
||||||
@@ -16,7 +22,8 @@ export const LogHandler: RequestHandler = async (req, res) => {
|
|||||||
return sendError(res, 'ID不合法');
|
return sendError(res, 'ID不合法');
|
||||||
}
|
}
|
||||||
const logContent = await WebUiConfig.GetLogContent(filename);
|
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');
|
res.setHeader('Connection', 'keep-alive');
|
||||||
const listener = (log: string) => {
|
const listener = (log: string) => {
|
||||||
try {
|
try {
|
||||||
res.write(`data: ${log}\n\n`);
|
const sanitizedLog = sanitizeLog(log);
|
||||||
|
res.write(`data: ${sanitizedLog}\n\n`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('向客户端写入日志数据时出错:', error);
|
console.error('向客户端写入日志数据时出错:', error);
|
||||||
}
|
}
|
||||||
@@ -47,9 +55,6 @@ export const CreateTerminalHandler: RequestHandler = async (req, res) => {
|
|||||||
if (isMacOS) {
|
if (isMacOS) {
|
||||||
return sendError(res, 'MacOS不支持终端');
|
return sendError(res, 'MacOS不支持终端');
|
||||||
}
|
}
|
||||||
if ((await WebUiConfig.GetWebUIConfig()).defaultToken) {
|
|
||||||
return sendError(res, '该密码禁止创建终端');
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const { cols, rows } = req.body;
|
const { cols, rows } = req.body;
|
||||||
const { id } = terminalManager.createTerminal(cols, rows);
|
const { id } = terminalManager.createTerminal(cols, rows);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { RequestHandler } from 'express';
|
|||||||
import { existsSync, readFileSync } from 'node:fs';
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { loadConfig, OneBotConfig } from '@/onebot/config/config';
|
import { loadConfig, OneBotConfig } from '@/onebot/config/config';
|
||||||
import { WebUiConfig, webUiPathWrapper } from '@/webui';
|
import { webUiPathWrapper } from '@/webui';
|
||||||
import { WebUiDataRuntime } from '@webapi/helper/Data';
|
import { WebUiDataRuntime } from '@webapi/helper/Data';
|
||||||
import { sendError, sendSuccess } from '@webapi/utils/response';
|
import { sendError, sendSuccess } from '@webapi/utils/response';
|
||||||
import { isEmpty } from '@webapi/utils/check';
|
import { isEmpty } from '@webapi/utils/check';
|
||||||
@@ -47,10 +47,6 @@ export const OB11SetConfigHandler: RequestHandler = async (req, res) => {
|
|||||||
if (isEmpty(req.body.config)) {
|
if (isEmpty(req.body.config)) {
|
||||||
return sendError(res, 'config is empty');
|
return sendError(res, 'config is empty');
|
||||||
}
|
}
|
||||||
const webuiToken = await WebUiConfig.GetWebUIConfig();
|
|
||||||
if (webuiToken.defaultToken) {
|
|
||||||
return sendError(res, '默认密码禁止写入配置');
|
|
||||||
}
|
|
||||||
// 写入配置
|
// 写入配置
|
||||||
try {
|
try {
|
||||||
// 解析并加载配置
|
// 解析并加载配置
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ const LoginRuntime: LoginRuntimeType = {
|
|||||||
onQuickLoginRequested: async () => {
|
onQuickLoginRequested: async () => {
|
||||||
return { result: false, message: '' };
|
return { result: false, message: '' };
|
||||||
},
|
},
|
||||||
|
onCleanCacheRequested: async () => {
|
||||||
|
return { result: false, message: '' };
|
||||||
|
},
|
||||||
QQLoginList: [],
|
QQLoginList: [],
|
||||||
NewQQLoginList: [],
|
NewQQLoginList: [],
|
||||||
},
|
},
|
||||||
@@ -130,6 +133,14 @@ export const WebUiDataRuntime = {
|
|||||||
return LoginRuntime.NapCatHelper.onOB11ConfigChanged(ob11);
|
return LoginRuntime.NapCatHelper.onOB11ConfigChanged(ob11);
|
||||||
} as LoginRuntimeType['NapCatHelper']['onOB11ConfigChanged'],
|
} as LoginRuntimeType['NapCatHelper']['onOB11ConfigChanged'],
|
||||||
|
|
||||||
|
setCleanCacheCall(func: LoginRuntimeType['NapCatHelper']['onCleanCacheRequested']): void {
|
||||||
|
LoginRuntime.NapCatHelper.onCleanCacheRequested = func;
|
||||||
|
},
|
||||||
|
|
||||||
|
requestCleanCache: function () {
|
||||||
|
return LoginRuntime.NapCatHelper.onCleanCacheRequested();
|
||||||
|
} as LoginRuntimeType['NapCatHelper']['onCleanCacheRequested'],
|
||||||
|
|
||||||
getPackageJson() {
|
getPackageJson() {
|
||||||
return LoginRuntime.packageJson;
|
return LoginRuntime.packageJson;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { webUiPathWrapper } from '@/webui';
|
import { webUiPathWrapper, getInitialWebUiToken } from '@/webui';
|
||||||
import { Type, Static } from '@sinclair/typebox';
|
import { Type, Static } from '@sinclair/typebox';
|
||||||
import Ajv from 'ajv';
|
import Ajv from 'ajv';
|
||||||
import fs, { constants } from 'node:fs/promises';
|
import fs, { constants } from 'node:fs/promises';
|
||||||
@@ -7,17 +7,17 @@ import { resolve } from 'node:path';
|
|||||||
|
|
||||||
import { deepMerge } from '../utils/object';
|
import { deepMerge } from '../utils/object';
|
||||||
import { themeType } from '../types/theme';
|
import { themeType } from '../types/theme';
|
||||||
|
import { getRandomToken } from '../utils/url'
|
||||||
|
|
||||||
// 限制尝试端口的次数,避免死循环
|
// 限制尝试端口的次数,避免死循环
|
||||||
// 定义配置的类型
|
// 定义配置的类型
|
||||||
const WebUiConfigSchema = Type.Object({
|
const WebUiConfigSchema = Type.Object({
|
||||||
host: Type.String({ default: '0.0.0.0' }),
|
host: Type.String({ default: '0.0.0.0' }),
|
||||||
port: Type.Number({ default: 6099 }),
|
port: Type.Number({ default: 6099 }),
|
||||||
token: Type.String({ default: 'napcat' }),
|
token: Type.String({ default: getRandomToken(12) }),
|
||||||
loginRate: Type.Number({ default: 10 }),
|
loginRate: Type.Number({ default: 10 }),
|
||||||
autoLoginAccount: Type.String({ default: '' }),
|
autoLoginAccount: Type.String({ default: '' }),
|
||||||
theme: themeType,
|
theme: themeType,
|
||||||
defaultToken: Type.Boolean({ default: true }),
|
|
||||||
// 是否关闭WebUI
|
// 是否关闭WebUI
|
||||||
disableWebUI: Type.Boolean({ default: false }),
|
disableWebUI: Type.Boolean({ default: false }),
|
||||||
// 是否关闭非局域网访问
|
// 是否关闭非局域网访问
|
||||||
@@ -63,6 +63,47 @@ export class WebUiConfigWrapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async GetWebUIConfig(): Promise<WebUiConfigType> {
|
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) {
|
if (this.WebUiConfigData) {
|
||||||
return this.WebUiConfigData;
|
return this.WebUiConfigData;
|
||||||
}
|
}
|
||||||
@@ -78,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> {
|
async UpdateToken(oldToken: string, newToken: string): Promise<void> {
|
||||||
const currentConfig = await this.GetWebUIConfig();
|
// 使用内存中缓存的token进行验证,确保强兼容性
|
||||||
if (currentConfig.token !== oldToken) {
|
const cachedToken = getInitialWebUiToken();
|
||||||
|
const tokenToCheck = cachedToken || (await this.GetWebUIConfig()).token;
|
||||||
|
|
||||||
|
if (tokenToCheck !== oldToken) {
|
||||||
throw new Error('旧 token 不匹配');
|
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 { NextFunction, Request, Response } from 'express';
|
||||||
|
|
||||||
import { WebUiConfig } from '@/webui';
|
import { getInitialWebUiToken } from '@/webui';
|
||||||
|
|
||||||
import { AuthHelper } from '@webapi/helper/SignToken';
|
import { AuthHelper } from '@webapi/helper/SignToken';
|
||||||
import { sendError } from '@webapi/utils/response';
|
import { sendError } from '@webapi/utils/response';
|
||||||
@@ -30,10 +30,13 @@ export async function auth(req: Request, res: Response, next: NextFunction) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
return sendError(res, 'Unauthorized');
|
return sendError(res, 'Unauthorized');
|
||||||
}
|
}
|
||||||
// 获取配置
|
// 使用启动时缓存的token进行验证,而不是动态读取配置文件 因为有可能运行时手动修改了密码
|
||||||
const config = await WebUiConfig.GetWebUIConfig();
|
const initialToken = getInitialWebUiToken();
|
||||||
|
if (!initialToken) {
|
||||||
|
return sendError(res, 'Server token not initialized');
|
||||||
|
}
|
||||||
// 验证凭证在1小时内有效
|
// 验证凭证在1小时内有效
|
||||||
const credentialJson = AuthHelper.validateCredentialWithinOneHour(config.token, Credential);
|
const credentialJson = AuthHelper.validateCredentialWithinOneHour(initialToken, Credential);
|
||||||
if (credentialJson) {
|
if (credentialJson) {
|
||||||
// 通过验证
|
// 通过验证
|
||||||
return next();
|
return next();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { GetThemeConfigHandler, PackageInfoHandler, QQVersionHandler, SetThemeConfigHandler } from '../api/BaseInfo';
|
import { CleanCacheHandler, GetThemeConfigHandler, PackageInfoHandler, QQVersionHandler, SetThemeConfigHandler } from '../api/BaseInfo';
|
||||||
import { StatusRealTimeHandler } from '@webapi/api/Status';
|
import { StatusRealTimeHandler } from '@webapi/api/Status';
|
||||||
import { GetProxyHandler } from '../api/Proxy';
|
import { GetProxyHandler } from '../api/Proxy';
|
||||||
|
|
||||||
@@ -11,5 +11,6 @@ router.get('/GetSysStatusRealTime', StatusRealTimeHandler);
|
|||||||
router.get('/proxy', GetProxyHandler);
|
router.get('/proxy', GetProxyHandler);
|
||||||
router.get('/Theme', GetThemeConfigHandler);
|
router.get('/Theme', GetThemeConfigHandler);
|
||||||
router.post('/SetTheme', SetThemeConfigHandler);
|
router.post('/SetTheme', SetThemeConfigHandler);
|
||||||
|
router.post('/CleanCache', CleanCacheHandler);
|
||||||
|
|
||||||
export { router as BaseRouter };
|
export { router as BaseRouter };
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CheckDefaultTokenHandler,
|
|
||||||
checkHandler,
|
checkHandler,
|
||||||
LoginHandler,
|
LoginHandler,
|
||||||
LogoutHandler,
|
LogoutHandler,
|
||||||
@@ -17,7 +16,5 @@ router.post('/check', checkHandler);
|
|||||||
router.post('/logout', LogoutHandler);
|
router.post('/logout', LogoutHandler);
|
||||||
// router:更新token
|
// router:更新token
|
||||||
router.post('/update_token', UpdateTokenHandler);
|
router.post('/update_token', UpdateTokenHandler);
|
||||||
// router:检查默认token
|
|
||||||
router.get('/check_using_default_token', CheckDefaultTokenHandler);
|
|
||||||
|
|
||||||
export { router as AuthRouter };
|
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', {
|
Object.defineProperty(global, '__dirname', {
|
||||||
get() {
|
get () {
|
||||||
const err = new Error();
|
const sites = callsites()
|
||||||
const stack = err.stack?.split('\n') || [];
|
const file = sites?.[1]?.getFileName()
|
||||||
let callerFile = '';
|
if (file) {
|
||||||
// 遍历错误堆栈,跳过当前文件所在行
|
return path.dirname(fileURLToPath(file))
|
||||||
// 注意:堆栈格式可能不同,请根据实际环境调整索引及正则表达式
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return callerFile ? path.dirname(callerFile) : '';
|
return ''
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import './init-dynamic-dirname';
|
// import './init-dynamic-dirname';
|
||||||
import { WebUiConfig } from '@/webui';
|
import { WebUiConfig } from '@/webui';
|
||||||
import { AuthHelper } from '../helper/SignToken';
|
import { AuthHelper } from '../helper/SignToken';
|
||||||
import { LogWrapper } from '@/common/log';
|
import { LogWrapper } from '@/common/log';
|
||||||
|
|||||||
1
src/webui/src/types/data.d.ts
vendored
1
src/webui/src/types/data.d.ts
vendored
@@ -15,6 +15,7 @@ interface LoginRuntimeType {
|
|||||||
NapCatHelper: {
|
NapCatHelper: {
|
||||||
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string }>;
|
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string }>;
|
||||||
onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;
|
onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;
|
||||||
|
onCleanCacheRequested: () => Promise<{ result: boolean; message: string }>;
|
||||||
QQLoginList: string[];
|
QQLoginList: string[];
|
||||||
NewQQLoginList: LoginListItem[];
|
NewQQLoginList: LoginListItem[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -66,7 +66,15 @@ export const createDiskStorage = (uploadPath: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createDiskUpload = (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;
|
return upload;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -76,6 +84,18 @@ const diskUploader = (req: Request, res: Response) => {
|
|||||||
createDiskUpload(uploadPath)(req, res, (error) => {
|
createDiskUpload(uploadPath)(req, res, (error) => {
|
||||||
if (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 reject(error);
|
||||||
}
|
}
|
||||||
return resolve(true);
|
return resolve(true);
|
||||||
|
|||||||
@@ -1,8 +1,57 @@
|
|||||||
/**
|
/**
|
||||||
* @file URL工具
|
* @file URL工具
|
||||||
*/
|
*/
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import { isIP } from 'node:net'
|
||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
|
|
||||||
import { isIP } from 'node:net';
|
type Protocol = 'http' | 'https'
|
||||||
|
|
||||||
|
let isDockerCached: boolean
|
||||||
|
|
||||||
|
function hasDockerEnv () {
|
||||||
|
try {
|
||||||
|
fs.statSync('/.dockerenv')
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDockerCGroup () {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync('/proc/self/cgroup', 'utf8').includes('docker')
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasContainerEnv = () => {
|
||||||
|
try {
|
||||||
|
fs.statSync('/run/.containerenv')
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDocker = () => {
|
||||||
|
if (isDockerCached === undefined) {
|
||||||
|
isDockerCached = hasContainerEnv() || hasDockerEnv() || hasDockerCGroup()
|
||||||
|
}
|
||||||
|
|
||||||
|
return isDockerCached
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取默认host地址
|
||||||
|
* @returns 根据环境返回合适的host地址
|
||||||
|
* @example getDefaultHost() => '127.0.0.1' // 非Docker环境
|
||||||
|
* @example getDefaultHost() => '0.0.0.0' // Docker环境
|
||||||
|
*/
|
||||||
|
export const getDefaultHost = (): string => {
|
||||||
|
return isDocker() ? '0.0.0.0' : '127.0.0.1'
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将 host(主机地址) 转换为标准格式
|
* 将 host(主机地址) 转换为标准格式
|
||||||
@@ -13,9 +62,9 @@ import { isIP } from 'node:net';
|
|||||||
* @example normalizeHost('2001:4860:4801:51::27') => '[2001:4860:4801:51::27]'
|
* @example normalizeHost('2001:4860:4801:51::27') => '[2001:4860:4801:51::27]'
|
||||||
*/
|
*/
|
||||||
export const normalizeHost = (host: string) => {
|
export const normalizeHost = (host: string) => {
|
||||||
if (isIP(host) === 6) return `[${host}]`;
|
if (isIP(host) === 6) return `[${host}]`
|
||||||
return host;
|
return host
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建URL
|
* 创建URL
|
||||||
@@ -34,13 +83,23 @@ export const createUrl = (
|
|||||||
search?: Record<string, any>,
|
search?: Record<string, any>,
|
||||||
protocol: Protocol = 'http'
|
protocol: Protocol = 'http'
|
||||||
) => {
|
) => {
|
||||||
const url = new URL(`${protocol}://${normalizeHost(host)}`);
|
const url = new URL(`${protocol}://${normalizeHost(host)}`)
|
||||||
url.port = port;
|
url.port = port
|
||||||
url.pathname = path;
|
url.pathname = path
|
||||||
if (search) {
|
if (search) {
|
||||||
for (const key in search) {
|
for (const key in search) {
|
||||||
url.searchParams.set(key, search[key]);
|
url.searchParams.set(key, search[key])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return url.toString();
|
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