Compare commits

..

41 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
edc14763f0 Improve fix: only suppress card change events for forward messages
Changed the condition to specifically check for forward message elements (multiForwardMsgElement or arkElement) before suppressing empty card name changes. This ensures legitimate card name clearing (setting to empty) still works correctly while preventing false positives from forward messages.

Co-authored-by: sj817 <74231782+sj817@users.noreply.github.com>
2025-10-02 01:43:50 +00:00
copilot-swe-agent[bot]
6a6a0e0539 Fix spurious group_card events for forward messages
Prevent false group card change events when sending forward messages with comments. The issue occurred because forward messages sometimes have empty sendMemberName fields, triggering incorrect "name → empty" card change events.

Solution: Skip card change detection when the new card name is empty but the old card name is not empty, as this indicates unreliable data (e.g., from forward messages).

Co-authored-by: sj817 <74231782+sj817@users.noreply.github.com>
2025-10-02 01:26:10 +00:00
copilot-swe-agent[bot]
b930eea84f Initial plan 2025-10-02 01:16:56 +00:00
手瓜一十雪
f2c62db76e Update README with new features in v4.8.115+
Added a section describing new features in version v4.8.115+, including Stream API support and recommendations to use string types for message_id, user_id, and group_id. Also explained the benefits of these changes for Docker, cross-device, large file transfers, and better compatibility with languages lacking large integer support.
2025-09-21 13:29:35 +08:00
手瓜一十雪
b1b051c4ce Update DeepWiki badge formatting in README
Reformatted the DeepWiki badge section in the README to match the table style used for other community links.
2025-09-20 16:45:27 +08:00
手瓜一十雪
a754b2ecc7 Add DeepWiki badge to README
Added a DeepWiki badge with a link to the project's DeepWiki page for increased visibility and resource access.
2025-09-20 16:44:57 +08:00
Mlikiowa
e0eb625b75 release: v4.8.116 2025-09-20 08:20:05 +00:00
手瓜一十雪
937be7678e Add file_retention parameter to upload test
Introduces the 'file_retention' field with a value of 30,000 to the upload test payload in OneBotUploadTester. This may be used to specify file retention duration in milliseconds.
2025-09-20 16:19:39 +08:00
手瓜一十雪
9b88946209 Add file retention option to UploadFileStream
Introduces a 'file_retention' parameter to control how long uploaded files are retained before automatic deletion. If set, files are deleted after the specified duration; otherwise, they are not automatically removed. This helps manage temporary file storage and cleanup.
2025-09-20 16:13:25 +08:00
Mlikiowa
74de3d9100 release: v4.8.115 2025-09-20 07:57:47 +00:00
手瓜一十雪
42d50014a1 Refactor event handling to use async/await across adapters
Updated all network adapters' onEvent methods to be asynchronous and return Promises, ensuring consistent async event emission and handling. Adjusted related methods and event emission logic to properly await asynchronous operations, improving reliability for streaming, plugin, HTTP, and WebSocket event flows. Also improved error handling and messaging in stream and WebSocket actions.
2025-09-20 15:55:37 +08:00
手瓜一十雪
a36ae315b0 Fix useStream variable scope in HTTP server adapter
Moved the declaration of useStream inside the action check to prevent referencing it when action is undefined.
2025-09-20 15:23:37 +08:00
手瓜一十雪
2161ec5fa7 feat: 标准化 2025-09-20 15:20:43 +08:00
手瓜一十雪
32bba007cd Add Readme for stream API actions
Introduces a Readme.txt file in the stream action directory, providing an overview and usage notes for stream-related API functions such as file upload, download, and cleanup.
2025-09-16 23:33:48 +08:00
Mlikiowa
84d3dc9f40 release: v4.8.114 2025-09-16 15:24:25 +00:00
手瓜一十雪
890d032794 Add streaming file upload and download actions
Introduces new OneBot actions for streaming file upload and download, including chunked file transfer with memory/disk management and SHA256 verification. Adds CleanStreamTempFile, DownloadFileStream, UploadFileStream, and TestStreamDownload actions, updates action routing and network adapters to support streaming via HTTP and WebSocket, and provides Python test scripts for concurrent upload testing.
2025-09-16 23:24:00 +08:00
837951602
66f30e1ebf 找不到类型时显式报错 (#1256) 2025-09-16 17:19:59 +08:00
MliKiowa
ada614d007 Fix link for QQ Group#2 in README
Updated the link for QQ Group#2 in the README.
2025-09-13 17:23:21 +08:00
MliKiowa
ea3ab7f13f Fix QQ Group links in README.md 2025-09-13 17:22:53 +08:00
MliKiowa
a5e4c24de3 Revise README for clarity and additional resources
Updated README with links to documentation and community guidelines.
2025-09-13 17:20:07 +08:00
Mlikiowa
bcc7d25b64 release: v4.8.113 2025-09-13 05:51:02 +00:00
手瓜一十雪
aae676fdc7 Update default host and token length in config
Changed the default host to '0.0.0.0' and increased the default token length from 8 to 12 characters in WebUiConfigSchema. Also removed unused getDefaultHost import and made minor formatting adjustments.
2025-09-13 13:49:18 +08:00
Mlikiowa
0e9aa43476 release: v4.8.112 2025-09-12 14:15:08 +00:00
时瑾
b2ff556aa6 fix: 更新默认主机地址获取逻辑,支持Docker环境 2025-09-12 21:56:12 +08:00
Mlikiowa
69c5b78678 release: v4.8.111 2025-09-12 11:19:26 +00:00
时瑾
8be7f74e9f fix: 移除 defaultToken 字段,彻底移除硬编码的默认密码,采用全随机密码 2025-09-12 18:50:21 +08:00
时瑾
a05150ebe1 fix(dos): 修复红红的ci 2025-09-12 15:36:30 +08:00
Mlikiowa
5e6b607ded release: v4.8.110 2025-09-11 05:15:29 +00:00
时瑾
df2dabfe76 refactor: 将默认密码相关逻辑重构为后端处理 (#1247)
* refactor: 将默认密码相关逻辑重构为后端处理

* refactor: 日志路由进行脱敏,生成随机密码使用node:crypto.randomBytes

* feat: 更新密码功能增强,添加新密码强度验证和旧密码检查

* feat: 给文件管理添加WebUI配置文件的脱敏处理和验证逻辑

* refactor: 优化网络显示卡片按钮样式和行为,调整按钮属性以提升用户体验

* feat: 增强路径处理逻辑,添加安全验证以防止路径遍历攻击

* feat: 增强文件路径处理逻辑,添加安全验证以防止路径遍历攻击,并优化查询参数提取

* feat: CodeQL不认可 受不了
2025-09-11 13:13:00 +08:00
手瓜一十雪
5e032fcc6a upate: package 2025-09-08 16:16:38 +08:00
Mlikiowa
44200a2208 release: v4.8.109 2025-09-08 08:11:05 +00:00
手瓜一十雪
e39bb05f01 fix: 多层解析 2025-09-08 16:05:10 +08:00
手瓜一十雪
677731dd70 Add qq-chat-exporter to recommended tools
Included qq-chat-exporter, a NapCat-based message export tool, in the list of recommended related projects in the README.
2025-09-07 15:20:17 +08:00
Mlikiowa
fa8e6f2c59 release: v4.8.108 2025-09-07 05:57:27 +00:00
手瓜一十雪
509b23ff04 Update Telegram link in About page
Changed the Telegram href in the About page from MelodicMoonlight to napcatqq to reflect the correct contact or channel.
2025-09-07 13:54:44 +08:00
Mlikiowa
cf1765f5a4 release: v4.8.107 2025-09-07 05:48:15 +00:00
手瓜一十雪
c541c7e257 Update Telegram link in README
Changed the Telegram badge and link from MelodicMoonlight to napcatqq for accuracy.
2025-09-07 13:47:23 +08:00
手瓜一十雪
298b8b71c8 Log WebUI panel URL on server start
Adds logging of the WebUI user panel URL with localhost address when the server starts. Also adjusts QQ login callback invocation in OneBot adapter for improved login flow.
2025-09-07 13:29:37 +08:00
手瓜一十雪
5c120a8231 Add WebUI token update and callback handling
Introduces callback mechanisms for WebUI token changes and QQ login status updates. The WebUI token is now updated and communicated via a callback after login, and related runtime and type definitions are extended to support these features. Also sets a static default token value in the config schema.
2025-09-06 18:15:35 +08:00
手瓜一十雪
88ee8f89fe Comment out documentation links in README
The section containing links to documentation and Telegram has been commented out in the README.md. This may be for temporary removal or future revision.
2025-09-06 13:13:14 +08:00
Mlikiowa
12b8130372 release: v4.8.106 2025-09-06 03:53:33 +00:00
52 changed files with 1806 additions and 395 deletions

View File

@@ -9,4 +9,9 @@
"css.customData": [
".vscode/tailwindcss.json"
],
}
"editor.formatOnPaste": false,
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "never"
},
}

View File

