@yunzai-ng/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +51 -0
- package/dist/adapter/accounts.d.ts +174 -0
- package/dist/adapter/accounts.d.ts.map +1 -0
- package/dist/adapter/accounts.js +653 -0
- package/dist/adapter/accounts.js.map +1 -0
- package/dist/adapter/bots.d.ts +142 -0
- package/dist/adapter/bots.d.ts.map +1 -0
- package/dist/adapter/bots.js +454 -0
- package/dist/adapter/bots.js.map +1 -0
- package/dist/adapter/host.d.ts +114 -0
- package/dist/adapter/host.d.ts.map +1 -0
- package/dist/adapter/host.js +90 -0
- package/dist/adapter/host.js.map +1 -0
- package/dist/adapter/login.d.ts +174 -0
- package/dist/adapter/login.d.ts.map +1 -0
- package/dist/adapter/login.js +345 -0
- package/dist/adapter/login.js.map +1 -0
- package/dist/adapter/registry.d.ts +102 -0
- package/dist/adapter/registry.d.ts.map +1 -0
- package/dist/adapter/registry.js +150 -0
- package/dist/adapter/registry.js.map +1 -0
- package/dist/config/core-config.d.ts +151 -0
- package/dist/config/core-config.d.ts.map +1 -0
- package/dist/config/core-config.js +338 -0
- package/dist/config/core-config.js.map +1 -0
- package/dist/config/schema.d.ts +449 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/config/schema.js +871 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/config/store.d.ts +184 -0
- package/dist/config/store.d.ts.map +1 -0
- package/dist/config/store.js +428 -0
- package/dist/config/store.js.map +1 -0
- package/dist/config/yaml.d.ts +23 -0
- package/dist/config/yaml.d.ts.map +1 -0
- package/dist/config/yaml.js +108 -0
- package/dist/config/yaml.js.map +1 -0
- package/dist/http/client.d.ts +82 -0
- package/dist/http/client.d.ts.map +1 -0
- package/dist/http/client.js +658 -0
- package/dist/http/client.js.map +1 -0
- package/dist/index.d.ts +77 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +94 -0
- package/dist/index.js.map +1 -0
- package/dist/kernel/app.d.ts +244 -0
- package/dist/kernel/app.d.ts.map +1 -0
- package/dist/kernel/app.js +654 -0
- package/dist/kernel/app.js.map +1 -0
- package/dist/kernel/kv-sink.d.ts +34 -0
- package/dist/kernel/kv-sink.d.ts.map +1 -0
- package/dist/kernel/kv-sink.js +25 -0
- package/dist/kernel/kv-sink.js.map +1 -0
- package/dist/kernel/policy.d.ts +77 -0
- package/dist/kernel/policy.d.ts.map +1 -0
- package/dist/kernel/policy.js +99 -0
- package/dist/kernel/policy.js.map +1 -0
- package/dist/kernel/runtime.d.ts +117 -0
- package/dist/kernel/runtime.d.ts.map +1 -0
- package/dist/kernel/runtime.js +197 -0
- package/dist/kernel/runtime.js.map +1 -0
- package/dist/kernel/sql-sink.d.ts +36 -0
- package/dist/kernel/sql-sink.d.ts.map +1 -0
- package/dist/kernel/sql-sink.js +114 -0
- package/dist/kernel/sql-sink.js.map +1 -0
- package/dist/logger/format.d.ts +67 -0
- package/dist/logger/format.d.ts.map +1 -0
- package/dist/logger/format.js +218 -0
- package/dist/logger/format.js.map +1 -0
- package/dist/logger/index.d.ts +97 -0
- package/dist/logger/index.d.ts.map +1 -0
- package/dist/logger/index.js +363 -0
- package/dist/logger/index.js.map +1 -0
- package/dist/logger/rotate.d.ts +47 -0
- package/dist/logger/rotate.d.ts.map +1 -0
- package/dist/logger/rotate.js +242 -0
- package/dist/logger/rotate.js.map +1 -0
- package/dist/message/segment.d.ts +255 -0
- package/dist/message/segment.d.ts.map +1 -0
- package/dist/message/segment.js +510 -0
- package/dist/message/segment.js.map +1 -0
- package/dist/message/split.d.ts +34 -0
- package/dist/message/split.d.ts.map +1 -0
- package/dist/message/split.js +79 -0
- package/dist/message/split.js.map +1 -0
- package/dist/message/target.d.ts +29 -0
- package/dist/message/target.d.ts.map +1 -0
- package/dist/message/target.js +34 -0
- package/dist/message/target.js.map +1 -0
- package/dist/pipeline/cooldown.d.ts +84 -0
- package/dist/pipeline/cooldown.d.ts.map +1 -0
- package/dist/pipeline/cooldown.js +94 -0
- package/dist/pipeline/cooldown.js.map +1 -0
- package/dist/pipeline/dispatch.d.ts +98 -0
- package/dist/pipeline/dispatch.d.ts.map +1 -0
- package/dist/pipeline/dispatch.js +323 -0
- package/dist/pipeline/dispatch.js.map +1 -0
- package/dist/pipeline/event.d.ts +130 -0
- package/dist/pipeline/event.d.ts.map +1 -0
- package/dist/pipeline/event.js +538 -0
- package/dist/pipeline/event.js.map +1 -0
- package/dist/pipeline/middleware.d.ts +63 -0
- package/dist/pipeline/middleware.d.ts.map +1 -0
- package/dist/pipeline/middleware.js +174 -0
- package/dist/pipeline/middleware.js.map +1 -0
- package/dist/pipeline/prompt.d.ts +80 -0
- package/dist/pipeline/prompt.d.ts.map +1 -0
- package/dist/pipeline/prompt.js +155 -0
- package/dist/pipeline/prompt.js.map +1 -0
- package/dist/pipeline/router.d.ts +98 -0
- package/dist/pipeline/router.d.ts.map +1 -0
- package/dist/pipeline/router.js +489 -0
- package/dist/pipeline/router.js.map +1 -0
- package/dist/platform/detect.d.ts +24 -0
- package/dist/platform/detect.d.ts.map +1 -0
- package/dist/platform/detect.js +178 -0
- package/dist/platform/detect.js.map +1 -0
- package/dist/platform/paths.d.ts +38 -0
- package/dist/platform/paths.d.ts.map +1 -0
- package/dist/platform/paths.js +129 -0
- package/dist/platform/paths.js.map +1 -0
- package/dist/platform/system.d.ts +71 -0
- package/dist/platform/system.d.ts.map +1 -0
- package/dist/platform/system.js +242 -0
- package/dist/platform/system.js.map +1 -0
- package/dist/plugin/context.d.ts +92 -0
- package/dist/plugin/context.d.ts.map +1 -0
- package/dist/plugin/context.js +573 -0
- package/dist/plugin/context.js.map +1 -0
- package/dist/plugin/define.d.ts +92 -0
- package/dist/plugin/define.d.ts.map +1 -0
- package/dist/plugin/define.js +82 -0
- package/dist/plugin/define.js.map +1 -0
- package/dist/plugin/discover.d.ts +106 -0
- package/dist/plugin/discover.d.ts.map +1 -0
- package/dist/plugin/discover.js +254 -0
- package/dist/plugin/discover.js.map +1 -0
- package/dist/plugin/events.d.ts +109 -0
- package/dist/plugin/events.d.ts.map +1 -0
- package/dist/plugin/events.js +209 -0
- package/dist/plugin/events.js.map +1 -0
- package/dist/plugin/hooks.d.ts +266 -0
- package/dist/plugin/hooks.d.ts.map +1 -0
- package/dist/plugin/hooks.js +84 -0
- package/dist/plugin/hooks.js.map +1 -0
- package/dist/plugin/host.d.ts +160 -0
- package/dist/plugin/host.d.ts.map +1 -0
- package/dist/plugin/host.js +546 -0
- package/dist/plugin/host.js.map +1 -0
- package/dist/plugin/market.d.ts +264 -0
- package/dist/plugin/market.d.ts.map +1 -0
- package/dist/plugin/market.js +700 -0
- package/dist/plugin/market.js.map +1 -0
- package/dist/plugin/services.d.ts +116 -0
- package/dist/plugin/services.d.ts.map +1 -0
- package/dist/plugin/services.js +213 -0
- package/dist/plugin/services.js.map +1 -0
- package/dist/plugin/tar.d.ts +65 -0
- package/dist/plugin/tar.d.ts.map +1 -0
- package/dist/plugin/tar.js +277 -0
- package/dist/plugin/tar.js.map +1 -0
- package/dist/render/registry.d.ts +127 -0
- package/dist/render/registry.d.ts.map +1 -0
- package/dist/render/registry.js +306 -0
- package/dist/render/registry.js.map +1 -0
- package/dist/scheduler/index.d.ts +67 -0
- package/dist/scheduler/index.d.ts.map +1 -0
- package/dist/scheduler/index.js +279 -0
- package/dist/scheduler/index.js.map +1 -0
- package/dist/server/api.d.ts +179 -0
- package/dist/server/api.d.ts.map +1 -0
- package/dist/server/api.js +602 -0
- package/dist/server/api.js.map +1 -0
- package/dist/server/auth.d.ts +83 -0
- package/dist/server/auth.d.ts.map +1 -0
- package/dist/server/auth.js +178 -0
- package/dist/server/auth.js.map +1 -0
- package/dist/server/browse.d.ts +60 -0
- package/dist/server/browse.d.ts.map +1 -0
- package/dist/server/browse.js +187 -0
- package/dist/server/browse.js.map +1 -0
- package/dist/server/files.d.ts +27 -0
- package/dist/server/files.d.ts.map +1 -0
- package/dist/server/files.js +76 -0
- package/dist/server/files.js.map +1 -0
- package/dist/server/index.d.ts +189 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +1123 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/table.d.ts +105 -0
- package/dist/server/table.d.ts.map +1 -0
- package/dist/server/table.js +289 -0
- package/dist/server/table.js.map +1 -0
- package/dist/store/index.d.ts +50 -0
- package/dist/store/index.d.ts.map +1 -0
- package/dist/store/index.js +120 -0
- package/dist/store/index.js.map +1 -0
- package/dist/store/json.d.ts +73 -0
- package/dist/store/json.d.ts.map +1 -0
- package/dist/store/json.js +174 -0
- package/dist/store/json.js.map +1 -0
- package/dist/store/kv.d.ts +126 -0
- package/dist/store/kv.d.ts.map +1 -0
- package/dist/store/kv.js +232 -0
- package/dist/store/kv.js.map +1 -0
- package/dist/store/level.d.ts +103 -0
- package/dist/store/level.d.ts.map +1 -0
- package/dist/store/level.js +119 -0
- package/dist/store/level.js.map +1 -0
- package/dist/store/memory.d.ts +61 -0
- package/dist/store/memory.d.ts.map +1 -0
- package/dist/store/memory.js +94 -0
- package/dist/store/memory.js.map +1 -0
- package/dist/store/sql.d.ts +142 -0
- package/dist/store/sql.d.ts.map +1 -0
- package/dist/store/sql.js +246 -0
- package/dist/store/sql.js.map +1 -0
- package/dist/testing/fake.d.ts +109 -0
- package/dist/testing/fake.d.ts.map +1 -0
- package/dist/testing/fake.js +196 -0
- package/dist/testing/fake.js.map +1 -0
- package/dist/testing/index.d.ts +16 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +16 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/mock-adapter.d.ts +205 -0
- package/dist/testing/mock-adapter.d.ts.map +1 -0
- package/dist/testing/mock-adapter.js +309 -0
- package/dist/testing/mock-adapter.js.map +1 -0
- package/dist/util/deep.d.ts +108 -0
- package/dist/util/deep.d.ts.map +1 -0
- package/dist/util/deep.js +275 -0
- package/dist/util/deep.js.map +1 -0
- package/dist/util/defer.d.ts +107 -0
- package/dist/util/defer.d.ts.map +1 -0
- package/dist/util/defer.js +163 -0
- package/dist/util/defer.js.map +1 -0
- package/dist/util/dispose.d.ts +88 -0
- package/dist/util/dispose.d.ts.map +1 -0
- package/dist/util/dispose.js +143 -0
- package/dist/util/dispose.js.map +1 -0
- package/dist/util/duration.d.ts +28 -0
- package/dist/util/duration.d.ts.map +1 -0
- package/dist/util/duration.js +77 -0
- package/dist/util/duration.js.map +1 -0
- package/dist/util/fs.d.ts +110 -0
- package/dist/util/fs.d.ts.map +1 -0
- package/dist/util/fs.js +269 -0
- package/dist/util/fs.js.map +1 -0
- package/dist/util/id.d.ts +49 -0
- package/dist/util/id.d.ts.map +1 -0
- package/dist/util/id.js +87 -0
- package/dist/util/id.js.map +1 -0
- package/dist/util/lru.d.ts +74 -0
- package/dist/util/lru.d.ts.map +1 -0
- package/dist/util/lru.js +156 -0
- package/dist/util/lru.js.map +1 -0
- package/dist/util/queue.d.ts +64 -0
- package/dist/util/queue.d.ts.map +1 -0
- package/dist/util/queue.js +147 -0
- package/dist/util/queue.js.map +1 -0
- package/dist/util/text.d.ts +86 -0
- package/dist/util/text.d.ts.map +1 -0
- package/dist/util/text.js +182 -0
- package/dist/util/text.js.map +1 -0
- package/package.json +44 -0
- package/src/adapter/accounts.ts +758 -0
- package/src/adapter/bots.ts +582 -0
- package/src/adapter/host.ts +224 -0
- package/src/adapter/login.ts +481 -0
- package/src/adapter/registry.ts +202 -0
- package/src/config/core-config.test.ts +60 -0
- package/src/config/core-config.ts +401 -0
- package/src/config/schema.test.ts +179 -0
- package/src/config/schema.ts +1036 -0
- package/src/config/store.test.ts +245 -0
- package/src/config/store.ts +506 -0
- package/src/config/yaml.ts +119 -0
- package/src/http/client.test.ts +568 -0
- package/src/http/client.ts +777 -0
- package/src/index.ts +106 -0
- package/src/kernel/app.test.ts +267 -0
- package/src/kernel/app.ts +843 -0
- package/src/kernel/kv-sink.ts +58 -0
- package/src/kernel/policy.ts +120 -0
- package/src/kernel/runtime.test.ts +542 -0
- package/src/kernel/runtime.ts +307 -0
- package/src/kernel/sql-sink.ts +154 -0
- package/src/logger/format.ts +259 -0
- package/src/logger/index.ts +427 -0
- package/src/logger/rotate.ts +265 -0
- package/src/message/segment.test.ts +335 -0
- package/src/message/segment.ts +569 -0
- package/src/message/split.ts +97 -0
- package/src/message/target.ts +48 -0
- package/src/pipeline/cooldown.ts +136 -0
- package/src/pipeline/dispatch.ts +419 -0
- package/src/pipeline/event.ts +735 -0
- package/src/pipeline/middleware.test.ts +426 -0
- package/src/pipeline/middleware.ts +199 -0
- package/src/pipeline/prompt.ts +212 -0
- package/src/pipeline/router.test.ts +432 -0
- package/src/pipeline/router.ts +575 -0
- package/src/platform/detect.ts +178 -0
- package/src/platform/paths.ts +147 -0
- package/src/platform/system.test.ts +102 -0
- package/src/platform/system.ts +289 -0
- package/src/plugin/context.test.ts +459 -0
- package/src/plugin/context.ts +757 -0
- package/src/plugin/define.test.ts +120 -0
- package/src/plugin/define.ts +142 -0
- package/src/plugin/discover.test.ts +253 -0
- package/src/plugin/discover.ts +353 -0
- package/src/plugin/events.test.ts +162 -0
- package/src/plugin/events.ts +266 -0
- package/src/plugin/hooks.ts +373 -0
- package/src/plugin/host.test.ts +696 -0
- package/src/plugin/host.ts +738 -0
- package/src/plugin/market.test.ts +781 -0
- package/src/plugin/market.ts +870 -0
- package/src/plugin/services.test.ts +135 -0
- package/src/plugin/services.ts +261 -0
- package/src/plugin/tar.test.ts +158 -0
- package/src/plugin/tar.ts +309 -0
- package/src/render/registry.test.ts +127 -0
- package/src/render/registry.ts +439 -0
- package/src/scheduler/index.ts +357 -0
- package/src/server/api.test.ts +802 -0
- package/src/server/api.ts +869 -0
- package/src/server/auth.ts +201 -0
- package/src/server/browse.test.ts +193 -0
- package/src/server/browse.ts +240 -0
- package/src/server/files.ts +97 -0
- package/src/server/index.test.ts +566 -0
- package/src/server/index.ts +1386 -0
- package/src/server/table.ts +351 -0
- package/src/store/index.ts +156 -0
- package/src/store/json.ts +198 -0
- package/src/store/kv.test.ts +307 -0
- package/src/store/kv.ts +256 -0
- package/src/store/level.ts +167 -0
- package/src/store/memory.ts +109 -0
- package/src/store/sql.test.ts +204 -0
- package/src/store/sql.ts +324 -0
- package/src/testing/fake.ts +297 -0
- package/src/testing/index.ts +15 -0
- package/src/testing/mock-adapter.ts +588 -0
- package/src/util/deep.ts +272 -0
- package/src/util/defer.ts +211 -0
- package/src/util/dispose.ts +169 -0
- package/src/util/duration.ts +82 -0
- package/src/util/fs.ts +279 -0
- package/src/util/id.ts +94 -0
- package/src/util/lru.ts +185 -0
- package/src/util/queue.ts +167 -0
- package/src/util/text.ts +189 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模块职责:静态文件定位 —— 把 URL 剩余段解析成目录内的一个真实文件
|
|
3
|
+
* 依赖方向:依赖 util/fs 的越界校验;不认识 Fastify
|
|
4
|
+
* 生命周期:纯函数
|
|
5
|
+
* 注意事项:**真正读文件与设响应头的活交给 `@fastify/static`**(mime、ETag、304、Range、HEAD 都由
|
|
6
|
+
* 它背后的 `@fastify/send` 实现),本文件只回答「该给哪个文件」。
|
|
7
|
+
*
|
|
8
|
+
* 它以 `{ serve: false }` 注册,那样它一条路由都不注册、只装饰出 `reply.sendFile()`。
|
|
9
|
+
* 必须如此 —— 它默认会占用 `/*`,而那正是本服务器自己的总入口,冲突会让 Fastify 直接抛
|
|
10
|
+
* duplicate route。
|
|
11
|
+
*
|
|
12
|
+
* 路径越界仍先由 `safeJoin()` 挡一道,不依赖 `@fastify/send` 的内部检查:任意文件读取是
|
|
13
|
+
* 这类服务器最致命的漏洞,值得两道锁。
|
|
14
|
+
*
|
|
15
|
+
* **本文件不定位面板前端产物。** 面板是插件,产物目录由它自己解析并经 `ctx.panel()` 传入
|
|
16
|
+
* —— 内核一旦掌握前端的目录约定,「替换面板」就退化成必须改内核。
|
|
17
|
+
*/
|
|
18
|
+
import { isDirectory, isFile, safeJoin } from "../util/fs.js"
|
|
19
|
+
|
|
20
|
+
/** 目录请求与单页回退都指向这个文件 */
|
|
21
|
+
const INDEX_FILE = "index.html"
|
|
22
|
+
|
|
23
|
+
/** 一个静态目录挂载 */
|
|
24
|
+
export interface StaticMount {
|
|
25
|
+
/** 本地目录绝对路径 */
|
|
26
|
+
readonly dir: string
|
|
27
|
+
/**
|
|
28
|
+
* 找不到文件时是否回退到 `index.html`
|
|
29
|
+
*
|
|
30
|
+
* 单页应用需要:`/plugins` 这类前端路由在磁盘上并不存在,
|
|
31
|
+
* 必须由 `index.html` 接管再由前端路由器解析。
|
|
32
|
+
*/
|
|
33
|
+
readonly spa: boolean
|
|
34
|
+
/**
|
|
35
|
+
* 注册方前缀
|
|
36
|
+
*
|
|
37
|
+
* 仅用于归属查询与面板展示,不参与文件定位。内核据此判断根路径是否已被
|
|
38
|
+
* 插件接管,从而决定是否提示使用者安装面板插件。
|
|
39
|
+
*/
|
|
40
|
+
readonly scope: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 判断最后一段是否带扩展名
|
|
45
|
+
*
|
|
46
|
+
* 单页回退**仅对"形似页面"的请求生效**。否则 `/assets/missing-abc.js`
|
|
47
|
+
* 将取得一份 HTML,浏览器报告的是"MIME 类型不匹配"或语法错误,
|
|
48
|
+
* 排查方向完全偏离 —— 该情形应当如实返回 404。
|
|
49
|
+
* @param rest 相对挂载点的路径
|
|
50
|
+
* @returns 是否带扩展名
|
|
51
|
+
*/
|
|
52
|
+
function looksLikeFile(rest: string): boolean {
|
|
53
|
+
const last = rest.slice(rest.lastIndexOf("/") + 1)
|
|
54
|
+
return last.includes(".")
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 在挂载目录内安全解析出一个候选绝对路径
|
|
59
|
+
* @param dir 挂载目录
|
|
60
|
+
* @param rel 相对路径
|
|
61
|
+
* @returns 绝对路径;越界时 undefined
|
|
62
|
+
*/
|
|
63
|
+
function within(dir: string, rel: string): string | undefined {
|
|
64
|
+
try {
|
|
65
|
+
return safeJoin(dir, rel)
|
|
66
|
+
} catch {
|
|
67
|
+
// 越界一律按"文件不存在"处理:告知对方"已越界"等同于确认了目录布局
|
|
68
|
+
return undefined
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 把 URL 剩余段解析成挂载目录内的一个真实文件
|
|
74
|
+
* @param mount 挂载信息
|
|
75
|
+
* @param rest 相对挂载点的剩余路径(已解码,不以 `/` 开头,可为空串)
|
|
76
|
+
* @returns 可直接交给 `reply.sendFile()` 的相对路径;找不到时 undefined
|
|
77
|
+
*/
|
|
78
|
+
export async function resolveStaticFile(mount: StaticMount, rest: string): Promise<string | undefined> {
|
|
79
|
+
const wanted = rest === "" ? INDEX_FILE : rest
|
|
80
|
+
|
|
81
|
+
const direct = within(mount.dir, wanted)
|
|
82
|
+
if (direct !== undefined) {
|
|
83
|
+
if (await isFile(direct)) return wanted
|
|
84
|
+
// 目录请求(`/docs` 或 `/docs/`)取其中的 index.html
|
|
85
|
+
if (await isDirectory(direct)) {
|
|
86
|
+
const indexRel = `${wanted}/${INDEX_FILE}`
|
|
87
|
+
const index = within(mount.dir, indexRel)
|
|
88
|
+
if (index !== undefined && (await isFile(index))) return indexRel
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (mount.spa && !looksLikeFile(wanted)) {
|
|
93
|
+
const fallback = within(mount.dir, INDEX_FILE)
|
|
94
|
+
if (fallback !== undefined && (await isFile(fallback))) return INDEX_FILE
|
|
95
|
+
}
|
|
96
|
+
return undefined
|
|
97
|
+
}
|
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模块职责:`ManagedServer` 的行为测试
|
|
3
|
+
* 依赖方向:测试文件,依赖 server/*、config/*
|
|
4
|
+
* 生命周期:每个用例一个临时配置目录与一个未监听的服务器
|
|
5
|
+
* 注意事项:全程用 `server.raw.inject()` / `raw.injectWS()`,**不真的监听端口** ——
|
|
6
|
+
* CI 上抢端口是最常见的偶发失败源,而这两个注入接口走的是完整的钩子链,
|
|
7
|
+
* 鉴权、请求体解析、路由分发一个都不会被跳过。
|
|
8
|
+
*
|
|
9
|
+
* `injectWS` 伪造的 raw request 上没有 `socket`,所以要在 upgradeContext 里
|
|
10
|
+
* 自己补一个 `remoteAddress`,否则"是否本机"判断永远为假。
|
|
11
|
+
*/
|
|
12
|
+
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"
|
|
13
|
+
import { tmpdir } from "node:os"
|
|
14
|
+
import { join } from "node:path"
|
|
15
|
+
import type { Socket } from "node:net"
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
|
17
|
+
import type { LogLevel, Logger } from "@yunzai-ng/types"
|
|
18
|
+
import { ConfigStore } from "../config/store.js"
|
|
19
|
+
import { defineCoreConfig, type CoreConfigHandle } from "../config/core-config.js"
|
|
20
|
+
import { ManagedServer, createManagedServer } from "./index.js"
|
|
21
|
+
|
|
22
|
+
/** 本机对端,light-my-request 默认也是这个 */
|
|
23
|
+
const LOCAL_IP = "127.0.0.1"
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 拼一个 `injectWS` 的 upgradeContext
|
|
27
|
+
*
|
|
28
|
+
* `injectWS` 伪造的 raw request 上没有 `socket`,而鉴权要看 TCP 对端地址,所以得自己
|
|
29
|
+
* 补一个。类型上只能断言:`net.Socket` 有近百个成员,这里真正需要的只有
|
|
30
|
+
* `remoteAddress` 一个字段。
|
|
31
|
+
* @param headers 额外的握手请求头
|
|
32
|
+
* @returns upgradeContext
|
|
33
|
+
*/
|
|
34
|
+
function upgradeCtx(headers: Record<string, string> = {}): { socket: Socket; headers: Record<string, string> } {
|
|
35
|
+
return { socket: { remoteAddress: LOCAL_IP } as unknown as Socket, headers }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 静音日志器
|
|
40
|
+
* @returns 只收集行的日志器
|
|
41
|
+
*/
|
|
42
|
+
function quietLogger(): Logger & { lines: string[] } {
|
|
43
|
+
const lines: string[] = []
|
|
44
|
+
const push =
|
|
45
|
+
(level: string) =>
|
|
46
|
+
(msg: unknown): void => {
|
|
47
|
+
lines.push(`${level} ${String(msg)}`)
|
|
48
|
+
}
|
|
49
|
+
const logger: Logger & { lines: string[] } = {
|
|
50
|
+
lines,
|
|
51
|
+
level: "silent" as LogLevel,
|
|
52
|
+
trace: push("trace"),
|
|
53
|
+
debug: push("debug"),
|
|
54
|
+
info: push("info"),
|
|
55
|
+
warn: push("warn"),
|
|
56
|
+
error: push("error"),
|
|
57
|
+
fatal: push("fatal"),
|
|
58
|
+
mark: push("info"),
|
|
59
|
+
child: () => logger,
|
|
60
|
+
isLevelEnabled: () => false
|
|
61
|
+
}
|
|
62
|
+
return logger
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("ManagedServer", () => {
|
|
66
|
+
let dir: string
|
|
67
|
+
let logger: ReturnType<typeof quietLogger>
|
|
68
|
+
let config: CoreConfigHandle
|
|
69
|
+
let server: ManagedServer
|
|
70
|
+
|
|
71
|
+
beforeEach(async () => {
|
|
72
|
+
dir = await mkdtemp(join(tmpdir(), "yzng-server-"))
|
|
73
|
+
logger = quietLogger()
|
|
74
|
+
const store = new ConfigStore({ dir, logger, watch: false })
|
|
75
|
+
config = await defineCoreConfig(store)
|
|
76
|
+
server = await createManagedServer({ config, logger })
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
afterEach(async () => {
|
|
80
|
+
await server.close()
|
|
81
|
+
await rm(dir, { recursive: true, force: true })
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe("路由分发", () => {
|
|
85
|
+
it("裸返回值当响应体,状态码 200", async () => {
|
|
86
|
+
server.route("/plugin/demo", "GET", "ping", () => ({ ok: true }))
|
|
87
|
+
const res = await server.raw.inject({ method: "GET", url: "/plugin/demo/ping" })
|
|
88
|
+
expect(res.statusCode).toBe(200)
|
|
89
|
+
expect(res.json()).toEqual({ ok: true })
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it("信封形式可以指定状态码与响应头", async () => {
|
|
93
|
+
server.route("/plugin/demo", "POST", "create", () => ({
|
|
94
|
+
status: 201,
|
|
95
|
+
headers: { "x-made-by": "demo" },
|
|
96
|
+
body: { id: 7 }
|
|
97
|
+
}))
|
|
98
|
+
const res = await server.raw.inject({
|
|
99
|
+
method: "POST",
|
|
100
|
+
url: "/plugin/demo/create",
|
|
101
|
+
payload: { name: "x" }
|
|
102
|
+
})
|
|
103
|
+
expect(res.statusCode).toBe(201)
|
|
104
|
+
expect(res.headers["x-made-by"]).toBe("demo")
|
|
105
|
+
expect(res.json()).toEqual({ id: 7 })
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it("返回 undefined 是 204", async () => {
|
|
109
|
+
server.route("/plugin/demo", "DELETE", "thing", () => undefined)
|
|
110
|
+
const res = await server.raw.inject({ method: "DELETE", url: "/plugin/demo/thing" })
|
|
111
|
+
expect(res.statusCode).toBe(204)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it("路径参数与通配段都能拿到", async () => {
|
|
115
|
+
server.route("/plugin/demo", "GET", "file/:name/*rest", req => req.params)
|
|
116
|
+
const res = await server.raw.inject({ method: "GET", url: "/plugin/demo/file/a/b/c" })
|
|
117
|
+
expect(res.json()).toEqual({ name: "a", rest: "b/c" })
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it("Uint8Array 走二进制直发而不是被 JSON 序列化", async () => {
|
|
121
|
+
server.route("/plugin/demo", "GET", "bin", () => new Uint8Array([1, 2, 3]))
|
|
122
|
+
const res = await server.raw.inject({ method: "GET", url: "/plugin/demo/bin" })
|
|
123
|
+
expect(res.statusCode).toBe(200)
|
|
124
|
+
expect([...res.rawPayload]).toEqual([1, 2, 3])
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("处理函数抛错回 500,抛 statusCode 时按它回", async () => {
|
|
128
|
+
server.route("/plugin/demo", "GET", "boom", () => {
|
|
129
|
+
throw new Error("处理失败")
|
|
130
|
+
})
|
|
131
|
+
server.route("/plugin/demo", "GET", "teapot", () => {
|
|
132
|
+
const err = new Error("我是茶壶") as Error & { statusCode: number }
|
|
133
|
+
err.statusCode = 418
|
|
134
|
+
throw err
|
|
135
|
+
})
|
|
136
|
+
const boom = await server.raw.inject({ method: "GET", url: "/plugin/demo/boom" })
|
|
137
|
+
expect(boom.statusCode).toBe(500)
|
|
138
|
+
expect(boom.json()).toEqual({ error: "处理失败" })
|
|
139
|
+
const teapot = await server.raw.inject({ method: "GET", url: "/plugin/demo/teapot" })
|
|
140
|
+
expect(teapot.statusCode).toBe(418)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it("Disposer 摘除路由后返回 404", async () => {
|
|
144
|
+
const off = server.route("/plugin/demo", "GET", "gone", () => "hi")
|
|
145
|
+
expect((await server.raw.inject({ method: "GET", url: "/plugin/demo/gone" })).statusCode).toBe(200)
|
|
146
|
+
off()
|
|
147
|
+
expect((await server.raw.inject({ method: "GET", url: "/plugin/demo/gone" })).statusCode).toBe(404)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it("同一模式重复注册会抛错,摘除后可再次注册", () => {
|
|
151
|
+
const off = server.route("/plugin/demo", "GET", "dup", () => 1)
|
|
152
|
+
expect(() => server.route("/plugin/demo", "GET", "dup", () => 2)).toThrow(/已经注册过了/)
|
|
153
|
+
off()
|
|
154
|
+
expect(() => server.route("/plugin/demo", "GET", "dup", () => 2)).not.toThrow()
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it("HEAD 没单独注册时回落到 GET", async () => {
|
|
158
|
+
server.route("/plugin/demo", "GET", "page", () => ({ ok: true }))
|
|
159
|
+
const res = await server.raw.inject({ method: "HEAD", url: "/plugin/demo/page" })
|
|
160
|
+
expect(res.statusCode).toBe(200)
|
|
161
|
+
// 仅断言响应头:真实网络下 Node 的 ServerResponse 会自动丢弃 HEAD 的响应体,
|
|
162
|
+
// 但注入接口不经由真实 socket,body 仍然留存于缓冲中
|
|
163
|
+
expect(res.headers["content-type"]).toMatch(/application\/json/)
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
describe("方法协商", () => {
|
|
168
|
+
it("路径不存在是 404,方法不对是 405 并带 Allow", async () => {
|
|
169
|
+
server.route("/plugin/demo", "GET", "thing", () => "ok")
|
|
170
|
+
const missing = await server.raw.inject({ method: "GET", url: "/nope" })
|
|
171
|
+
expect(missing.statusCode).toBe(404)
|
|
172
|
+
const wrong = await server.raw.inject({ method: "PUT", url: "/plugin/demo/thing" })
|
|
173
|
+
expect(wrong.statusCode).toBe(405)
|
|
174
|
+
expect(wrong.headers.allow).toBe("GET, HEAD, OPTIONS")
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it("OPTIONS 由服务器代劳,回 204 + Allow", async () => {
|
|
178
|
+
server.route("/plugin/demo", "GET", "thing", () => "ok")
|
|
179
|
+
server.route("/plugin/demo", "POST", "thing", () => "ok")
|
|
180
|
+
const res = await server.raw.inject({ method: "OPTIONS", url: "/plugin/demo/thing" })
|
|
181
|
+
expect(res.statusCode).toBe(204)
|
|
182
|
+
expect(res.headers.allow).toBe("GET, POST, HEAD, OPTIONS")
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
describe("鉴权", () => {
|
|
187
|
+
it("没配令牌时放行本机", async () => {
|
|
188
|
+
server.route("/api", "GET", "state", () => ({ ok: true }))
|
|
189
|
+
const res = await server.raw.inject({ method: "GET", url: "/api/state", remoteAddress: "127.0.0.1" })
|
|
190
|
+
expect(res.statusCode).toBe(200)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it("没配令牌时拒绝外部,并说明该去配什么", async () => {
|
|
194
|
+
server.route("/api", "GET", "state", () => ({ ok: true }))
|
|
195
|
+
const res = await server.raw.inject({ method: "GET", url: "/api/state", remoteAddress: "10.0.0.9" })
|
|
196
|
+
expect(res.statusCode).toBe(401)
|
|
197
|
+
expect(String(res.json().error)).toContain("server.token")
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it("配了令牌后本机也要带令牌", async () => {
|
|
201
|
+
await config.patch({ server: { token: "0123456789abcdef" } }, "api")
|
|
202
|
+
server.route("/api", "GET", "state", () => ({ ok: true }))
|
|
203
|
+
|
|
204
|
+
const bare = await server.raw.inject({ method: "GET", url: "/api/state" })
|
|
205
|
+
expect(bare.statusCode).toBe(401)
|
|
206
|
+
|
|
207
|
+
const wrong = await server.raw.inject({
|
|
208
|
+
method: "GET",
|
|
209
|
+
url: "/api/state",
|
|
210
|
+
headers: { authorization: "Bearer nope" }
|
|
211
|
+
})
|
|
212
|
+
expect(wrong.statusCode).toBe(403)
|
|
213
|
+
|
|
214
|
+
const right = await server.raw.inject({
|
|
215
|
+
method: "GET",
|
|
216
|
+
url: "/api/state",
|
|
217
|
+
headers: { authorization: "Bearer 0123456789abcdef" }
|
|
218
|
+
})
|
|
219
|
+
expect(right.statusCode).toBe(200)
|
|
220
|
+
|
|
221
|
+
const alt = await server.raw.inject({
|
|
222
|
+
method: "GET",
|
|
223
|
+
url: "/api/state",
|
|
224
|
+
headers: { "x-yunzai-token": "0123456789abcdef" }
|
|
225
|
+
})
|
|
226
|
+
expect(alt.statusCode).toBe(200)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it("auth: false 的路由不受令牌影响,适配器 webhook 靠这个", async () => {
|
|
230
|
+
await config.patch({ server: { token: "0123456789abcdef" } }, "api")
|
|
231
|
+
server.route("/plugin/napcat", "POST", "hook", () => ({ ok: true }), { auth: false })
|
|
232
|
+
const res = await server.raw.inject({
|
|
233
|
+
method: "POST",
|
|
234
|
+
url: "/plugin/napcat/hook",
|
|
235
|
+
payload: { post_type: "message" },
|
|
236
|
+
remoteAddress: "10.0.0.9"
|
|
237
|
+
})
|
|
238
|
+
expect(res.statusCode).toBe(200)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it("需要鉴权的写接口挡掉可跨站伪造的 Content-Type", async () => {
|
|
242
|
+
server.route("/api", "POST", "config", () => ({ ok: true }))
|
|
243
|
+
const res = await server.raw.inject({
|
|
244
|
+
method: "POST",
|
|
245
|
+
url: "/api/config",
|
|
246
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
247
|
+
payload: "a=1"
|
|
248
|
+
})
|
|
249
|
+
expect(res.statusCode).toBe(415)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it("查询串里的令牌不算数:那正是 auth.ts 第 1 条刻意排除的来源", async () => {
|
|
253
|
+
await config.patch({ server: { token: "0123456789abcdef" } }, "api")
|
|
254
|
+
server.route("/api", "GET", "state", () => ({ ok: true }))
|
|
255
|
+
const res = await server.raw.inject({ method: "GET", url: "/api/state?token=0123456789abcdef" })
|
|
256
|
+
expect(res.statusCode).toBe(401)
|
|
257
|
+
})
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
describe("请求体", () => {
|
|
261
|
+
it("JSON 正常解析,且原型污染被中和", async () => {
|
|
262
|
+
let seen: unknown
|
|
263
|
+
server.route("/plugin/demo", "POST", "echo", req => {
|
|
264
|
+
seen = req.body
|
|
265
|
+
return { ok: true }
|
|
266
|
+
})
|
|
267
|
+
const res = await server.raw.inject({
|
|
268
|
+
method: "POST",
|
|
269
|
+
url: "/plugin/demo/echo",
|
|
270
|
+
payload: '{"a":1,"__proto__":{"polluted":true}}',
|
|
271
|
+
headers: { "content-type": "application/json" }
|
|
272
|
+
})
|
|
273
|
+
expect(res.statusCode).toBe(200)
|
|
274
|
+
expect(seen).toEqual({ a: 1 })
|
|
275
|
+
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it("空请求体是 undefined 而不是解析失败", async () => {
|
|
279
|
+
let seen: unknown = "未赋值"
|
|
280
|
+
server.route("/plugin/demo", "POST", "empty", req => {
|
|
281
|
+
seen = req.body
|
|
282
|
+
return { ok: true }
|
|
283
|
+
})
|
|
284
|
+
const res = await server.raw.inject({ method: "POST", url: "/plugin/demo/empty" })
|
|
285
|
+
expect(res.statusCode).toBe(200)
|
|
286
|
+
expect(seen).toBeUndefined()
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it("没有 Content-Type 时也按 JSON 试一次", async () => {
|
|
290
|
+
let seen: unknown
|
|
291
|
+
server.route("/plugin/demo", "POST", "guess", req => {
|
|
292
|
+
seen = req.body
|
|
293
|
+
return { ok: true }
|
|
294
|
+
})
|
|
295
|
+
const res = await server.raw.inject({
|
|
296
|
+
method: "POST",
|
|
297
|
+
url: "/plugin/demo/guess",
|
|
298
|
+
payload: '{"a":1}'
|
|
299
|
+
})
|
|
300
|
+
expect(res.statusCode).toBe(200)
|
|
301
|
+
expect(seen).toEqual({ a: 1 })
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it("超过 bodyLimit 回 413", async () => {
|
|
305
|
+
server.route("/plugin/demo", "POST", "small", () => ({ ok: true }), { bodyLimit: 16 })
|
|
306
|
+
const res = await server.raw.inject({
|
|
307
|
+
method: "POST",
|
|
308
|
+
url: "/plugin/demo/small",
|
|
309
|
+
payload: { pad: "x".repeat(200) },
|
|
310
|
+
headers: { "content-type": "application/json" }
|
|
311
|
+
})
|
|
312
|
+
expect(res.statusCode).toBe(413)
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it("rawBody 为真时能拿到原始字节,为假时拿不到", async () => {
|
|
316
|
+
let withRaw: Uint8Array | undefined
|
|
317
|
+
let withoutRaw: Uint8Array | undefined
|
|
318
|
+
server.route(
|
|
319
|
+
"/plugin/demo",
|
|
320
|
+
"POST",
|
|
321
|
+
"raw",
|
|
322
|
+
req => {
|
|
323
|
+
withRaw = req.rawBody
|
|
324
|
+
return { ok: true }
|
|
325
|
+
},
|
|
326
|
+
{ rawBody: true }
|
|
327
|
+
)
|
|
328
|
+
server.route("/plugin/demo", "POST", "norm", req => {
|
|
329
|
+
withoutRaw = req.rawBody
|
|
330
|
+
return { ok: true }
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
await server.raw.inject({
|
|
334
|
+
method: "POST",
|
|
335
|
+
url: "/plugin/demo/raw",
|
|
336
|
+
payload: '{"a":1}',
|
|
337
|
+
headers: { "content-type": "application/json" }
|
|
338
|
+
})
|
|
339
|
+
await server.raw.inject({
|
|
340
|
+
method: "POST",
|
|
341
|
+
url: "/plugin/demo/norm",
|
|
342
|
+
payload: '{"a":1}',
|
|
343
|
+
headers: { "content-type": "application/json" }
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
expect(withRaw).toBeInstanceOf(Uint8Array)
|
|
347
|
+
expect(Buffer.from(withRaw ?? new Uint8Array()).toString("utf8")).toBe('{"a":1}')
|
|
348
|
+
expect(withoutRaw).toBeUndefined()
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it("表单请求体在 auth: false 的路由上解析成无原型对象", async () => {
|
|
352
|
+
let seen: unknown
|
|
353
|
+
server.route(
|
|
354
|
+
"/plugin/napcat",
|
|
355
|
+
"POST",
|
|
356
|
+
"form",
|
|
357
|
+
req => {
|
|
358
|
+
seen = req.body
|
|
359
|
+
return { ok: true }
|
|
360
|
+
},
|
|
361
|
+
{ auth: false }
|
|
362
|
+
)
|
|
363
|
+
await server.raw.inject({
|
|
364
|
+
method: "POST",
|
|
365
|
+
url: "/plugin/napcat/form",
|
|
366
|
+
payload: "a=1&b=2",
|
|
367
|
+
headers: { "content-type": "application/x-www-form-urlencoded" }
|
|
368
|
+
})
|
|
369
|
+
expect(seen).toEqual({ a: "1", b: "2" })
|
|
370
|
+
expect(Object.getPrototypeOf(seen)).toBeNull()
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
describe("静态资源", () => {
|
|
375
|
+
let web: string
|
|
376
|
+
|
|
377
|
+
beforeEach(async () => {
|
|
378
|
+
web = join(dir, "web")
|
|
379
|
+
await mkdir(join(web, "assets"), { recursive: true })
|
|
380
|
+
await writeFile(join(web, "index.html"), "<html>面板</html>", "utf8")
|
|
381
|
+
await writeFile(join(web, "assets", "app.js"), "console.error(1)", "utf8")
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
it("挂载根路径时同时命中 `/` 与子路径", async () => {
|
|
385
|
+
server.panel("plugin:webui", web)
|
|
386
|
+
const root = await server.raw.inject({ method: "GET", url: "/" })
|
|
387
|
+
expect(root.statusCode).toBe(200)
|
|
388
|
+
expect(root.body).toContain("面板")
|
|
389
|
+
|
|
390
|
+
const asset = await server.raw.inject({ method: "GET", url: "/assets/app.js" })
|
|
391
|
+
expect(asset.statusCode).toBe(200)
|
|
392
|
+
expect(asset.headers["content-type"]).toMatch(/javascript/)
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it("单页回退只对看起来像页面的路径生效", async () => {
|
|
396
|
+
server.panel("plugin:webui", web)
|
|
397
|
+
const page = await server.raw.inject({ method: "GET", url: "/plugins/list" })
|
|
398
|
+
expect(page.statusCode).toBe(200)
|
|
399
|
+
expect(page.body).toContain("面板")
|
|
400
|
+
|
|
401
|
+
// 带扩展名的缺失资源必须是 404,否则浏览器拿到 HTML 会报一个方向完全跑偏的错
|
|
402
|
+
const missing = await server.raw.inject({ method: "GET", url: "/assets/missing.js" })
|
|
403
|
+
expect(missing.statusCode).toBe(404)
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it("静态资源不需要令牌,但同前缀的 API 需要", async () => {
|
|
407
|
+
await config.patch({ server: { token: "0123456789abcdef" } }, "api")
|
|
408
|
+
server.panel("plugin:webui", web)
|
|
409
|
+
server.route("/api", "GET", "state", () => ({ ok: true }))
|
|
410
|
+
|
|
411
|
+
expect((await server.raw.inject({ method: "GET", url: "/" })).statusCode).toBe(200)
|
|
412
|
+
expect((await server.raw.inject({ method: "GET", url: "/api/state" })).statusCode).toBe(401)
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it("路径穿越拿不到挂载目录之外的文件", async () => {
|
|
416
|
+
await writeFile(join(dir, "secret.txt"), "令牌在这里", "utf8")
|
|
417
|
+
server.static("/", "", web)
|
|
418
|
+
for (const url of ["/../secret.txt", "/..%2Fsecret.txt", "/%2e%2e/secret.txt"]) {
|
|
419
|
+
const res = await server.raw.inject({ method: "GET", url })
|
|
420
|
+
expect(res.statusCode).not.toBe(200)
|
|
421
|
+
}
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
it("相对目录直接拒掉", () => {
|
|
425
|
+
expect(() => server.static("/", "", "web")).toThrow(/绝对路径/)
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
it("更具体的挂载优先于根挂载", async () => {
|
|
429
|
+
const docs = join(dir, "docs")
|
|
430
|
+
await mkdir(docs, { recursive: true })
|
|
431
|
+
await writeFile(join(docs, "index.html"), "<html>文档</html>", "utf8")
|
|
432
|
+
server.panel("plugin:webui", web)
|
|
433
|
+
server.static("/docs", "", docs)
|
|
434
|
+
const res = await server.raw.inject({ method: "GET", url: "/docs" })
|
|
435
|
+
expect(res.body).toContain("文档")
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
it("面板归属可查,重复接管抛错", async () => {
|
|
439
|
+
expect(server.claimant("/")).toBeUndefined()
|
|
440
|
+
server.panel("plugin:webui", web)
|
|
441
|
+
expect(server.claimant("/")).toBe("plugin:webui")
|
|
442
|
+
// 第二个调用方撞在同一个路径模式上:静默覆盖会让"面板显示的是谁的页面"无法判定。
|
|
443
|
+
// 归属一律形如 `plugin:<名>` —— 内核自身从不挂面板
|
|
444
|
+
expect(() => server.panel("plugin:webui-alt", web)).toThrow()
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it("插件接管后归属指向该插件", () => {
|
|
448
|
+
server.panel("plugin:webui-next", web)
|
|
449
|
+
expect(server.claimant("/")).toBe("plugin:webui-next")
|
|
450
|
+
})
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
describe("WebSocket", () => {
|
|
454
|
+
it("收发一条消息,并计入连接数", async () => {
|
|
455
|
+
server.websocket("/plugin/demo", "echo", conn => {
|
|
456
|
+
conn.onMessage(data => {
|
|
457
|
+
conn.send(`收到 ${String(data)}`)
|
|
458
|
+
})
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
const ws = await server.raw.injectWS("/plugin/demo/echo", upgradeCtx())
|
|
462
|
+
expect(server.connections).toBe(1)
|
|
463
|
+
const got = await new Promise<string>(resolve => {
|
|
464
|
+
ws.on("message", (raw: unknown) => {
|
|
465
|
+
resolve(String(raw))
|
|
466
|
+
})
|
|
467
|
+
ws.send("你好")
|
|
468
|
+
})
|
|
469
|
+
expect(got).toBe("收到 你好")
|
|
470
|
+
ws.terminate()
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
it("未注册的路径回真正的 HTTP 404,而不是一次没理由的断线", async () => {
|
|
474
|
+
await expect(server.raw.injectWS("/plugin/demo/nope", upgradeCtx())).rejects.toThrow(
|
|
475
|
+
/Unexpected server response: 404/
|
|
476
|
+
)
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
it("配了令牌后缺令牌回 401,子协议里带上就通", async () => {
|
|
480
|
+
await config.patch({ server: { token: "0123456789abcdef" } }, "api")
|
|
481
|
+
server.websocket("/plugin/demo", "logs", () => undefined)
|
|
482
|
+
|
|
483
|
+
await expect(server.raw.injectWS("/plugin/demo/logs", upgradeCtx())).rejects.toThrow(
|
|
484
|
+
/Unexpected server response: 401/
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
// 前端只能以此方式携带令牌:浏览器的 WebSocket 构造函数无法设置请求头(见 auth.ts 第 2 条)
|
|
488
|
+
const ws = await server.raw.injectWS("/plugin/demo/logs", upgradeCtx({ "sec-websocket-protocol": "yunzai, 0123456789abcdef" }))
|
|
489
|
+
expect(server.connections).toBe(1)
|
|
490
|
+
ws.terminate()
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
it("自定义 verify 完全接管校验,返回 false 时回 401", async () => {
|
|
494
|
+
server.websocket(
|
|
495
|
+
"/plugin/napcat",
|
|
496
|
+
"reverse",
|
|
497
|
+
() => undefined,
|
|
498
|
+
{ verify: req => req.headers["x-self-id"] === "10000" }
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
await expect(server.raw.injectWS("/plugin/napcat/reverse", upgradeCtx())).rejects.toThrow(
|
|
502
|
+
/Unexpected server response: 401/
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
const ws = await server.raw.injectWS("/plugin/napcat/reverse", upgradeCtx({ "x-self-id": "10000" }))
|
|
506
|
+
expect(server.connections).toBe(1)
|
|
507
|
+
ws.terminate()
|
|
508
|
+
})
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
describe("面板内省", () => {
|
|
512
|
+
it("列出路由、端点与静态挂载", () => {
|
|
513
|
+
server.route("/api", "GET", "state", () => 1)
|
|
514
|
+
server.route("/plugin/napcat", "POST", "hook", () => 1, { auth: false })
|
|
515
|
+
server.websocket("/api", "logs", () => undefined)
|
|
516
|
+
server.static("/docs", "", join(dir, "docs"))
|
|
517
|
+
|
|
518
|
+
expect(server.listRoutes()).toEqual(
|
|
519
|
+
expect.arrayContaining([
|
|
520
|
+
{ method: "GET", pattern: "/api/state", scope: "/api", auth: true },
|
|
521
|
+
{ method: "POST", pattern: "/plugin/napcat/hook", scope: "/plugin/napcat", auth: false }
|
|
522
|
+
])
|
|
523
|
+
)
|
|
524
|
+
expect(server.listWebsockets()).toEqual([{ pattern: "/api/logs", scope: "/api" }])
|
|
525
|
+
expect(server.listStatic()).toEqual([{ pattern: "/docs/*rest", dir: join(dir, "docs"), spa: false }])
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
it("info 反映配置,publicUrl 把通配地址换成能点开的形式", async () => {
|
|
529
|
+
expect(server.info.enabled).toBe(false)
|
|
530
|
+
expect(server.info.publicUrl).toBe("http://127.0.0.1:2536")
|
|
531
|
+
await config.patch({ server: { host: "0.0.0.0", port: 3000 } }, "api")
|
|
532
|
+
expect(server.info.publicUrl).toBe("http://127.0.0.1:3000")
|
|
533
|
+
})
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
describe("生命周期", () => {
|
|
537
|
+
it("非本机监听时自动生成并落盘令牌,本机则不生成", async () => {
|
|
538
|
+
await server.ensureToken()
|
|
539
|
+
expect(config.get().server.token).toBeUndefined()
|
|
540
|
+
|
|
541
|
+
await config.patch({ server: { host: "0.0.0.0" } }, "api")
|
|
542
|
+
await server.ensureToken()
|
|
543
|
+
const token = config.get().server.token
|
|
544
|
+
expect(typeof token).toBe("string")
|
|
545
|
+
expect((token ?? "").length).toBeGreaterThanOrEqual(16)
|
|
546
|
+
expect(logger.lines.some(line => line.startsWith("warn") && line.includes(token ?? "!"))).toBe(true)
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
it("close 幂等,且关闭后不能再注册", async () => {
|
|
550
|
+
server.route("/api", "GET", "state", () => 1)
|
|
551
|
+
await server.close()
|
|
552
|
+
await server.close()
|
|
553
|
+
expect(server.listRoutes()).toEqual([])
|
|
554
|
+
expect(() => server.route("/api", "GET", "other", () => 1)).toThrow(/已关闭/)
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
it("真的监听时 info 给出实际端口", async () => {
|
|
558
|
+
// 配置 schema 不接受 port 0,所以自己挑一个高位端口而不是让内核分配
|
|
559
|
+
const port = 40000 + Math.floor(Math.random() * 20000)
|
|
560
|
+
await config.patch({ server: { port } }, "api")
|
|
561
|
+
await server.listen()
|
|
562
|
+
expect(server.info.enabled).toBe(true)
|
|
563
|
+
expect(server.info.port).toBe(port)
|
|
564
|
+
})
|
|
565
|
+
})
|
|
566
|
+
})
|