@@ -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
+ NapCatQQ is a modern implementation of the Bot protocol based on NTQQ.
- NapCatQQ 是现代化的基于 NTQQ 的 Bot 协议端实现
@@ -33,6 +42,7 @@ _Modern protocol-side framework implemented based on NTQQ._
**首次使用**请务必查看如下文档看使用教程
> 项目非盈利,对接问题/基础问题/下层框架问题 请自行搜索解决,本项目社区不提供此类解答。
## Link
| Docs | [![Github.IO](https://img.shields.io/badge/docs%20on-Github.IO-orange)](https://napneko.github.io/) | [![Cloudflare.Worker](https://img.shields.io/badge/docs%20on-Cloudflare.Worker-black)](https://doc.napneko.icu/) | [![Cloudflare.HKServer](https://img.shields.io/badge/docs%20on-Cloudflare.HKServer-informational)](https://napcat.napneko.icu/) |
@@ -41,12 +51,17 @@ _Modern protocol-side framework implemented based on NTQQ._
| Docs | [![Cloudflare.Pages](https://img.shields.io/badge/docs%20on-Cloudflare.Pages-blue)](https://napneko.pages.dev/) | [![Server.Other](https://img.shields.io/badge/docs%20on-Server.Other-green)](https://napcat.cyou/) | [![NapCat.Wiki](https://img.shields.io/badge/docs%20on-NapCat.Wiki-red)](https://www.napcat.wiki) |
|:-:|:-:|:-:|:-:|
| QQ Group | [![QQ Group#4](https://img.shields.io/badge/QQ%20Group%234-Join-blue)](https://qm.qq.com/q/CMmPbGw0jA) | [![QQ Group#3](https://img.shields.io/badge/QQ%20Group%233-Join-blue)](https://qm.qq.com/q/8zJMLjqy2Y) | [![QQ Group#2](https://img.shields.io/badge/QQ%20Group%232-Join-blue)](https://qm.qq.com/q/HaRcfrHpUk) | [![QQ Group#1](https://img.shields.io/badge/QQ%20Group%231-Join-blue)](https://qm.qq.com/q/I6LU87a0Yq) |
| QQ Group | [![QQ Group#4](https://img.shields.io/badge/QQ%20Group%234-Join-blue)](https://qm.qq.com/q/CMmPbGw0jA) | [![QQ Group#3](https://img.shields.io/badge/QQ%20Group%233-Join-blue)](https://qm.qq.com/q/8zJMLjqy2Y) | [![QQ Group#2](https://img.shields.io/badge/QQ%20Group%232-Join-blue)](https://qm.qq.com/q/CMmPbGw0jA) | [![QQ Group#1](https://img.shields.io/badge/QQ%20Group%231-Join-blue)](https://qm.qq.com/q/I6LU87a0Yq) |
|:-:|:-:|:-:|:-:|:-:|
| Telegram | [![Telegram](https://img.shields.io/badge/Telegram-MelodicMoonlight-blue)](https://t.me/MelodicMoonlight) |
| Telegram | [![Telegram](https://img.shields.io/badge/Telegram-napcatqq-blue)](https://t.me/napcatqq) |
|:-:|:-:|
| DeepWiki | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/NapNeko/NapCatQQ) |
|:-:|:-:|
> 请不要在其余社区提及本项目(包括其余协议端/相关应用端项目)引发争论如有建议到达官方交流群讨论或PR。
## Thanks
+ [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) 对本项目的大力支持 参考部分代码 已获授权
@@ -54,7 +69,9 @@ _Modern protocol-side framework implemented based on NTQQ._
+ [AstrBot](https://github.com/AstrBotDevs/AstrBot) 是完美适配本项目的LLM Bot框架 在此推荐一下
+ [MaiBot](https://github.com/MaiM-with-u/MaiBot) 一只赛博群友 麦麦 Bot框架 在此推荐一下
+ [qq-chat-exporter](https://github.com/shuakami/qq-chat-exporter/) 基于NapCat的消息导出工具 在此推荐一下
+ 不过最最重要的 还是需要感谢屏幕前的你哦~
---

View File

@@ -1,9 +1,9 @@
{
"name": "qq-chat",
"version": "9.9.19-34740",
"verHash": "f31348f2",
"linuxVersion": "3.2.17-34740",
"linuxVerHash": "5aa2d8d6",
"verHash": "cc326038",
"version": "9.9.21-39038",
"linuxVersion": "3.2.19-39038",
"linuxVerHash": "c773cdf7",
"private": true,
"description": "QQ",
"productName": "QQ",
@@ -17,7 +17,7 @@
"qd": "externals/devtools/cli/index.js"
},
"main": "./loadNapCat.js",
"buildVersion": "34740",
"buildVersion": "39038",
"isPureShell": true,
"isByteCodeShell": true,
"platform": "win32",

View File

@@ -4,7 +4,7 @@
"name": "NapCatQQ",
"slug": "NapCat.Framework",
"description": "高性能的 OneBot 11 协议实现",
"version": "4.8.105",
"version": "4.8.116",
"icon": "./logo.png",
"authors": [
{

View File

@@ -62,24 +62,39 @@ const NetworkDisplayCard = <T extends keyof NetworkType>({
<ButtonGroup
fullWidth
isDisabled={editing}
radius="full"
radius="sm"
size="sm"
variant="shadow"
variant="flat"
>
<Button color="warning" startContent={<FiEdit3 />} onPress={onEdit}>
<Button
color="warning"
startContent={<FiEdit3 size={16} />}
onPress={onEdit}
>
</Button>
<Button
color={debug ? 'success' : 'default'}
startContent={<CgDebug />}
color={debug ? 'secondary' : 'success'}
variant="flat"
startContent={
<CgDebug
style={{
width: '16px',
height: '16px',
minWidth: '16px',
minHeight: '16px'
}}
/>
}
onPress={handleEnableDebug}
>
{debug ? '关闭调试' : '开启调试'}
</Button>
<Button
color="primary"
startContent={<MdDeleteForever />}
className="bg-danger/20 text-danger hover:bg-danger/30 transition-colors"
variant="flat"
startContent={<MdDeleteForever size={16} />}
onPress={handleDelete}
>

View File

@@ -33,21 +33,6 @@ export default class WebUIManager {
return data.data
}
public static async changePasswordFromDefault(newToken: string) {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/auth/update_token',
{ newToken, fromDefault: true }
)
return data.data
}
public static async checkUsingDefaultToken() {
const { data } = await serverRequest.get<ServerResponse<boolean>>(
'/auth/check_using_default_token'
)
return data.data
}
public static async proxy<T>(url = '') {
const data = await serverRequest.get<ServerResponse<string>>(
'/base/proxy?url=' + encodeURIComponent(url)

View File

@@ -155,7 +155,7 @@ export default function AboutPage() {
shadow="sm"
isPressable
isExternal
href="https://t.me/MelodicMoonlight"
href="https://t.me/napcatqq"
>
<CardBody className="flex-row items-center gap-2">
<span className="p-2 rounded-small bg-primary-50 text-primary-500">

View File

@@ -1,6 +1,5 @@
import { Input } from '@heroui/input'
import { useLocalStorage } from '@uidotdev/usehooks'
import { useEffect, useState } from 'react'
import { Controller, useForm } from 'react-hook-form'
import toast from 'react-hot-toast'
import { useNavigate } from 'react-router-dom'
@@ -12,14 +11,12 @@ import SaveButtons from '@/components/button/save_buttons'
import WebUIManager from '@/controllers/webui_manager'
const ChangePasswordCard = () => {
const [isDefaultToken, setIsDefaultToken] = useState<boolean>(false)
const [isLoadingCheck, setIsLoadingCheck] = useState<boolean>(true)
const {
control,
handleSubmit: handleWebuiSubmit,
formState: { isSubmitting },
reset
formState: { isSubmitting, errors },
reset,
watch
} = useForm<{
oldToken: string
newToken: string
@@ -33,31 +30,13 @@ const ChangePasswordCard = () => {
const navigate = useNavigate()
const [_, setToken] = useLocalStorage(key.token, '')
// 检查是否使用默认密码
useEffect(() => {
const checkDefaultToken = async () => {
try {
const isDefault = await WebUIManager.checkUsingDefaultToken()
setIsDefaultToken(isDefault)
} catch (error) {
console.error('检查默认密码状态失败:', error)
} finally {
setIsLoadingCheck(false)
}
}
checkDefaultToken()
}, [])
// 监听旧密码的值
const oldTokenValue = watch('oldToken')
const onSubmit = handleWebuiSubmit(async (data) => {
try {
if (isDefaultToken) {
// 从默认密码更新
await WebUIManager.changePasswordFromDefault(data.newToken)
} else {
// 正常密码更新
await WebUIManager.changePassword(data.oldToken, data.newToken)
}
// 使用正常密码更新流程
await WebUIManager.changePassword(data.oldToken, data.newToken)
toast.success('修改成功')
setToken('')
@@ -69,53 +48,74 @@ const ChangePasswordCard = () => {
}
})
if (isLoadingCheck) {
return (
<>
<title> - NapCat WebUI</title>
<div className="flex justify-center items-center h-32">
<div className="text-center">...</div>
</div>
</>
)
}
return (
<>
<title> - NapCat WebUI</title>
{isDefaultToken && (
<div className="mb-4 p-3 bg-warning-50 border border-warning-200 rounded-lg">
<p className="text-warning-700 text-sm">
使
</p>
</div>
)}
{!isDefaultToken && (
<Controller
control={control}
name="oldToken"
render={({ field }) => (
<Input
{...field}
label="旧密码"
placeholder="请输入旧密码"
type="password"
/>
)}
/>
)}
<Controller
control={control}
name="oldToken"
rules={{
required: '旧密码不能为空',
validate: (value) => {
if (!value || value.trim().length === 0) {
return '旧密码不能为空'
}
return true
}
}}
render={({ field }) => (
<Input
{...field}
label="旧密码"
placeholder="请输入旧密码"
type="password"
isRequired
isInvalid={!!errors.oldToken}
errorMessage={errors.oldToken?.message}
/>
)}
/>
<Controller
control={control}
name="newToken"
rules={{
required: '新密码不能为空',
minLength: {
value: 6,
message: '新密码至少需要6个字符'
},
validate: (value) => {
if (!value || value.trim().length === 0) {
return '新密码不能为空'
}
if (value.trim().length !== value.length) {
return '新密码不能包含前后空格'
}
if (value === oldTokenValue) {
return '新密码不能与旧密码相同'
}
// 检查是否包含字母
if (!/[a-zA-Z]/.test(value)) {
return '新密码必须包含字母'
}
// 检查是否包含数字
if (!/[0-9]/.test(value)) {
return '新密码必须包含数字'
}
return true
}
}}
render={({ field }) => (
<Input
{...field}
label={isDefaultToken ? "设置新密码" : "新密码"}
placeholder={isDefaultToken ? "请设置一个安全的新密码" : "请输入新密码"}
label="新密码"
placeholder="至少6位包含字母和数字"
type="password"
isRequired
isInvalid={!!errors.newToken}
errorMessage={errors.newToken?.message}
/>
)}
/>

View File

@@ -1,52 +1,15 @@
import { Spinner } from '@heroui/spinner'
import { AnimatePresence, motion } from 'motion/react'
import { Suspense, useEffect } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { Suspense } from 'react'
import { Outlet, useLocation } from 'react-router-dom'
import useAuth from '@/hooks/auth'
import useDialog from '@/hooks/use-dialog'
import WebUIManager from '@/controllers/webui_manager'
import DefaultLayout from '@/layouts/default'
const CheckDefaultPassword = () => {
const { isAuth } = useAuth()
const dialog = useDialog()
const navigate = useNavigate()
const checkDefaultPassword = async () => {
const data = await WebUIManager.checkUsingDefaultToken()
if (data) {
dialog.confirm({
title: '修改默认密码',
content: '检测到当前密码为默认密码,为了您的安全,必须立即修改密码。',
confirmText: '前往修改',
onConfirm: () => {
navigate('/config?tab=token')
},
onCancel: () => {
navigate('/config?tab=token')
},
onClose() {
navigate('/config?tab=token')
},
})
}
}
useEffect(() => {
if (isAuth) {
checkDefaultPassword()
}
}, [isAuth])
return null
}
export default function IndexPage() {
const location = useLocation()
return (
<DefaultLayout>
<CheckDefaultPassword />
<Suspense
fallback={
<div className="flex justify-center px-10">

View File

@@ -92,42 +92,65 @@ export default function WebLoginPage() {
</CardHeader>
<CardBody className="flex gap-5 py-5 px-5 md:px-10">
<Input
isClearable
type="password"
classNames={{
label: 'text-black/50 dark:text-white/90',
input: [
'bg-transparent',
'text-black/90 dark:text-white/90',
'placeholder:text-default-700/50 dark:placeholder:text-white/60'
],
innerWrapper: 'bg-transparent',
inputWrapper: [
'shadow-xl',
'bg-default-100/70',
'dark:bg-default/60',
'backdrop-blur-xl',
'backdrop-saturate-200',
'hover:bg-default-0/70',
'dark:hover:bg-default/70',
'group-data-[focus=true]:bg-default-100/50',
'dark:group-data-[focus=true]:bg-default/60',
'!cursor-text'
]
<form
onSubmit={(e) => {
e.preventDefault()
onSubmit()
}}
isDisabled={isLoading}
label="Token"
placeholder="请输入token"
radius="lg"
size="lg"
startContent={
<IoKeyOutline className="text-black/50 mb-0.5 dark:text-white/90 text-slate-400 pointer-events-none flex-shrink-0" />
}
value={tokenValue}
onChange={(e) => setTokenValue(e.target.value)}
onClear={() => setTokenValue('')}
/>
>
{/* 隐藏的用户名字段,帮助浏览器识别登录表单 */}
<input
type="text"
name="username"
value="napcat-webui"
autoComplete="username"
className="absolute -left-[9999px] opacity-0 pointer-events-none"
readOnly
tabIndex={-1}
aria-label="Username"
/>
<Input
isClearable
type="password"
name="password"
autoComplete="current-password"
classNames={{
label: 'text-black/50 dark:text-white/90',
input: [
'bg-transparent',
'text-black/90 dark:text-white/90',
'placeholder:text-default-700/50 dark:placeholder:text-white/60'
],
innerWrapper: 'bg-transparent',
inputWrapper: [
'shadow-xl',
'bg-default-100/70',
'dark:bg-default/60',
'backdrop-blur-xl',
'backdrop-saturate-200',
'hover:bg-default-0/70',
'dark:hover:bg-default/70',
'group-data-[focus=true]:bg-default-100/50',
'dark:group-data-[focus=true]:bg-default/60',
'!cursor-text'
]
}}
isDisabled={isLoading}
label="Token"
placeholder="请输入token"
radius="lg"
size="lg"
startContent={
<IoKeyOutline className="text-black/50 mb-0.5 dark:text-white/90 text-slate-400 pointer-events-none flex-shrink-0" />
}
value={tokenValue}
onChange={(e) => setTokenValue(e.target.value)}
onClear={() => setTokenValue('')}
/>
</form>
<div className="text-center text-small text-default-600 dark:text-default-400 px-2">
💡 NapCat
</div>
<Button
className="mx-10 mt-10 text-lg py-7"
color="primary"

View File

@@ -2,7 +2,7 @@
"name": "napcat",
"private": true,
"type": "module",
"version": "4.8.105",
"version": "4.8.116",
"scripts": {
"build:universal": "npm run build:webui && vite build --mode universal || exit 1",
"build:framework": "npm run build:webui && vite build --mode framework || exit 1",

View File

@@ -1 +1 @@
export const napCatVersion = '4.8.105';
export const napCatVersion = '4.8.116';

View File

@@ -4,9 +4,10 @@ import { NapCatCore } from '@/core';
import { NapCatOneBot11Adapter, OB11Return } from '@/onebot';
import { NetworkAdapterConfig } from '../config/config';
import { TSchema } from '@sinclair/typebox';
import { StreamPacket, StreamPacketBasic, StreamStatus } from './stream/StreamBasic';
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 {
status,
retcode,
@@ -14,28 +15,32 @@ export class OB11Response {
message,
wording: message,
echo,
stream: useStream ? 'stream-action' : 'normal-action'
};
}
static res<T>(data: T, status: string, retcode: number, message: string = ''): OB11Return<T> {
return this.createResponse(data, status, retcode, message);
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, echo, useStream);
}
static ok<T>(data: T, echo: unknown = null): OB11Return<T> {
return this.createResponse(data, 'ok', 0, '', echo);
static ok<T>(data: T, echo: unknown = null, useStream: boolean = false): OB11Return<T> {
return this.createResponse(data, 'ok', 0, '', echo, useStream);
}
static error(err: string, retcode: number, echo: unknown = null): OB11Return<null> {
return this.createResponse(null, 'failed', retcode, err, echo);
static error(err: string, retcode: number, echo: unknown = null, useStream: boolean = false): OB11Return<null | StreamPacketBasic> {
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> {
actionName: typeof ActionName[keyof typeof ActionName] = ActionName.Unknown;
core: NapCatCore;
private validate?: ValidateFunction<unknown> = undefined;
payloadSchema?: TSchema = undefined;
obContext: NapCatOneBot11Adapter;
useStream: boolean = false;
constructor(obContext: NapCatOneBot11Adapter, core: NapCatCore) {
this.obContext = obContext;
@@ -57,33 +62,33 @@ export abstract class OneBotAction<PayloadType, ReturnDataType> {
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);
if (!result.valid) {
return OB11Response.error(result.message, 400);
}
try {
const resData = await this._handle(payload, adaptername, config);
return OB11Response.ok(resData);
const resData = await this._handle(payload, adaptername, config, req);
return OB11Response.ok(resData, echo, this.useStream);
} catch (e: unknown) {
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);
if (!result.valid) {
return OB11Response.error(result.message, 1400, echo);
return OB11Response.error(result.message, 1400, echo, this.useStream);
}
try {
const resData = await this._handle(payload, adaptername, config);
return OB11Response.ok(resData, echo);
const resData = await this._handle(payload, adaptername, config, req);
return OB11Response.ok(resData, echo, this.useStream);
} catch (e: unknown) {
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>;
}

View File

@@ -41,12 +41,12 @@ export class GetFileBase extends OneBotAction<GetFilePayload, GetFileResponse> {
let url = '';
if (mixElement?.picElement && rawMessage) {
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 ?? '';
}
if (mixElement?.videoElement && rawMessage) {
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 ?? '';
}
const res: GetFileResponse = {

View File

@@ -130,7 +130,7 @@ export class GoCQHTTPGetForwardMsgAction extends OneBotAction<Payload, {
throw new Error('消息不存在或已过期');
}
// 6. 解析消息内容
const resMsg = (await this.obContext.apis.MsgApi.parseMessageV2(singleMsg))?.arrayMsg;
const resMsg = (await this.obContext.apis.MsgApi.parseMessage(singleMsg, 'array', true));
const forwardContent = (resMsg?.message?.[0] as OB11MessageForward)?.data?.content;
if (forwardContent) {

View File

@@ -130,10 +130,18 @@ import { DoGroupAlbumComment } from './extends/DoGroupAlbumComment';
import { GetGroupAlbumMediaList } from './extends/GetGroupAlbumMediaList';
import { SetGroupAlbumMediaLike } from './extends/SetGroupAlbumMediaLike';
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) {
const actionHandlers = [
new CleanStreamTempFile(obContext, core),
new DownloadFileStream(obContext, core),
new TestDownloadStream(obContext, core),
new UploadFileStream(obContext, core),
new DelGroupAlbumMedia(obContext, core),
new SetGroupAlbumMediaLike(obContext, core),
new DoGroupAlbumComment(obContext, core),

View File

@@ -10,6 +10,14 @@ export interface InvalidCheckResult {
}
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',
SetGroupAlbumMediaLike: 'set_group_album_media_like',
DoGroupAlbumComment: 'do_group_album_comment',

View 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}`);
}
}
}

View 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}`);
}
}
}

View File

@@ -0,0 +1,3 @@
# Stream-Api
## 流式接口

View 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>>;
}

View 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'
};
}
}

View 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`);
}
}
}

View 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())

View File

@@ -116,6 +116,17 @@ export class OneBotGroupApi {
const member = await this.core.apis.GroupApi.getGroupMember(msg.peerUid, msg.senderUin);
if (member && member.cardName !== msg.sendMemberName) {
const newCardName = msg.sendMemberName ?? '';
// 防止误触如果消息包含转发元素且新名片为空不触发事件转发消息的sendMemberName不可靠
const hasForwardElement = msg.elements.some(e => e.multiForwardMsgElement || e.arkElement);
if (newCardName === '' && member.cardName !== '' && hasForwardElement) {
this.core.context.logger.logDebug('忽略转发消息的不可靠群名片变更事件', {
peerUid: msg.peerUid,
senderUin: msg.senderUin,
oldCard: member.cardName,
newCard: newCardName
});
return undefined;
}
const event = new OB11GroupCardEvent(this.core, parseInt(msg.peerUid), parseInt(msg.senderUin), newCardName, member.cardName);
member.cardName = newCardName;
return event;

View File

@@ -1124,10 +1124,13 @@ export class OneBotMsgApi {
if (ignoreTypes.includes(sendMsg.type)) {
continue;
}
const converter = this.ob11ToRawConverters[sendMsg.type] as (
const converter = this.ob11ToRawConverters[sendMsg.type] as ((
sendMsg: Extract<OB11MessageData, { type: OB11MessageData['type'] }>,
context: SendMessageContext,
) => Promise<SendMessageElement | undefined>;
) => Promise<SendMessageElement | undefined>) | undefined;
if (converter == undefined) {
throw new Error('未知的消息类型:' + sendMsg.type);
}
const callResult = converter(
sendMsg,
{ peer, deleteAfterSentFiles },

View File

@@ -13,8 +13,11 @@ import {
SendStatusType,
NTMsgType,
MessageElement,
ElementType,
NTMsgAtType,
} from '@/core';
import { OB11ConfigLoader } from '@/onebot/config';
import { pendingTokenToSend } from '@/webui/index';
import {
OB11HttpClientAdapter,
OB11WebSocketClientAdapter,
@@ -62,8 +65,8 @@ export class NapCatOneBot11Adapter {
networkManager: OB11NetworkManager;
actions: ActionMap;
private readonly bootTime = Date.now() / 1000;
recallEventCache = new Map<string, any>();
constructor(core: NapCatCore, context: InstanceContext, pathWrapper: NapCatPathWrapper) {
recallEventCache = new Map<string, NodeJS.Timeout>();
constructor (core: NapCatCore, context: InstanceContext, pathWrapper: NapCatPathWrapper) {
this.core = core;
this.context = context;
this.configLoader = new OB11ConfigLoader(core, pathWrapper.configPath, OneBotConfigSchema);
@@ -77,7 +80,7 @@ export class NapCatOneBot11Adapter {
this.actions = createActionMap(this, core);
this.networkManager = new OB11NetworkManager();
}
async creatOneBotLog(ob11Config: OneBotConfig) {
async creatOneBotLog (ob11Config: OneBotConfig) {
let log = '[network] 配置加载\n';
for (const key of ob11Config.network.httpServers) {
log += `HTTP服务: ${key.host}:${key.port}, : ${key.enable ? '已启动' : '未启动'}\n`;
@@ -96,14 +99,44 @@ export class NapCatOneBot11Adapter {
}
return log;
}
async InitOneBot() {
async InitOneBot () {
const selfInfo = this.core.selfInfo;
const ob11Config = this.configLoader.configData;
this.core.apis.UserApi.getUserDetailInfo(selfInfo.uid, false)
.then((user) => {
.then(async (user) => {
selfInfo.nick = user.nick;
this.context.logger.setLogSelfInfo(selfInfo);
// 检查是否有待发送的token
if (pendingTokenToSend) {
this.context.logger.log('[NapCat] [OneBot] 🔐 检测到待发送的WebUI Token开始发送');
try {
await this.core.apis.MsgApi.sendMsg(
{ chatType: ChatType.KCHATTYPEC2C, peerUid: selfInfo.uid, guildId: '' },
[{
elementType: ElementType.TEXT,
elementId: '',
textElement: {
content:
'[NapCat] 温馨提示:\n'+
'WebUI密码为默认密码已进行强制修改\n'+
'新密码: ' +pendingTokenToSend,
atType: NTMsgAtType.ATTYPEUNKNOWN,
atUid: '',
atTinyId: '',
atNtUid: '',
}
}],
5000
);
this.context.logger.log('[NapCat] [OneBot] ✅ WebUI Token 消息发送成功');
} catch (error) {
this.context.logger.logError('[NapCat] [OneBot] ❌ WebUI Token 消息发送失败:', error);
}
}
WebUiDataRuntime.getQQLoginCallback()(true);
})
.catch(e => this.context.logger.logError(e));
@@ -117,7 +150,7 @@ export class NapCatOneBot11Adapter {
// new OB11PluginAdapter('myPlugin', this.core, this,this.actions)
// );
if (existsSync(this.context.pathWrapper.pluginPath)) {
this.context.logger.log(`[Plugins] 插件目录存在,开始加载插件`);
this.context.logger.log('[Plugins] 插件目录存在,开始加载插件');
this.networkManager.registerAdapter(
new OB11PluginMangerAdapter('plugin_manager', this.core, this, this.actions)
);
@@ -187,7 +220,7 @@ export class NapCatOneBot11Adapter {
}
private async reloadNetwork(prev: OneBotConfig, now: OneBotConfig): Promise<void> {
private async reloadNetwork (prev: OneBotConfig, now: OneBotConfig): Promise<void> {
const prevLog = await this.creatOneBotLog(prev);
const newLog = await this.creatOneBotLog(now);
this.context.logger.log(`[Notice] [OneBot11] 配置变更前:\n${prevLog}`);
@@ -200,7 +233,7 @@ export class NapCatOneBot11Adapter {
await this.handleConfigChange(prev.network.websocketClients, now.network.websocketClients, OB11WebSocketClientAdapter);
}
private async handleConfigChange<CT extends NetworkAdapterConfig>(
private async handleConfigChange<CT extends NetworkAdapterConfig> (
prevConfig: NetworkAdapterConfig[],
nowConfig: NetworkAdapterConfig[],
adapterClass: new (
@@ -232,7 +265,7 @@ export class NapCatOneBot11Adapter {
}
}
private initMsgListener() {
private initMsgListener () {
const msgListener = new NodeIKernelMsgListener();
msgListener.onRecvSysMsg = (msg) => {
this.apis.MsgApi.parseSysMessage(msg)
@@ -346,7 +379,7 @@ export class NapCatOneBot11Adapter {
this.context.session.getMsgService().addKernelMsgListener(proxiedListenerOf(msgListener, this.context.logger));
}
private initBuddyListener() {
private initBuddyListener () {
const buddyListener = new NodeIKernelBuddyListener();
buddyListener.onBuddyReqChange = async (reqs) => {
@@ -377,7 +410,7 @@ export class NapCatOneBot11Adapter {
.addKernelBuddyListener(proxiedListenerOf(buddyListener, this.context.logger));
}
private initGroupListener() {
private initGroupListener () {
const groupListener = new NodeIKernelGroupListener();
groupListener.onGroupNotifiesUpdated = async (_, notifies) => {
@@ -470,7 +503,7 @@ export class NapCatOneBot11Adapter {
.addKernelGroupListener(proxiedListenerOf(groupListener, this.context.logger));
}
private async emitMsg(message: RawMessage) {
private async emitMsg (message: RawMessage) {
const network = await this.networkManager.getAllConfig();
this.context.logger.logDebug('收到新消息 RawMessage', message);
await Promise.allSettled([
@@ -479,7 +512,7 @@ export class NapCatOneBot11Adapter {
]);
}
private async handleMsg(message: RawMessage, network: Array<NetworkAdapterConfig>) {
private async handleMsg (message: RawMessage, network: Array<NetworkAdapterConfig>) {
// 过滤无效消息
if (message.msgType === NTMsgType.KMSGTYPENULL) {
return;
@@ -500,17 +533,17 @@ export class NapCatOneBot11Adapter {
}
}
private isSelfMessage(ob11Msg: {
stringMsg: OB11Message;
arrayMsg: OB11Message;
private isSelfMessage (ob11Msg: {
stringMsg: OB11Message
arrayMsg: OB11Message
}): boolean {
return ob11Msg.stringMsg.user_id.toString() == this.core.selfInfo.uin ||
ob11Msg.arrayMsg.user_id.toString() == this.core.selfInfo.uin;
}
private createMsgMap(network: Array<NetworkAdapterConfig>, ob11Msg: {
stringMsg: OB11Message;
arrayMsg: OB11Message;
private createMsgMap (network: Array<NetworkAdapterConfig>, ob11Msg: {
stringMsg: OB11Message
arrayMsg: OB11Message
}, isSelfMsg: boolean, message: RawMessage): Map<string, OB11Message> {
const msgMap: Map<string, OB11Message> = new Map();
network.filter(e => e.enable).forEach(e => {
@@ -528,7 +561,7 @@ export class NapCatOneBot11Adapter {
return msgMap;
}
private handleDebugNetwork(network: Array<NetworkAdapterConfig>, msgMap: Map<string, OB11Message>, message: RawMessage) {
private handleDebugNetwork (network: Array<NetworkAdapterConfig>, msgMap: Map<string, OB11Message>, message: RawMessage) {
const debugNetwork = network.filter(e => e.enable && e.debug);
if (debugNetwork.length > 0) {
debugNetwork.forEach(adapter => {
@@ -542,7 +575,7 @@ export class NapCatOneBot11Adapter {
}
}
private handleNotReportSelfNetwork(network: Array<NetworkAdapterConfig>, msgMap: Map<string, OB11Message>, isSelfMsg: boolean) {
private handleNotReportSelfNetwork (network: Array<NetworkAdapterConfig>, msgMap: Map<string, OB11Message>, isSelfMsg: boolean) {
if (isSelfMsg) {
const notReportSelfNetwork = network.filter(e => e.enable && (('reportSelfMessage' in e && !e.reportSelfMessage) || !('reportSelfMessage' in e)));
notReportSelfNetwork.forEach(adapter => {
@@ -551,7 +584,7 @@ export class NapCatOneBot11Adapter {
}
}
private async handleGroupEvent(message: RawMessage) {
private async handleGroupEvent (message: RawMessage) {
try {
// 群名片修改事件解析 任何都该判断
if (message.senderUin && message.senderUin !== '0') {
@@ -584,7 +617,7 @@ export class NapCatOneBot11Adapter {
}
}
private async handlePrivateMsgEvent(message: RawMessage) {
private async handlePrivateMsgEvent (message: RawMessage) {
try {
if (message.msgType === NTMsgType.KMSGTYPEGRAYTIPS) {
// 灰条为单元素消息
@@ -602,7 +635,7 @@ export class NapCatOneBot11Adapter {
}
}
private async emitRecallMsg(message: RawMessage, element: MessageElement) {
private async emitRecallMsg (message: RawMessage, element: MessageElement) {
const peer: Peer = { chatType: message.chatType, peerUid: message.peerUid, guildId: '' };
const oriMessageId = MessageUnique.getShortIdByMsgId(message.msgId) ?? MessageUnique.createUniqueMsgId(peer, message.msgId);
if (message.chatType == ChatType.KCHATTYPEC2C) {
@@ -613,7 +646,7 @@ export class NapCatOneBot11Adapter {
return;
}
private async emitFriendRecallMsg(message: RawMessage, oriMessageId: number, element: MessageElement) {
private async emitFriendRecallMsg (message: RawMessage, oriMessageId: number, element: MessageElement) {
const operatorUid = element.grayTipElement?.revokeElement.operatorUid;
if (!operatorUid) return undefined;
return new OB11FriendRecallNoticeEvent(
@@ -623,7 +656,7 @@ export class NapCatOneBot11Adapter {
);
}
private async emitGroupRecallMsg(message: RawMessage, oriMessageId: number, element: MessageElement) {
private async emitGroupRecallMsg (message: RawMessage, oriMessageId: number, element: MessageElement) {
const operatorUid = element.grayTipElement?.revokeElement.operatorUid;
if (!operatorUid) return undefined;
const operatorId = await this.core.apis.UserApi.getUinByUidV2(operatorUid);

View File

@@ -23,7 +23,7 @@ export abstract class IOB11NetworkAdapter<CT extends NetworkAdapterConfig> {
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>;

View File

@@ -16,7 +16,7 @@ export class OB11HttpClientAdapter extends IOB11NetworkAdapter<HttpClientConfig>
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));
}

View File

@@ -9,7 +9,7 @@ export class OB11HttpSSEServerAdapter extends OB11HttpServerAdapter {
if (req.path === '/_events') {
this.createSseSupport(req, res);
} else {
super.httpApiRequest(req, res);
super.httpApiRequest(req, res, true);
}
}
@@ -23,11 +23,22 @@ export class OB11HttpSSEServerAdapter extends OB11HttpServerAdapter {
req.on('close', () => {
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) => {
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);
}
}

View File

@@ -20,7 +20,7 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
}
// 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
}
@@ -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;
if (req.method == 'get') {
payload = req.query;
@@ -117,17 +117,35 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
return res.json(hello);
}
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
const action = this.actions.get(actionName as any);
if (action) {
const useStream = action.useStream;
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);
} 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 {
return res.json(OB11Response.error('不支持的Api ' + actionName, 200));
return res.json(OB11Response.error('不支持的Api ' + actionName, 200, real_echo));
}
}

View File

@@ -20,9 +20,9 @@ export class OB11NetworkManager {
}
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) {
return adapter.onEvent(event);
return await adapter.onEvent(event);
}
}));
}
@@ -32,19 +32,19 @@ export class OB11NetworkManager {
}
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);
if (adapter && adapter.isEnable) {
return adapter.onEvent(event);
return await adapter.onEvent(event);
}
}));
}
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);
if (adapter && adapter.isEnable) {
return adapter.onEvent(event);
return await adapter.onEvent(event);
}
}));
}

View File

@@ -251,14 +251,20 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`);
}
onEvent<T extends OB11EmitEventContent>(event: T) {
async onEvent<T extends OB11EmitEventContent>(event: T) {
if (!this.isEnable) {
return;
}
// 遍历所有已加载的插件,调用它们的事件处理方法
for (const [, plugin] of this.loadedPlugins) {
this.callPluginEventHandler(plugin, event);
try {
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);
}
}

View File

@@ -251,7 +251,7 @@ export class OB11PluginAdapter extends IOB11NetworkAdapter<PluginConfig> {
this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`);
}
onEvent<T extends OB11EmitEventContent>(event: T) {
async onEvent<T extends OB11EmitEventContent>(event: T) {
if (!this.isEnable) {
return;
}

View File

@@ -19,7 +19,7 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
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) {
this.connection.send(JSON.stringify(event));
}
@@ -62,10 +62,15 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
}
}
private checkStateAndReply<T>(data: T) {
if (this.connection && this.connection.readyState === WebSocket.OPEN) {
this.connection.send(JSON.stringify(data));
}
private async checkStateAndReply<T>(data: T) {
return new Promise<void>((resolve, reject) => {
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() {
@@ -92,7 +97,7 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
});
this.connection.on('open', () => {
try {
this.connectEvent(this.core);
this.connectEvent(this.core).catch(e => this.logger.logError('[OneBot] [WebSocket Client] 发送连接生命周期失败', e));
} catch (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 {
this.checkStateAndReply<unknown>(new OB11LifeCycleEvent(core, LifeCycleSubType.CONNECT));
await this.checkStateAndReply<unknown>(new OB11LifeCycleEvent(core, LifeCycleSubType.CONNECT));
} catch (e) {
this.logger.logError('[OneBot] [WebSocket Client] 发送生命周期失败', e);
}
@@ -140,7 +145,7 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
echo = receiveData.echo;
this.logger.logDebug('[OneBot] [WebSocket Client] 收到正向Websocket消息', receiveData);
} catch {
this.checkStateAndReply<unknown>(OB11Response.error('json解析失败,请检查数据格式', 1400, echo));
await this.checkStateAndReply<unknown>(OB11Response.error('json解析失败,请检查数据格式', 1400, echo));
return;
}
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);
if (!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;
}
const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config);
this.checkStateAndReply<unknown>({ ...retdata });
const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config, {
send: async (data: object) => {
await this.checkStateAndReply<unknown>({ ...OB11Response.ok(data, echo ?? '', true) });
}
});
await this.checkStateAndReply<unknown>({ ...retdata });
}
async reload(newConfig: WebsocketClientConfig) {
const wasEnabled = this.isEnable;

View File

@@ -83,17 +83,25 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
}
connectEvent(core: NapCatCore, wsClient: WebSocket) {
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) {
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.wsClientWithEvent.forEach((wsClient) => {
wsClient.send(JSON.stringify(event));
let promises = this.wsClientWithEvent.map((wsClient) => {
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;
}
private checkStateAndReply<T>(data: T, wsClient: WebSocket) {
if (wsClient.readyState === WebSocket.OPEN) {
wsClient.send(JSON.stringify(data));
}
private async checkStateAndReply<T>(data: T, wsClient: WebSocket) {
return await new Promise<void>((resolve, reject) => {
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) {
@@ -175,7 +188,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
echo = receiveData.echo;
//this.logger.logDebug('收到正向Websocket消息', receiveData);
} catch {
this.checkStateAndReply<unknown>(OB11Response.error('json解析失败,请检查数据格式', 1400, echo), wsClient);
await this.checkStateAndReply<unknown>(OB11Response.error('json解析失败,请检查数据格式', 1400, echo), wsClient);
return;
}
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);
if (!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;
}
const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config);
this.checkStateAndReply<unknown>({ ...retdata }, wsClient);
const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config, {
send: async (data: object) => {
await this.checkStateAndReply<unknown>({ ...OB11Response.ok(data, echo ?? '', true) }, wsClient);
}
});
await this.checkStateAndReply<unknown>({ ...retdata }, wsClient);
}
async reload(newConfig: WebsocketServerConfig) {

View File

@@ -46,6 +46,7 @@ export interface OB11Return<DataType> {
message: string;
echo?: unknown; // ws调用api才有此字段
wording?: string; // go-cqhttp字段错误信息
stream?: 'stream-action' | 'normal-action' ; // 流式返回标记
}
// 消息数据类型枚举

View File

@@ -203,7 +203,13 @@ export class WindowsPtyAgent {
}
private _getWindowsBuildNumber(): number {
const osVersion = (/(\d+)\.(\d+)\.(\d+)/g).exec(os.release());
const release = os.release();
// Limit input length to prevent potential DoS attacks
if (release.length > 50) {
return 0;
}
// Use non-global regex with more specific pattern to prevent backtracking
const osVersion = /^(\d{1,5})\.(\d{1,5})\.(\d{1,10})/.exec(release);
let buildNumber: number = 0;
if (osVersion && osVersion.length === 4) {
buildNumber = parseInt(osVersion[3]!);

View File

@@ -4,13 +4,14 @@
import express from 'express';
import { createServer } from 'http';
import { randomUUID } from 'node:crypto'
import { createServer as createHttpsServer } from 'https';
import { LogWrapper } from '@/common/log';
import { NapCatPathWrapper } from '@/common/path';
import { WebUiConfigWrapper } from '@webapi/helper/config';
import { ALLRouter } from '@webapi/router';
import { cors } from '@webapi/middleware/cors';
import { createUrl } from '@webapi/utils/url';
import { createUrl, getRandomToken } from '@webapi/utils/url';
import { sendError } from '@webapi/utils/response';
import { join } from 'node:path';
import { terminalManager } from '@webapi/terminal/terminal_manager';
@@ -30,17 +31,42 @@ const MAX_PORT_TRY = 100;
import * as net from 'node:net';
import { WebUiDataRuntime } from './src/helper/Data';
import { existsSync, readFileSync } from 'node:fs';
export let webUiRuntimePort = 6099;
export async function InitPort(parsedConfig: WebUiConfigType): Promise<[string, number, string]> {
// 全局变量存储需要在QQ登录成功后发送的新token
export let pendingTokenToSend: string | null = null;
/**
* 存储WebUI启动时的初始token用于鉴权
* - 无论是否在运行时修改密码都应该使用此token进行鉴权
* - 运行时手动修改的密码将会在下次napcat重启后生效
* - 如果需要在运行时修改密码并立即生效,则需要在前端调用路由进行修改
*/
let initialWebUiToken: string = '';
export function setInitialWebUiToken(token: string) {
initialWebUiToken = token;
}
export function getInitialWebUiToken(): string {
return initialWebUiToken;
}
export function setPendingTokenToSend(token: string | null) {
pendingTokenToSend = token;
}
export async function InitPort(parsedConfig: WebUiConfigType): Promise<[string, number,string]> {
try {
await tryUseHost(parsedConfig.host);
const port = await tryUsePort(parsedConfig.port, parsedConfig.host);
return [parsedConfig.host, port, parsedConfig.token];
} catch (error) {
console.log('host或port不可用', error);
return ['', 0, ''];
return ['', 0, randomUUID()];
}
}
async function checkCertificates(logger: LogWrapper): Promise<{ key: string, cert: string } | null> {
try {
const certPath = join(webUiPathWrapper.configPath, 'cert.pem');
@@ -61,14 +87,34 @@ async function checkCertificates(logger: LogWrapper): Promise<{ key: string, cer
export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapper) {
webUiPathWrapper = pathWrapper;
WebUiConfig = new WebUiConfigWrapper();
const config = await WebUiConfig.GetWebUIConfig();
let config = await WebUiConfig.GetWebUIConfig();
// 检查并更新默认密码 - 最高优先级
if (config.token === 'napcat' || !config.token) {
const randomToken = getRandomToken(8);
await WebUiConfig.UpdateWebUIConfig({ token: randomToken });
logger.log(`[NapCat] [WebUi] 🔐 检测到默认密码,已自动更新为安全密码`);
// 存储token到全局变量等待QQ登录成功后发送
setPendingTokenToSend(randomToken);
logger.log(`[NapCat] [WebUi] 📤 新密码将在QQ登录成功后发送给用户`);
// 重新获取更新后的配置
config = await WebUiConfig.GetWebUIConfig();
} else {
logger.log(`[NapCat] [WebUi] ✅ 当前使用安全密码`);
}
// 存储启动时的初始token用于鉴权
setInitialWebUiToken(config.token);
logger.log(`[NapCat] [WebUi] 🔑 已缓存启动时的token用于鉴权运行时手动修改配置文件密码将不会生效`);
// 检查是否禁用WebUI
if (config.disableWebUI) {
logger.log('[NapCat] [WebUi] WebUI is disabled by configuration.');
return;
}
const [host, port, token] = await InitPort(config);
webUiRuntimePort = port;
if (port == 0) {
@@ -169,14 +215,18 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp
// ------------启动服务------------
server.listen(port, host, async () => {
// 启动后打印出相关地址
let searchParams = { token: token };
logger.log(`[NapCat] [WebUi] 🔑 token=${token}`);
logger.log(
`[NapCat] [WebUi] WebUi User Panel Url: ${createUrl('127.0.0.1', port.toString(), '/webui', searchParams)}`
);
if (host !== '') {
logger.log(
`[NapCat] [WebUi] WebUi User Panel Url: ${createUrl(host, port.toString(), '/webui', searchParams)}`
);
}
});
// ------------Over------------
}

View File

@@ -1,21 +1,12 @@
import { RequestHandler } from 'express';
import { WebUiConfig } from '@/webui';
import { WebUiConfig, getInitialWebUiToken, setInitialWebUiToken } from '@/webui';
import { AuthHelper } from '@webapi/helper/SignToken';
import { WebUiDataRuntime } from '@webapi/helper/Data';
import { sendSuccess, sendError } from '@webapi/utils/response';
import { isEmpty } from '@webapi/utils/check';
// 检查是否使用默认Token
export const CheckDefaultTokenHandler: RequestHandler = async (_, res) => {
const webuiToken = await WebUiConfig.GetWebUIConfig();
if (webuiToken.defaultToken) {
return sendSuccess(res, true);
}
return sendSuccess(res, false);
};
// 登录
export const LoginHandler: RequestHandler = async (req, res) => {
// 获取WebUI配置
@@ -33,8 +24,13 @@ export const LoginHandler: RequestHandler = async (req, res) => {
if (!WebUiDataRuntime.checkLoginRate(clientIP, WebUiConfigData.loginRate)) {
return sendError(res, 'login rate limit');
}
//验证config.token hash是否等于token hash
if (!AuthHelper.comparePasswordHash(WebUiConfigData.token, hash)) {
// 使用启动时缓存的token进行验证而不是动态读取配置文件
const initialToken = getInitialWebUiToken();
if (!initialToken) {
return sendError(res, 'Server token not initialized');
}
//验证初始token hash是否等于提交的token hash
if (!AuthHelper.comparePasswordHash(initialToken, hash)) {
return sendError(res, 'token is invalid');
}
@@ -63,8 +59,6 @@ export const LogoutHandler: RequestHandler = async (req, res) => {
// 检查登录状态
export const checkHandler: RequestHandler = async (req, res) => {
// 获取WebUI配置
const WebUiConfigData = await WebUiConfig.GetWebUIConfig();
// 获取请求头中的Authorization
const authorization = req.headers.authorization;
// 检查凭证
@@ -79,8 +73,13 @@ export const checkHandler: RequestHandler = async (req, res) => {
return sendError(res, 'Token has been revoked');
}
// 使用启动时缓存的token进行验证
const initialToken = getInitialWebUiToken();
if (!initialToken) {
return sendError(res, 'Server token not initialized');
}
// 验证凭证是否在一小时内有效
const valid = AuthHelper.validateCredentialWithinOneHour(WebUiConfigData.token, Credential);
const valid = AuthHelper.validateCredentialWithinOneHour(initialToken, Credential);
// 返回成功信息
if (valid) return sendSuccess(res, null);
// 返回错误信息
@@ -93,16 +92,36 @@ export const checkHandler: RequestHandler = async (req, res) => {
// 修改密码token
export const UpdateTokenHandler: RequestHandler = async (req, res) => {
const { oldToken, newToken, fromDefault } = req.body;
const { oldToken, newToken } = req.body;
const authorization = req.headers.authorization;
if (isEmpty(newToken)) {
return sendError(res, 'newToken is empty');
}
// 如果不是从默认密码更新,则需要验证旧密码
if (!fromDefault && isEmpty(oldToken)) {
return sendError(res, 'oldToken is required when not updating from default password');
// 强制要求旧密码
if (isEmpty(oldToken)) {
return sendError(res, 'oldToken is required');
}
// 检查新旧密码是否相同
if (oldToken === newToken) {
return sendError(res, '新密码不能与旧密码相同');
}
// 检查新密码强度
if (newToken.length < 6) {
return sendError(res, '新密码至少需要6个字符');
}
// 检查是否包含字母
if (!/[a-zA-Z]/.test(newToken)) {
return sendError(res, '新密码必须包含字母');
}
// 检查是否包含数字
if (!/[0-9]/.test(newToken)) {
return sendError(res, '新密码必须包含数字');
}
try {
@@ -113,17 +132,18 @@ export const UpdateTokenHandler: RequestHandler = async (req, res) => {
AuthHelper.revokeCredential(Credential);
}
if (fromDefault) {
// 从默认密码更新,直接设置新密码
const currentConfig = await WebUiConfig.GetWebUIConfig();
if (!currentConfig.defaultToken) {
return sendError(res, 'Current password is not default password');
}
await WebUiConfig.UpdateWebUIConfig({ token: newToken, defaultToken: false });
} else {
// 正常的密码更新流程
await WebUiConfig.UpdateToken(oldToken, newToken);
// 使用启动时缓存的token进行验证
const initialToken = getInitialWebUiToken();
if (!initialToken) {
return sendError(res, 'Server token not initialized');
}
if (initialToken !== oldToken) {
return sendError(res, '旧 token 不匹配');
}
// 直接更新配置文件中的token不需要通过WebUiConfig.UpdateToken方法
await WebUiConfig.UpdateWebUIConfig({ token: newToken });
// 更新内存中的缓存token使新密码立即生效
setInitialWebUiToken(newToken);
return sendSuccess(res, 'Token updated successfully');
} catch (e: any) {

View File

@@ -9,10 +9,18 @@ import { PassThrough } from 'stream';
import multer from 'multer';
import webUIFontUploader from '../uploader/webui_font';
import diskUploader from '../uploader/disk';
import { WebUiConfig } from '@/webui';
import { WebUiConfig, getInitialWebUiToken, webUiPathWrapper } from '@/webui';
const isWindows = os.platform() === 'win32';
// 安全地从查询参数中提取字符串值,防止类型混淆
const getQueryStringParam = (param: any): string => {
if (Array.isArray(param)) {
return String(param[0] || '');
}
return String(param || '');
};
// 获取系统根目录列表Windows返回盘符列表其他系统返回['/']
const getRootDirs = async (): Promise<string[]> => {
if (!isWindows) return ['/'];
@@ -32,14 +40,68 @@ const getRootDirs = async (): Promise<string[]> => {
return drives.length > 0 ? drives : ['C:'];
};
// 规范化路径
// 规范化路径并进行安全验证
const normalizePath = (inputPath: string): string => {
if (!inputPath) return isWindows ? 'C:\\' : '/';
// 如果是Windows且输入为纯盘符(可能带或不带斜杠),统一返回 "X:\"
if (isWindows && /^[A-Z]:[\\/]*$/i.test(inputPath)) {
return inputPath.slice(0, 2) + '\\';
if (!inputPath) {
// 对于空路径,Windows返回用户主目录,其他系统返回根目录
return isWindows ? process.env['USERPROFILE'] || 'C:\\' : '/';
}
return path.normalize(inputPath);
// 对输入路径进行清理,移除潜在的危险字符
const cleanedPath = inputPath.replace(/[\x00-\x1f\x7f]/g, ''); // 移除控制字符
// 如果是Windows且输入为纯盘符可能带或不带斜杠统一返回 "X:\"
if (isWindows && /^[A-Z]:[\\/]*$/i.test(cleanedPath)) {
return cleanedPath.slice(0, 2) + '\\';
}
// 安全验证:检查是否包含危险的路径遍历模式(在规范化之前)
if (containsPathTraversal(cleanedPath)) {
throw new Error('Invalid path: path traversal detected');
}
// 进行路径规范化
const normalized = path.resolve(cleanedPath);
// 再次检查规范化后的路径,确保没有绕过安全检查
if (containsPathTraversal(normalized)) {
throw new Error('Invalid path: path traversal detected after normalization');
}
// 额外安全检查:确保规范化后的路径不包含连续的路径分隔符
const finalPath = normalized.replace(/[\\\/]+/g, path.sep);
return finalPath;
};
// 检查路径是否包含路径遍历攻击模式
const containsPathTraversal = (inputPath: string): boolean => {
// 对输入进行URL解码防止编码绕过
let decodedPath = inputPath;
try {
decodedPath = decodeURIComponent(inputPath);
} catch {
// 如果解码失败,使用原始路径
}
// 将路径统一为正斜杠格式进行检查
const normalizedForCheck = decodedPath.replace(/\\/g, '/');
// 检查危险模式 - 更全面的路径遍历检测
const dangerousPatterns = [
/\.\.\//, // ../ 模式
/\/\.\./, // /.. 模式
/^\.\./, // 以.. 开头
/\.\.$/, // 以.. 结尾
/\.\.\\/, // ..\ 模式Windows
/\\\.\./, // \.. 模式Windows
/%2e%2e/i, // URL编码的..
/%252e%252e/i, // 双重URL编码的..
/\.\.\x00/, // null字节攻击
/\0/, // null字节
];
return dangerousPatterns.some(pattern => pattern.test(normalizedForCheck));
};
interface FileInfo {
@@ -52,6 +114,35 @@ interface FileInfo {
// 添加系统文件黑名单
const SYSTEM_FILES = new Set(['pagefile.sys', 'swapfile.sys', 'hiberfil.sys', 'System Volume Information']);
// 检查是否为WebUI配置文件
const isWebUIConfigFile = (filePath: string): boolean => {
// 先用字符串快速筛选
if (!filePath.includes('webui.json')) {
return false;
}
// 进入更严格的路径判断 - 统一路径分隔符为 /
const webUIConfigPath = path.resolve(webUiPathWrapper.configPath, 'webui.json').replace(/\\/g, '/');
const targetPath = path.resolve(filePath).replace(/\\/g, '/');
// 统一分隔符后进行路径比较
return targetPath === webUIConfigPath;
};
// WebUI配置文件脱敏处理
const sanitizeWebUIConfig = (content: string): string => {
try {
const config = JSON.parse(content);
if (config.token) {
config.token = '******';
}
return JSON.stringify(config, null, 4);
} catch {
// 如果解析失败,返回原内容
return content;
}
};
// 检查同类型的文件或目录是否存在
const checkSameTypeExists = async (pathToCheck: string, isDirectory: boolean): Promise<boolean> => {
try {
@@ -65,13 +156,16 @@ const checkSameTypeExists = async (pathToCheck: string, isDirectory: boolean): P
// 获取目录内容
export const ListFilesHandler: RequestHandler = async (req, res) => {
const webuiToken = await WebUiConfig.GetWebUIConfig();
if (webuiToken.defaultToken) {
return sendError(res, '默认密码禁止使用');
}
try {
const requestPath = (req.query['path'] as string) || (isWindows ? 'C:\\' : '/');
const normalizedPath = normalizePath(requestPath);
const requestPath = getQueryStringParam(req.query['path']) || (isWindows ? process.env['USERPROFILE'] || 'C:\\' : '/');
let normalizedPath: string;
try {
normalizedPath = normalizePath(requestPath);
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
const onlyDirectory = req.query['onlyDirectory'] === 'true';
// 如果是根路径且在Windows系统上返回盘符列表
@@ -139,7 +233,18 @@ export const ListFilesHandler: RequestHandler = async (req, res) => {
export const CreateDirHandler: RequestHandler = async (req, res) => {
try {
const { path: dirPath } = req.body;
const normalizedPath = normalizePath(dirPath);
let normalizedPath: string;
try {
normalizedPath = normalizePath(dirPath);
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
// 额外安全检查:确保路径是绝对路径
if (!path.isAbsolute(normalizedPath)) {
return sendError(res, '路径必须是绝对路径');
}
// 检查是否已存在同类型(目录)
if (await checkSameTypeExists(normalizedPath, true)) {
@@ -157,7 +262,19 @@ export const CreateDirHandler: RequestHandler = async (req, res) => {
export const DeleteHandler: RequestHandler = async (req, res) => {
try {
const { path: targetPath } = req.body;
const normalizedPath = normalizePath(targetPath);
let normalizedPath: string;
try {
normalizedPath = normalizePath(targetPath);
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
// 额外安全检查:确保路径是绝对路径
if (!path.isAbsolute(normalizedPath)) {
return sendError(res, '路径必须是绝对路径');
}
const stat = await fsProm.stat(normalizedPath);
if (stat.isDirectory()) {
await fsProm.rm(normalizedPath, { recursive: true });
@@ -175,7 +292,18 @@ export const BatchDeleteHandler: RequestHandler = async (req, res) => {
try {
const { paths } = req.body;
for (const targetPath of paths) {
const normalizedPath = normalizePath(targetPath);
let normalizedPath: string;
try {
normalizedPath = normalizePath(targetPath);
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
// 额外安全检查:确保路径是绝对路径
if (!path.isAbsolute(normalizedPath)) {
return sendError(res, '路径必须是绝对路径');
}
const stat = await fsProm.stat(normalizedPath);
if (stat.isDirectory()) {
await fsProm.rm(normalizedPath, { recursive: true });
@@ -192,8 +320,25 @@ export const BatchDeleteHandler: RequestHandler = async (req, res) => {
// 读取文件内容
export const ReadFileHandler: RequestHandler = async (req, res) => {
try {
const filePath = normalizePath(req.query['path'] as string);
const content = await fsProm.readFile(filePath, 'utf-8');
let filePath: string;
try {
filePath = normalizePath(getQueryStringParam(req.query['path']));
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
// 额外安全检查:确保路径是绝对路径
if (!path.isAbsolute(filePath)) {
return sendError(res, '路径必须是绝对路径');
}
let content = await fsProm.readFile(filePath, 'utf-8');
// 如果是WebUI配置文件对token进行脱敏处理
if (isWebUIConfigFile(filePath)) {
content = sanitizeWebUIConfig(content);
}
return sendSuccess(res, content);
} catch (error) {
return sendError(res, '读取文件失败');
@@ -204,8 +349,40 @@ export const ReadFileHandler: RequestHandler = async (req, res) => {
export const WriteFileHandler: RequestHandler = async (req, res) => {
try {
const { path: filePath, content } = req.body;
const normalizedPath = normalizePath(filePath);
await fsProm.writeFile(normalizedPath, content, 'utf-8');
// 安全的路径规范化,如果检测到路径遍历攻击会抛出异常
let normalizedPath: string;
try {
normalizedPath = normalizePath(filePath);
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
// 额外安全检查:确保路径是绝对路径
if (!path.isAbsolute(normalizedPath)) {
return sendError(res, '路径必须是绝对路径');
}
let finalContent = content;
// 检查是否为WebUI配置文件
if (isWebUIConfigFile(normalizedPath)) {
try {
// 解析要写入的配置
const configToWrite = JSON.parse(content);
// 获取内存中的token覆盖前端传来的token
const memoryToken = getInitialWebUiToken();
if (memoryToken) {
configToWrite.token = memoryToken;
finalContent = JSON.stringify(configToWrite, null, 4);
}
} catch (e) {
// 如果解析失败 说明不符合json格式 不允许写入
return sendError(res, '写入的WebUI配置文件内容格式错误必须是合法的JSON');
}
}
await fsProm.writeFile(normalizedPath, finalContent, 'utf-8');
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '写入文件失败');
@@ -216,7 +393,18 @@ export const WriteFileHandler: RequestHandler = async (req, res) => {
export const CreateFileHandler: RequestHandler = async (req, res) => {
try {
const { path: filePath } = req.body;
const normalizedPath = normalizePath(filePath);
let normalizedPath: string;
try {
normalizedPath = normalizePath(filePath);
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
// 额外安全检查:确保路径是绝对路径
if (!path.isAbsolute(normalizedPath)) {
return sendError(res, '路径必须是绝对路径');
}
// 检查是否已存在同类型(文件)
if (await checkSameTypeExists(normalizedPath, false)) {
@@ -234,8 +422,21 @@ export const CreateFileHandler: RequestHandler = async (req, res) => {
export const RenameHandler: RequestHandler = async (req, res) => {
try {
const { oldPath, newPath } = req.body;
const normalizedOldPath = normalizePath(oldPath);
const normalizedNewPath = normalizePath(newPath);
let normalizedOldPath: string;
let normalizedNewPath: string;
try {
normalizedOldPath = normalizePath(oldPath);
normalizedNewPath = normalizePath(newPath);
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
// 额外安全检查:确保路径是绝对路径
if (!path.isAbsolute(normalizedOldPath) || !path.isAbsolute(normalizedNewPath)) {
return sendError(res, '路径必须是绝对路径');
}
await fsProm.rename(normalizedOldPath, normalizedNewPath);
return sendSuccess(res, true);
} catch (error) {
@@ -247,8 +448,21 @@ export const RenameHandler: RequestHandler = async (req, res) => {
export const MoveHandler: RequestHandler = async (req, res) => {
try {
const { sourcePath, targetPath } = req.body;
const normalizedSourcePath = normalizePath(sourcePath);
const normalizedTargetPath = normalizePath(targetPath);
let normalizedSourcePath: string;
let normalizedTargetPath: string;
try {
normalizedSourcePath = normalizePath(sourcePath);
normalizedTargetPath = normalizePath(targetPath);
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
// 额外安全检查:确保路径是绝对路径
if (!path.isAbsolute(normalizedSourcePath) || !path.isAbsolute(normalizedTargetPath)) {
return sendError(res, '路径必须是绝对路径');
}
await fsProm.rename(normalizedSourcePath, normalizedTargetPath);
return sendSuccess(res, true);
} catch (error) {
@@ -261,8 +475,20 @@ export const BatchMoveHandler: RequestHandler = async (req, res) => {
try {
const { items } = req.body;
for (const { sourcePath, targetPath } of items) {
const normalizedSourcePath = normalizePath(sourcePath);
const normalizedTargetPath = normalizePath(targetPath);
let normalizedSourcePath: string;
let normalizedTargetPath: string;
try {
normalizedSourcePath = normalizePath(sourcePath);
normalizedTargetPath = normalizePath(targetPath);
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
// 额外安全检查:确保路径是绝对路径
if (!path.isAbsolute(normalizedSourcePath) || !path.isAbsolute(normalizedTargetPath)) {
return sendError(res, '路径必须是绝对路径');
}
await fsProm.rename(normalizedSourcePath, normalizedTargetPath);
}
return sendSuccess(res, true);
@@ -274,10 +500,21 @@ export const BatchMoveHandler: RequestHandler = async (req, res) => {
// 新增:文件下载处理方法(注意流式传输,不将整个文件读入内存)
export const DownloadHandler: RequestHandler = async (req, res) => {
try {
const filePath = normalizePath(req.query['path'] as string);
let filePath: string;
try {
filePath = normalizePath(getQueryStringParam(req.query['path']));
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
if (!filePath) {
return sendError(res, '参数错误');
}
// 额外安全检查:确保路径是绝对路径
if (!path.isAbsolute(filePath)) {
return sendError(res, '路径必须是绝对路径');
}
const stat = await fsProm.stat(filePath);
@@ -316,12 +553,25 @@ export const BatchDownloadHandler: RequestHandler = async (req, res) => {
const zipStream = new compressing.zip.Stream();
// 修改:根据文件类型设置 relativePath
for (const filePath of paths) {
const normalizedPath = normalizePath(filePath);
let normalizedPath: string;
try {
normalizedPath = normalizePath(filePath);
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
// 额外安全检查:确保规范化后的路径是绝对路径
if (!path.isAbsolute(normalizedPath)) {
return sendError(res, '路径必须是绝对路径');
}
const stat = await fsProm.stat(normalizedPath);
if (stat.isDirectory()) {
zipStream.addEntry(normalizedPath, { relativePath: '' });
} else {
zipStream.addEntry(normalizedPath, { relativePath: path.basename(normalizedPath) });
// 确保相对路径只使用文件名,防止路径遍历
const safeName = path.basename(normalizedPath);
zipStream.addEntry(normalizedPath, { relativePath: safeName });
}
}
zipStream.pipe(res);

View File

@@ -5,6 +5,12 @@ import { terminalManager } from '../terminal/terminal_manager';
import { WebUiConfig } from '@/webui';
// 判断是否是 macos
const isMacOS = process.platform === 'darwin';
// 日志脱敏函数
const sanitizeLog = (log: string): string => {
// 脱敏 token 参数,将 token=xxx 替换为 token=***
return log.replace(/token=[\w\d]+/gi, 'token=***');
};
// 日志记录
export const LogHandler: RequestHandler = async (req, res) => {
const filename = req.query['id'];
@@ -16,7 +22,8 @@ export const LogHandler: RequestHandler = async (req, res) => {
return sendError(res, 'ID不合法');
}
const logContent = await WebUiConfig.GetLogContent(filename);
return sendSuccess(res, logContent);
const sanitizedLogContent = sanitizeLog(logContent);
return sendSuccess(res, sanitizedLogContent);
};
// 日志列表
@@ -31,7 +38,8 @@ export const LogRealTimeHandler: RequestHandler = async (req, res) => {
res.setHeader('Connection', 'keep-alive');
const listener = (log: string) => {
try {
res.write(`data: ${log}\n\n`);
const sanitizedLog = sanitizeLog(log);
res.write(`data: ${sanitizedLog}\n\n`);
} catch (error) {
console.error('向客户端写入日志数据时出错:', error);
}
@@ -47,9 +55,6 @@ export const CreateTerminalHandler: RequestHandler = async (req, res) => {
if (isMacOS) {
return sendError(res, 'MacOS不支持终端');
}
if ((await WebUiConfig.GetWebUIConfig()).defaultToken) {
return sendError(res, '该密码禁止创建终端');
}
try {
const { cols, rows } = req.body;
const { id } = terminalManager.createTerminal(cols, rows);

View File

@@ -2,7 +2,7 @@ import { RequestHandler } from 'express';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { loadConfig, OneBotConfig } from '@/onebot/config/config';
import { WebUiConfig, webUiPathWrapper } from '@/webui';
import { webUiPathWrapper } from '@/webui';
import { WebUiDataRuntime } from '@webapi/helper/Data';
import { sendError, sendSuccess } from '@webapi/utils/response';
import { isEmpty } from '@webapi/utils/check';
@@ -47,10 +47,6 @@ export const OB11SetConfigHandler: RequestHandler = async (req, res) => {
if (isEmpty(req.body.config)) {
return sendError(res, 'config is empty');
}
const webuiToken = await WebUiConfig.GetWebUIConfig();
if (webuiToken.defaultToken) {
return sendError(res, '默认密码禁止写入配置');
}
// 写入配置
try {
// 解析并加载配置
@@ -61,4 +57,4 @@ export const OB11SetConfigHandler: RequestHandler = async (req, res) => {
} catch (e) {
return sendError(res, 'Error: ' + e);
}
};
};

View File

@@ -14,6 +14,12 @@ const LoginRuntime: LoginRuntimeType = {
nick: '',
},
QQVersion: 'unknown',
onQQLoginStatusChange: async (status: boolean) => {
LoginRuntime.QQLoginStatus = status;
},
onWebUiTokenChange: async (_token: string) => {
return;
},
NapCatHelper: {
onOB11ConfigChanged: async () => {
return;
@@ -31,6 +37,12 @@ const LoginRuntime: LoginRuntimeType = {
};
export const WebUiDataRuntime = {
setWebUiTokenChangeCallback(func: (token: string) => Promise<void>): void {
LoginRuntime.onWebUiTokenChange = func;
},
getWebUiTokenChangeCallback(): (token: string) => Promise<void> {
return LoginRuntime.onWebUiTokenChange;
},
checkLoginRate(ip: string, RateLimit: number): boolean {
const key = `login_rate:${ip}`;
const count = store.get<number>(key) || 0;
@@ -53,6 +65,14 @@ export const WebUiDataRuntime = {
return LoginRuntime.QQLoginStatus;
},
setQQLoginCallback(func: (status: boolean) => Promise<void>): void {
LoginRuntime.onQQLoginStatusChange = func;
},
getQQLoginCallback(): (status: boolean) => Promise<void> {
return LoginRuntime.onQQLoginStatusChange;
},
setQQLoginStatus(status: LoginRuntimeType['QQLoginStatus']): void {
LoginRuntime.QQLoginStatus = status;
},

View File

@@ -1,4 +1,4 @@
import { webUiPathWrapper } from '@/webui';
import { webUiPathWrapper, getInitialWebUiToken } from '@/webui';
import { Type, Static } from '@sinclair/typebox';
import Ajv from 'ajv';
import fs, { constants } from 'node:fs/promises';
@@ -7,18 +7,17 @@ import { resolve } from 'node:path';
import { deepMerge } from '../utils/object';
import { themeType } from '../types/theme';
import { getRandomToken } from '../utils/url'
// 限制尝试端口的次数,避免死循环
// 定义配置的类型
const WebUiConfigSchema = Type.Object({
host: Type.String({ default: '0.0.0.0' }),
port: Type.Number({ default: 6099 }),
// napcat+<月份日时>,例如 napcat062511
token: Type.String({ default: 'napcat' + (new Date().getMonth() + 1).toString().padStart(2, '0') + new Date().getDate().toString().padStart(2, '0') + new Date().getHours().toString().padStart(2, '0') }),
token: Type.String({ default: getRandomToken(12) }),
loginRate: Type.Number({ default: 10 }),
autoLoginAccount: Type.String({ default: '' }),
theme: themeType,
defaultToken: Type.Boolean({ default: true }),
// 是否关闭WebUI
disableWebUI: Type.Boolean({ default: false }),
// 是否关闭非局域网访问
@@ -64,6 +63,47 @@ export class WebUiConfigWrapper {
}
async GetWebUIConfig(): Promise<WebUiConfigType> {
if (this.WebUiConfigData) {
return this.WebUiConfigData
}
try {
const configPath = resolve(webUiPathWrapper.configPath, './webui.json');
await this.ensureConfigFileExists(configPath);
const parsedConfig = await this.readAndValidateConfig(configPath);
// 使用内存中缓存的token进行覆盖确保强兼容性
this.WebUiConfigData = {
...parsedConfig,
// 首次读取内存中是没有token的需要进行一层兜底
token: getInitialWebUiToken() || parsedConfig.token,
};
return this.WebUiConfigData;
} catch (e) {
console.log('读取配置文件失败', e);
const defaultConfig = this.validateAndApplyDefaults({});
this.WebUiConfigData = {
...defaultConfig,
token: getInitialWebUiToken() || defaultConfig.token,
}
return this.WebUiConfigData;
}
}
async UpdateWebUIConfig(newConfig: Partial<WebUiConfigType>): Promise<void> {
const configPath = resolve(webUiPathWrapper.configPath, './webui.json');
// 使用原始配置进行合并避免内存token覆盖影响配置更新
const currentConfig = await this.GetRawWebUIConfig();
const mergedConfig = deepMerge({ ...currentConfig }, newConfig);
const updatedConfig = this.validateAndApplyDefaults(mergedConfig);
await this.writeConfig(configPath, updatedConfig);
this.WebUiConfigData = updatedConfig;
}
/**
* 获取配置文件中实际存储的配置不被内存token覆盖
* 主要用于配置更新和特殊场景
*/
async GetRawWebUIConfig(): Promise<WebUiConfigType> {
if (this.WebUiConfigData) {
return this.WebUiConfigData;
}
@@ -79,21 +119,15 @@ export class WebUiConfigWrapper {
}
}
async UpdateWebUIConfig(newConfig: Partial<WebUiConfigType>): Promise<void> {
const configPath = resolve(webUiPathWrapper.configPath, './webui.json');
const currentConfig = await this.GetWebUIConfig();
const mergedConfig = deepMerge({ ...currentConfig }, newConfig);
const updatedConfig = this.validateAndApplyDefaults(mergedConfig);
await this.writeConfig(configPath, updatedConfig);
this.WebUiConfigData = updatedConfig;
}
async UpdateToken(oldToken: string, newToken: string): Promise<void> {
const currentConfig = await this.GetWebUIConfig();
if (currentConfig.token !== oldToken) {
// 使用内存中缓存的token进行验证确保强兼容性
const cachedToken = getInitialWebUiToken();
const tokenToCheck = cachedToken || (await this.GetWebUIConfig()).token;
if (tokenToCheck !== oldToken) {
throw new Error('旧 token 不匹配');
}
await this.UpdateWebUIConfig({ token: newToken, defaultToken: false });
await this.UpdateWebUIConfig({ token: newToken });
}
// 获取日志文件夹路径

View File

@@ -1,6 +1,6 @@
import { NextFunction, Request, Response } from 'express';
import { WebUiConfig } from '@/webui';
import { getInitialWebUiToken } from '@/webui';
import { AuthHelper } from '@webapi/helper/SignToken';
import { sendError } from '@webapi/utils/response';
@@ -30,10 +30,13 @@ export async function auth(req: Request, res: Response, next: NextFunction) {
} catch (e) {
return sendError(res, 'Unauthorized');
}
// 获取配置
const config = await WebUiConfig.GetWebUIConfig();
// 使用启动时缓存的token进行验证而不是动态读取配置文件 因为有可能运行时手动修改了密码
const initialToken = getInitialWebUiToken();
if (!initialToken) {
return sendError(res, 'Server token not initialized');
}
// 验证凭证在1小时内有效
const credentialJson = AuthHelper.validateCredentialWithinOneHour(config.token, Credential);
const credentialJson = AuthHelper.validateCredentialWithinOneHour(initialToken, Credential);
if (credentialJson) {
// 通过验证
return next();

View File

@@ -1,7 +1,6 @@
import { Router } from 'express';
import {
CheckDefaultTokenHandler,
checkHandler,
LoginHandler,
LogoutHandler,
@@ -17,7 +16,5 @@ router.post('/check', checkHandler);
router.post('/logout', LogoutHandler);
// router:更新token
router.post('/update_token', UpdateTokenHandler);
// router:检查默认token
router.get('/check_using_default_token', CheckDefaultTokenHandler);
export { router as AuthRouter };

View File

@@ -1,21 +1,30 @@
import path from 'path';
import path from 'path'
import { fileURLToPath } from 'url'
export function callsites () {
const _prepareStackTrace = Error.prepareStackTrace
try {
let result: NodeJS.CallSite[] = []
Error.prepareStackTrace = (_, callSites) => {
const callSitesWithoutCurrent = callSites.slice(1)
result = callSitesWithoutCurrent
return callSitesWithoutCurrent
}
new Error().stack
return result
} finally {
Error.prepareStackTrace = _prepareStackTrace
}
}
Object.defineProperty(global, '__dirname', {
get() {
const err = new Error();
const stack = err.stack?.split('\n') || [];
let callerFile = '';
// 遍历错误堆栈,跳过当前文件所在行
// 注意:堆栈格式可能不同,请根据实际环境调整索引及正则表达式
for (const line of stack) {
const match = line.match(/\((.*):\d+:\d+\)/);
if (match?.[1]) {
callerFile = match[1];
if (!callerFile.includes('init-dynamic-dirname.ts')) {
break;
}
}
get () {
const sites = callsites()
const file = sites?.[1]?.getFileName()
if (file) {
return path.dirname(fileURLToPath(file))
}
return callerFile ? path.dirname(callerFile) : '';
return ''
},
});
})

View File

@@ -1,4 +1,4 @@
import './init-dynamic-dirname';
// import './init-dynamic-dirname';
import { WebUiConfig } from '@/webui';
import { AuthHelper } from '../helper/SignToken';
import { LogWrapper } from '@/common/log';

View File

@@ -9,6 +9,8 @@ interface LoginRuntimeType {
QQLoginUin: string;
QQLoginInfo: SelfInfo;
QQVersion: string;
onQQLoginStatusChange: (status: boolean) => Promise<void>;
onWebUiTokenChange: (token: string) => Promise<void>;
WebUiConfigQuickFunction: () => Promise<void>;
NapCatHelper: {
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string }>;

View File

@@ -66,7 +66,15 @@ export const createDiskStorage = (uploadPath: string) => {
};
export const createDiskUpload = (uploadPath: string) => {
const upload = multer({ storage: createDiskStorage(uploadPath) }).array('files');
const upload = multer({
storage: createDiskStorage(uploadPath),
limits: {
fileSize: 100 * 1024 * 1024, // 100MB 文件大小限制
files: 20, // 最多同时上传20个文件
fieldSize: 1024 * 1024, // 1MB 字段大小限制
fields: 10 // 最多10个字段
}
}).array('files');
return upload;
};
@@ -76,6 +84,18 @@ const diskUploader = (req: Request, res: Response) => {
createDiskUpload(uploadPath)(req, res, (error) => {
if (error) {
// 错误处理
if (error.code === 'LIMIT_FILE_SIZE') {
return reject(new Error('文件大小超过限制最大100MB'));
}
if (error.code === 'LIMIT_FILE_COUNT') {
return reject(new Error('文件数量超过限制最多20个文件'));
}
if (error.code === 'LIMIT_FIELD_VALUE') {
return reject(new Error('字段值大小超过限制'));
}
if (error.code === 'LIMIT_FIELD_COUNT') {
return reject(new Error('字段数量超过限制'));
}
return reject(error);
}
return resolve(true);

View File

@@ -1,8 +1,57 @@
/**
* @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主机地址 转换为标准格式
@@ -13,9 +62,9 @@ import { isIP } from 'node:net';
* @example normalizeHost('2001:4860:4801:51::27') => '[2001:4860:4801:51::27]'
*/
export const normalizeHost = (host: string) => {
if (isIP(host) === 6) return `[${host}]`;
return host;
};
if (isIP(host) === 6) return `[${host}]`
return host
}
/**
* 创建URL
@@ -34,13 +83,23 @@ export const createUrl = (
search?: Record<string, any>,
protocol: Protocol = 'http'
) => {
const url = new URL(`${protocol}://${normalizeHost(host)}`);
url.port = port;
url.pathname = path;
const url = new URL(`${protocol}://${normalizeHost(host)}`)
url.port = port
url.pathname = path
if (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)
}