@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,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模块职责:面板与插件路由的访问控制 —— 令牌生成、校验、跨站伪造防线
|
|
3
|
+
* 依赖方向:只依赖 node:crypto 与类型包
|
|
4
|
+
* 生命周期:纯函数,无状态;令牌本身存在配置里
|
|
5
|
+
* 注意事项:四条安全约定 ——
|
|
6
|
+
*
|
|
7
|
+
* **令牌只从请求头读,绝不从查询串或 Cookie 读。** 查询串会进 access log、浏览器历史与
|
|
8
|
+
* Referer;Cookie 会被浏览器自动附带,那正是 CSRF 的成因。头部令牌没有「自动附带」这回事,
|
|
9
|
+
* 故本服务器天然免疫 CSRF,也因此不需要 CSRF token。代价是 `EventSource` 用不了(它设不了
|
|
10
|
+
* 请求头),故日志实时推送走 WebSocket。
|
|
11
|
+
*
|
|
12
|
+
* **浏览器 WebSocket 同样设不了请求头**,故允许把令牌放进 `Sec-WebSocket-Protocol`。
|
|
13
|
+
* 它是请求头的一部分,同样不会被自动附带。
|
|
14
|
+
*
|
|
15
|
+
* **比较走 SHA-256 摘要 + `timingSafeEqual`。** 直接对原文用后者会在长度不等时抛错,
|
|
16
|
+
* 等于把「长度对不对」变成一个旁路信号;先摘要则两边恒为 32 字节。
|
|
17
|
+
*
|
|
18
|
+
* **没设令牌时只放行本机**,判据是 TCP 对端地址,且服务器强制 `trustProxy: false` ——
|
|
19
|
+
* 否则任何人都能靠一个 `X-Forwarded-For: 127.0.0.1` 把自己伪装成本机。
|
|
20
|
+
*/
|
|
21
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto"
|
|
22
|
+
|
|
23
|
+
/** 自动生成令牌的字节数:24 字节 → base64url 32 字符,既可抵御爆破又便于人工转录 */
|
|
24
|
+
const TOKEN_BYTES = 24
|
|
25
|
+
|
|
26
|
+
/** 令牌请求头(`Authorization: Bearer` 之外的简易写法) */
|
|
27
|
+
const TOKEN_HEADER = "x-yunzai-token"
|
|
28
|
+
|
|
29
|
+
/** WebSocket 子协议里约定的第一个标识,第二个才是令牌 */
|
|
30
|
+
export const WS_PROTOCOL = "yunzai"
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 会被跨站表单 / 简单请求造出来的请求体类型
|
|
34
|
+
*
|
|
35
|
+
* 这三种 `Content-Type` 不触发预检,因此一个恶意页面可以在用户不知情的情况下
|
|
36
|
+
* 向 `127.0.0.1` 发出携带它们的 POST。面板的写接口一律只收 JSON,于是把这三种
|
|
37
|
+
* 直接挡在门外 —— 挡的不是"错误的格式",是"唯一一条能绕开同源策略的路"。
|
|
38
|
+
*/
|
|
39
|
+
const FORGEABLE_TYPES = ["application/x-www-form-urlencoded", "multipart/form-data", "text/plain"]
|
|
40
|
+
|
|
41
|
+
/** 会改变状态、因此需要防伪造的方法 */
|
|
42
|
+
const UNSAFE_METHODS = ["POST", "PUT", "PATCH", "DELETE"]
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 生成一个新的访问令牌
|
|
46
|
+
*
|
|
47
|
+
* 用 `randomBytes` 而**不是** `util/id.ts` 的 `randomId()`:后者对随机字节取模
|
|
48
|
+
* 引入了可忽略但真实存在的分布偏差,其文档里也写明"不用于密码学场景"。
|
|
49
|
+
* 令牌正是密码学场景。
|
|
50
|
+
* @returns base64url 编码的 32 字符令牌
|
|
51
|
+
*/
|
|
52
|
+
export function generateToken(): string {
|
|
53
|
+
return randomBytes(TOKEN_BYTES).toString("base64url")
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 定时安全地比较两个令牌
|
|
58
|
+
* @param expected 配置里的令牌
|
|
59
|
+
* @param given 请求带来的令牌
|
|
60
|
+
* @returns 是否一致
|
|
61
|
+
*/
|
|
62
|
+
export function tokenEquals(expected: string, given: string): boolean {
|
|
63
|
+
const a = createHash("sha256").update(expected, "utf8").digest()
|
|
64
|
+
const b = createHash("sha256").update(given, "utf8").digest()
|
|
65
|
+
return timingSafeEqual(a, b)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 判断一个对端地址是否是本机
|
|
70
|
+
*
|
|
71
|
+
* IPv4 的整个 `127.0.0.0/8` 都是回环,不只是 `127.0.0.1`;Node 在双栈监听下
|
|
72
|
+
* 会把 IPv4 对端写成 `::ffff:127.0.0.1` 这种映射形式。两者都要认。
|
|
73
|
+
* @param address 对端地址;unix socket 等场景可能是空串
|
|
74
|
+
* @returns 是否本机
|
|
75
|
+
*/
|
|
76
|
+
export function isLoopbackAddress(address: string): boolean {
|
|
77
|
+
if (address === "") return false
|
|
78
|
+
const ip = address.startsWith("::ffff:") ? address.slice(7) : address
|
|
79
|
+
if (ip === "::1" || ip === "localhost") return true
|
|
80
|
+
if (!ip.includes(".")) return false
|
|
81
|
+
return ip.startsWith("127.")
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 判断配置中填写的监听地址是否仅对本机可见
|
|
86
|
+
*
|
|
87
|
+
* `ensureToken()` 据此决定是否强制生成令牌:仅监听本机时不生成,
|
|
88
|
+
* 以免单机部署的使用者被一串随机字符阻挡在面板之外。
|
|
89
|
+
* @param host 监听地址
|
|
90
|
+
* @returns 是否只对本机可见
|
|
91
|
+
*/
|
|
92
|
+
export function isLocalHost(host: string): boolean {
|
|
93
|
+
return host === "localhost" || isLoopbackAddress(host)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 从请求头里取出令牌
|
|
98
|
+
*
|
|
99
|
+
* 三个来源按优先级:`Authorization: Bearer x` → `x-yunzai-token: x` →
|
|
100
|
+
* `Sec-WebSocket-Protocol: yunzai, x`。刻意**不看**查询串与 Cookie,见文件头第 1 条。
|
|
101
|
+
* @param headers 已小写化键名的请求头
|
|
102
|
+
* @returns 令牌;没带时 undefined
|
|
103
|
+
*/
|
|
104
|
+
export function readToken(headers: Readonly<Record<string, string | string[] | undefined>>): string | undefined {
|
|
105
|
+
const auth = firstValue(headers.authorization)
|
|
106
|
+
if (auth !== undefined) {
|
|
107
|
+
const match = /^Bearer\s+(.+)$/i.exec(auth.trim())
|
|
108
|
+
if (match?.[1] !== undefined) return match[1].trim()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const plain = firstValue(headers[TOKEN_HEADER])
|
|
112
|
+
if (plain !== undefined && plain !== "") return plain.trim()
|
|
113
|
+
|
|
114
|
+
const protocols = firstValue(headers["sec-websocket-protocol"])
|
|
115
|
+
if (protocols !== undefined) {
|
|
116
|
+
const parts = protocols.split(",").map(part => part.trim())
|
|
117
|
+
if (parts[0] === WS_PROTOCOL && parts[1] !== undefined && parts[1] !== "") return parts[1]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return undefined
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 取请求头的第一个值
|
|
125
|
+
*
|
|
126
|
+
* Node 对重复出现的请求头会给出数组。取第一个而非拼接:令牌被写入两次时,
|
|
127
|
+
* 拼接得到的字符串必定校验失败,而报错信息会指向"令牌错误"而非"令牌重复出现"。
|
|
128
|
+
* @param value 头值
|
|
129
|
+
* @returns 首个值;不存在时 undefined
|
|
130
|
+
*/
|
|
131
|
+
function firstValue(value: string | string[] | undefined): string | undefined {
|
|
132
|
+
if (value === undefined) return undefined
|
|
133
|
+
return Array.isArray(value) ? value[0] : value
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 鉴权失败的描述 */
|
|
137
|
+
export interface AuthFailure {
|
|
138
|
+
/** 建议的 HTTP 状态码 */
|
|
139
|
+
readonly status: number
|
|
140
|
+
/** 给人看的原因,会直接写进响应体 */
|
|
141
|
+
readonly message: string
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 一次鉴权要用到的请求信息 */
|
|
145
|
+
export interface AuthRequest {
|
|
146
|
+
/** 对端地址(必须是 TCP 对端,不能是 `X-Forwarded-For`) */
|
|
147
|
+
readonly ip: string
|
|
148
|
+
/** 请求方法 */
|
|
149
|
+
readonly method: string
|
|
150
|
+
/** 已小写化键名的请求头 */
|
|
151
|
+
readonly headers: Readonly<Record<string, string | string[] | undefined>>
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 校验一次访问
|
|
156
|
+
*
|
|
157
|
+
* 规则:
|
|
158
|
+
* - 已配置令牌 → 一律要求携带正确令牌,**本机亦不例外**。本机进程与使用者浏览器中
|
|
159
|
+
* 的任意页面均可访问 `127.0.0.1`,"本机"并不等同于"可信"。
|
|
160
|
+
* - 未配置令牌 → 仅放行本机对端;外部访问直接拒绝,并说明需要配置的内容。
|
|
161
|
+
* @param token 配置中的令牌;未设置时 undefined
|
|
162
|
+
* @param req 请求信息
|
|
163
|
+
* @returns 校验失败时的描述;通过时 undefined
|
|
164
|
+
*/
|
|
165
|
+
export function checkAuth(token: string | undefined, req: AuthRequest): AuthFailure | undefined {
|
|
166
|
+
if (token === undefined || token === "") {
|
|
167
|
+
if (isLoopbackAddress(req.ip)) return undefined
|
|
168
|
+
return {
|
|
169
|
+
status: 401,
|
|
170
|
+
message: "本服务器尚未设置访问令牌,因此只接受本机访问。请在 config/yunzai.yaml 的 server.token 里设置令牌后重启"
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const given = readToken(req.headers)
|
|
175
|
+
if (given === undefined) {
|
|
176
|
+
return {
|
|
177
|
+
status: 401,
|
|
178
|
+
message: "缺少访问令牌。请在请求头带上 Authorization: Bearer <令牌>,或 x-yunzai-token: <令牌>"
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (!tokenEquals(token, given)) return { status: 403, message: "访问令牌不正确" }
|
|
182
|
+
return undefined
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 校验请求体类型,挡掉可跨站伪造的写请求
|
|
187
|
+
*
|
|
188
|
+
* 只对需要鉴权的路由生效:适配器 webhook 常用表单编码,它们走 `auth: false`
|
|
189
|
+
* 并自行验签,不该被这条规则牵连。
|
|
190
|
+
* @param req 请求信息
|
|
191
|
+
* @returns 校验失败时的描述;通过时 undefined
|
|
192
|
+
*/
|
|
193
|
+
export function checkForgeableBody(req: AuthRequest): AuthFailure | undefined {
|
|
194
|
+
if (!UNSAFE_METHODS.includes(req.method)) return undefined
|
|
195
|
+
const type = (firstValue(req.headers["content-type"]) ?? "").split(";")[0]?.trim().toLowerCase() ?? ""
|
|
196
|
+
if (type === "" || !FORGEABLE_TYPES.includes(type)) return undefined
|
|
197
|
+
return {
|
|
198
|
+
status: 415,
|
|
199
|
+
message: `${type} 不被接受:需要鉴权的写接口只收 application/json(这条限制是为了挡住跨站伪造请求)`
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模块职责:`browse.ts` 的用例 —— 目录列举、上一级、错误映射与截断
|
|
3
|
+
* 依赖方向:测试文件,依赖被测模块与 node:fs
|
|
4
|
+
* 生命周期:每个用例一个临时目录
|
|
5
|
+
* 注意事项:**用例跑在真实文件系统上,不打桩。** 要验的恰是「Node 的 readdir 在这台
|
|
6
|
+
* 机器上给出什么」——把 fs 打成桩就只是在验证桩自己,而符号链接、悬空链接、
|
|
7
|
+
* 权限错误这几处的行为差异全在真实文件系统一侧。
|
|
8
|
+
*
|
|
9
|
+
* 符号链接一节在 Windows 上需要管理员权限或开发者模式,因此建链接失败时
|
|
10
|
+
* 跳过该用例而不是让它红 —— 那种失败与被测代码无关。
|
|
11
|
+
*/
|
|
12
|
+
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"
|
|
13
|
+
import { tmpdir } from "node:os"
|
|
14
|
+
import { join, sep } from "node:path"
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
|
16
|
+
import { browseDirectory, parentOf } from "./browse.js"
|
|
17
|
+
|
|
18
|
+
describe("目录浏览", () => {
|
|
19
|
+
let dir: string
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
dir = await mkdtemp(join(tmpdir(), "yzng-browse-"))
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
afterEach(async () => {
|
|
26
|
+
await rm(dir, { recursive: true, force: true })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 取成功结果的列举内容
|
|
31
|
+
* @param path 要列举的路径
|
|
32
|
+
* @returns 列举内容
|
|
33
|
+
*/
|
|
34
|
+
async function listing(path: string | undefined): Promise<{
|
|
35
|
+
/** 路径 */
|
|
36
|
+
path: string
|
|
37
|
+
/** 上一级 */
|
|
38
|
+
parent?: string
|
|
39
|
+
/** 条目 */
|
|
40
|
+
entries: readonly { name: string; dir: boolean; link?: boolean }[]
|
|
41
|
+
/** 是否截断 */
|
|
42
|
+
truncated: boolean
|
|
43
|
+
/** 分隔符 */
|
|
44
|
+
sep: string
|
|
45
|
+
}> {
|
|
46
|
+
const result = await browseDirectory(path)
|
|
47
|
+
if (!result.ok) throw new Error(`预期成功,实得 ${result.status}:${result.message}`)
|
|
48
|
+
return result.listing
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
it("目录在前、文件在后,各按名称排序", async () => {
|
|
52
|
+
await mkdir(join(dir, "z-dir"))
|
|
53
|
+
await mkdir(join(dir, "a-dir"))
|
|
54
|
+
await writeFile(join(dir, "z.txt"), "1")
|
|
55
|
+
await writeFile(join(dir, "a.txt"), "1")
|
|
56
|
+
|
|
57
|
+
const out = await listing(dir)
|
|
58
|
+
expect(out.entries.map(e => e.name)).toEqual(["a-dir", "z-dir", "a.txt", "z.txt"])
|
|
59
|
+
expect(out.entries.map(e => e.dir)).toEqual([true, true, false, false])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("数字按数值大小排,f2 在 f10 之前", async () => {
|
|
63
|
+
for (const name of ["f10.txt", "f2.txt", "f1.txt"]) await writeFile(join(dir, name), "1")
|
|
64
|
+
const out = await listing(dir)
|
|
65
|
+
expect(out.entries.map(e => e.name)).toEqual(["f1.txt", "f2.txt", "f10.txt"])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("**中文名整块排在英文名之前** —— CLDR 中文排序把汉字提到拉丁字母之前,非缺陷", async () => {
|
|
69
|
+
for (const name of ["伽马", "阿尔法", "alpha"]) await mkdir(join(dir, name))
|
|
70
|
+
const out = await listing(dir)
|
|
71
|
+
// 汉字之间按拼音:阿尔法(a)在伽马(g)之前
|
|
72
|
+
expect(out.entries.map(e => e.name)).toEqual(["阿尔法", "伽马", "alpha"])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("回的是名字与是否目录,**不含大小、时间与内容**", async () => {
|
|
76
|
+
await writeFile(join(dir, "secret.txt"), "不该出现在响应里")
|
|
77
|
+
const out = await listing(dir)
|
|
78
|
+
const entry = out.entries[0]
|
|
79
|
+
expect(entry).toEqual({ name: "secret.txt", dir: false })
|
|
80
|
+
expect(JSON.stringify(out)).not.toContain("不该出现")
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("给出上一级与本机分隔符", async () => {
|
|
84
|
+
const child = join(dir, "sub")
|
|
85
|
+
await mkdir(child)
|
|
86
|
+
const out = await listing(child)
|
|
87
|
+
expect(out.path).toBe(child)
|
|
88
|
+
expect(out.parent).toBe(dir)
|
|
89
|
+
expect(out.sep).toBe(sep)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it("隐藏项照常列出 —— Linux 上 .config 一类目录正是要找的", async () => {
|
|
93
|
+
await mkdir(join(dir, ".config"))
|
|
94
|
+
const out = await listing(dir)
|
|
95
|
+
expect(out.entries.map(e => e.name)).toContain(".config")
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("路径被规范化,末尾多余的分隔符与 `.` 不影响结果", async () => {
|
|
99
|
+
const out = await listing(`${dir}${sep}.${sep}`)
|
|
100
|
+
expect(out.path).toBe(dir)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("**相对路径一律拒绝**,并说明基准不可见", async () => {
|
|
104
|
+
const result = await browseDirectory("plugins")
|
|
105
|
+
expect(result.ok).toBe(false)
|
|
106
|
+
if (result.ok) return
|
|
107
|
+
expect(result.status).toBe(400)
|
|
108
|
+
expect(result.message).toContain("绝对路径")
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it("不存在回 404、指向文件回 400", async () => {
|
|
112
|
+
const missing = await browseDirectory(join(dir, "无此目录"))
|
|
113
|
+
expect(missing.ok).toBe(false)
|
|
114
|
+
if (!missing.ok) expect(missing.status).toBe(404)
|
|
115
|
+
|
|
116
|
+
const file = join(dir, "f.txt")
|
|
117
|
+
await writeFile(file, "1")
|
|
118
|
+
const notDir = await browseDirectory(file)
|
|
119
|
+
expect(notDir.ok).toBe(false)
|
|
120
|
+
if (!notDir.ok) {
|
|
121
|
+
expect(notDir.status).toBe(400)
|
|
122
|
+
expect(notDir.message).toContain("不是一个目录")
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it("超过 1000 项时截断并标明", async () => {
|
|
127
|
+
await Promise.all(
|
|
128
|
+
Array.from({ length: 1005 }, (_, i) => writeFile(join(dir, `f${String(i).padStart(4, "0")}.txt`), "1"))
|
|
129
|
+
)
|
|
130
|
+
const out = await listing(dir)
|
|
131
|
+
expect(out.entries.length).toBe(1000)
|
|
132
|
+
expect(out.truncated).toBe(true)
|
|
133
|
+
// 先排序再截断,故截到的必是排序后的前 1000 项
|
|
134
|
+
expect(out.entries[0]?.name).toBe("f0000.txt")
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it("未超上限时 truncated 为假", async () => {
|
|
138
|
+
await writeFile(join(dir, "one.txt"), "1")
|
|
139
|
+
expect((await listing(dir)).truncated).toBe(false)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it("指向目录的符号链接算目录,悬空链接不算且不报错", async () => {
|
|
143
|
+
const target = join(dir, "real")
|
|
144
|
+
await mkdir(target)
|
|
145
|
+
try {
|
|
146
|
+
await symlink(target, join(dir, "link-dir"), "dir")
|
|
147
|
+
await symlink(join(dir, "无此物"), join(dir, "link-broken"), "dir")
|
|
148
|
+
} catch {
|
|
149
|
+
// Windows 上建符号链接需要额外权限,与被测代码无关
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
const out = await listing(dir)
|
|
153
|
+
const byName = new Map(out.entries.map(e => [e.name, e]))
|
|
154
|
+
expect(byName.get("link-dir")).toEqual({ name: "link-dir", dir: true, link: true })
|
|
155
|
+
expect(byName.get("link-broken")).toEqual({ name: "link-broken", dir: false, link: true })
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
describe("上一级", () => {
|
|
160
|
+
it("Windows 上盘根的上一级是盘符列表(空串),而不是它自己", () => {
|
|
161
|
+
expect(parentOf("C:\\Users\\x", true)).toBe("C:\\Users")
|
|
162
|
+
expect(parentOf("C:\\", true)).toBe("")
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it("类 Unix 上根目录没有上一级", () => {
|
|
166
|
+
// 在 Windows 上 dirname("/") 同样是 "/",故用 windows=false 走另一条分支
|
|
167
|
+
expect(parentOf("/", false)).toBeUndefined()
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
describe("缺省起点", () => {
|
|
172
|
+
it("不带路径时按平台给出最外层:Windows 列盘符、其余列根目录", async () => {
|
|
173
|
+
const result = await browseDirectory(undefined)
|
|
174
|
+
expect(result.ok).toBe(true)
|
|
175
|
+
if (!result.ok) return
|
|
176
|
+
if (process.platform === "win32") {
|
|
177
|
+
expect(result.listing.path).toBe("")
|
|
178
|
+
expect(result.listing.parent).toBeUndefined()
|
|
179
|
+
// 至少有一个盘,且每个都形如 X:\
|
|
180
|
+
expect(result.listing.entries.length).toBeGreaterThan(0)
|
|
181
|
+
expect(result.listing.entries.every(e => /^[C-Z]:\\$/.test(e.name) && e.dir)).toBe(true)
|
|
182
|
+
} else {
|
|
183
|
+
expect(result.listing.path).toBe("/")
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it("空串与只有空白的路径等同于缺省", async () => {
|
|
188
|
+
const blank = await browseDirectory(" ")
|
|
189
|
+
const none = await browseDirectory(undefined)
|
|
190
|
+
expect(blank.ok).toBe(true)
|
|
191
|
+
if (blank.ok && none.ok) expect(blank.listing.path).toBe(none.listing.path)
|
|
192
|
+
})
|
|
193
|
+
})
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模块职责:只读的目录浏览 —— 面板填写「服务器上的路径」时的候选来源
|
|
3
|
+
* 依赖方向:依赖 node:fs / node:path 与 util/fs;不认识 HTTP,也不认识面板
|
|
4
|
+
* 生命周期:纯函数,无状态
|
|
5
|
+
* 注意事项:**这是内核唯一一处允许外部枚举本机文件系统的地方,故边界写在这里而非调用处。**
|
|
6
|
+
* 它的存在理由只有一个:配置里的 `file` / `dir` 字段要的是服务器上的路径,而浏览器的
|
|
7
|
+
* `<input type="file">` 给不到路径。没有它,那两种 widget 只能让人手敲绝对路径。
|
|
8
|
+
*
|
|
9
|
+
* 四条刻意的限制,都是为了让扩权尽量小:**只回名字与「是不是目录」**,不回大小、时间、
|
|
10
|
+
* 权限、内容(附带好处是不必逐项 `stat`,万条文件的目录会因此慢上百倍);**条目数设上限**
|
|
11
|
+
* 并如实标明截断;**符号链接只跟一层**,且跟不到就当普通文件(`withFileTypes` 是 lstat
|
|
12
|
+
* 语义,指向目录的链接会被判成不是目录、于是点不进去);**不做写操作,也不收相对路径**
|
|
13
|
+
* —— 相对路径的基准是内核的工作目录,而那不是面板看得见的东西。
|
|
14
|
+
*
|
|
15
|
+
* 「只读模式下要禁用这个只读接口」那条在 api.ts —— 那是面板策略,不是文件系统的性质。
|
|
16
|
+
*/
|
|
17
|
+
import { constants as fsConstants, type Dirent } from "node:fs"
|
|
18
|
+
import { access, readdir, stat } from "node:fs/promises"
|
|
19
|
+
import { posix, win32 } from "node:path"
|
|
20
|
+
import { errorCode } from "../util/fs.js"
|
|
21
|
+
|
|
22
|
+
/** 单次最多返回的条目数,见文件头第 2 条 */
|
|
23
|
+
const MAX_ENTRIES = 1000
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 探测盘符的范围
|
|
27
|
+
*
|
|
28
|
+
* 自 C 起而不自 A 起:A、B 是软驱字母,在部分机器上探测它们会让驱动器空转数秒,
|
|
29
|
+
* 而这两个字母上今日不会有内核要访问的目录。
|
|
30
|
+
*/
|
|
31
|
+
const DRIVE_LETTERS = "CDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 取对应平台的路径实现
|
|
35
|
+
*
|
|
36
|
+
* 本模块的每个导出都带 `windows` 参数,于是路径计算也必须跟着这个参数走,不能用
|
|
37
|
+
* `node:path` 的平台默认导出:那些导出绑的是**当前进程**的平台,于是
|
|
38
|
+
* `dirname("C:\\Users\\x")` 在 Linux 上是 `"."`,参数就成了摆设 —— 用例在 Windows 上
|
|
39
|
+
* 全绿、在 Linux 的 CI 上挂。
|
|
40
|
+
* @param windows 是否按 Windows 处理
|
|
41
|
+
* @returns `node:path` 的 win32 或 posix 实现
|
|
42
|
+
*/
|
|
43
|
+
function pathOf(windows: boolean): typeof win32 | typeof posix {
|
|
44
|
+
return windows ? win32 : posix
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 目录里的一项 */
|
|
48
|
+
export interface BrowseEntry {
|
|
49
|
+
/** 名字,不含所在目录 */
|
|
50
|
+
readonly name: string
|
|
51
|
+
/** 是不是目录(符号链接按其指向判定) */
|
|
52
|
+
readonly dir: boolean
|
|
53
|
+
/** 是不是符号链接;不是则不出现 */
|
|
54
|
+
readonly link?: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 一次目录列举的结果 */
|
|
58
|
+
export interface BrowseListing {
|
|
59
|
+
/** 规范化后的绝对路径;Windows 上列举盘符时为空串 */
|
|
60
|
+
readonly path: string
|
|
61
|
+
/** 上一级路径;已在最外层时不出现 */
|
|
62
|
+
readonly parent?: string
|
|
63
|
+
/** 条目,目录在前、各按名称排序 */
|
|
64
|
+
readonly entries: readonly BrowseEntry[]
|
|
65
|
+
/** 条目是否因超过上限而被截断 */
|
|
66
|
+
readonly truncated: boolean
|
|
67
|
+
/**
|
|
68
|
+
* 本机的路径分隔符
|
|
69
|
+
*
|
|
70
|
+
* 一并回给面板,免得前端靠 `navigator.platform` 猜 —— 浏览器所在的机器与内核
|
|
71
|
+
* 所在的机器完全可以是两台,用浏览器的分隔符去拼服务器的路径必然出错。
|
|
72
|
+
*/
|
|
73
|
+
readonly sep: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 列举结果:成功或一条可直接回给面板的失败说明 */
|
|
77
|
+
export type BrowseResult =
|
|
78
|
+
| {
|
|
79
|
+
/** 成功 */
|
|
80
|
+
readonly ok: true
|
|
81
|
+
/** 列举内容 */
|
|
82
|
+
readonly listing: BrowseListing
|
|
83
|
+
}
|
|
84
|
+
| {
|
|
85
|
+
/** 失败 */
|
|
86
|
+
readonly ok: false
|
|
87
|
+
/** 建议的 HTTP 状态码 */
|
|
88
|
+
readonly status: number
|
|
89
|
+
/** 中文说明,可原样回给面板 */
|
|
90
|
+
readonly message: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 错误码 → 状态码与说明 */
|
|
94
|
+
const CODE_TABLE: Record<string, { status: number; text: string }> = {
|
|
95
|
+
ENOENT: { status: 404, text: "不存在" },
|
|
96
|
+
ENOTDIR: { status: 400, text: "不是一个目录" },
|
|
97
|
+
EACCES: { status: 403, text: "没有读取权限" },
|
|
98
|
+
EPERM: { status: 403, text: "没有读取权限" },
|
|
99
|
+
ELOOP: { status: 400, text: "符号链接成环" },
|
|
100
|
+
ENAMETOOLONG: { status: 400, text: "路径过长" }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 上一级路径
|
|
105
|
+
*
|
|
106
|
+
* Windows 上盘根(`C:\`)的上一级是**盘符列表**(以空串表示),而不是它自己 ——
|
|
107
|
+
* 否则面板上的「上一级」在盘根处点不动,也就无从切换到另一个盘。
|
|
108
|
+
* @param target 绝对路径
|
|
109
|
+
* @param windows 是否 Windows
|
|
110
|
+
* @returns 上一级;已在最外层时 undefined
|
|
111
|
+
*/
|
|
112
|
+
export function parentOf(target: string, windows: boolean): string | undefined {
|
|
113
|
+
const up = pathOf(windows).dirname(target)
|
|
114
|
+
if (up !== target) return up
|
|
115
|
+
return windows ? "" : undefined
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 列举现有的盘符
|
|
120
|
+
*
|
|
121
|
+
* Node 没有列举盘符的 API,只能逐个字母试。`access` 而非 `stat`:后者在未插盘的
|
|
122
|
+
* 读卡器上会等到超时,前者只问「能不能访问」。
|
|
123
|
+
* @returns 形如 `C:\` 的盘根数组
|
|
124
|
+
*/
|
|
125
|
+
async function listDrives(): Promise<BrowseEntry[]> {
|
|
126
|
+
const probes = [...DRIVE_LETTERS].map(async letter => {
|
|
127
|
+
const root = `${letter}:\\`
|
|
128
|
+
try {
|
|
129
|
+
await access(root, fsConstants.R_OK)
|
|
130
|
+
return root
|
|
131
|
+
} catch {
|
|
132
|
+
return undefined
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
const found = await Promise.all(probes)
|
|
136
|
+
return found.filter((root): root is string => root !== undefined).map(name => ({ name, dir: true }))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 判定一个条目是不是目录
|
|
141
|
+
*
|
|
142
|
+
* 只对符号链接补 `stat`,见文件头第 3 条。
|
|
143
|
+
* @param dir 所在目录
|
|
144
|
+
* @param entry readdir 给出的条目
|
|
145
|
+
* @param windows 是否按 Windows 处理
|
|
146
|
+
* @returns 是否为目录
|
|
147
|
+
*/
|
|
148
|
+
async function isDirEntry(dir: string, entry: Dirent, windows: boolean): Promise<boolean> {
|
|
149
|
+
if (!entry.isSymbolicLink()) return entry.isDirectory()
|
|
150
|
+
try {
|
|
151
|
+
return (await stat(pathOf(windows).join(dir, entry.name))).isDirectory()
|
|
152
|
+
} catch {
|
|
153
|
+
// 悬空链接:当作普通条目,不报错也不当目录 —— 点不进去是对的
|
|
154
|
+
return false
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 排序:目录在前,同类按名称(`localeCompare` + `numeric`,故 `f2` 在 `f10` 之前)
|
|
160
|
+
*
|
|
161
|
+
* **中文名排在英文名之前不是缺陷** —— CLDR 的中文规则把汉字整块提到拉丁字母之前。
|
|
162
|
+
* 要改成「英文在前」得在此处显式加一层「是否以 ASCII 起头」的比较键,而不是换 locale。
|
|
163
|
+
* @param a 左项
|
|
164
|
+
* @param b 右项
|
|
165
|
+
* @returns 比较结果
|
|
166
|
+
*/
|
|
167
|
+
function compareEntries(a: BrowseEntry, b: BrowseEntry): number {
|
|
168
|
+
if (a.dir !== b.dir) return a.dir ? -1 : 1
|
|
169
|
+
return a.name.localeCompare(b.name, "zh-Hans-CN", { numeric: true })
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 列举一个目录
|
|
174
|
+
*
|
|
175
|
+
* @param input 要列举的绝对路径;空或缺省时,Windows 上列举盘符、其余系统列举根目录
|
|
176
|
+
* @param windows 是否按 Windows 处理,缺省取当前平台。仅为让用例能同时覆盖两种行为
|
|
177
|
+
* @returns 列举结果
|
|
178
|
+
*/
|
|
179
|
+
export async function browseDirectory(input: string | undefined, windows = process.platform === "win32"): Promise<BrowseResult> {
|
|
180
|
+
const path = pathOf(windows)
|
|
181
|
+
const raw = (input ?? "").trim()
|
|
182
|
+
|
|
183
|
+
if (raw === "") {
|
|
184
|
+
if (windows) {
|
|
185
|
+
return { ok: true, listing: { path: "", entries: await listDrives(), truncated: false, sep: path.sep } }
|
|
186
|
+
}
|
|
187
|
+
return read("/", windows)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!path.isAbsolute(raw)) {
|
|
191
|
+
return {
|
|
192
|
+
ok: false,
|
|
193
|
+
status: 400,
|
|
194
|
+
message: `只接受绝对路径:${raw} 是相对路径,而它的基准(内核进程的工作目录)在面板上看不到`
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return read(path.resolve(raw), windows)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 真正读一次目录
|
|
203
|
+
* @param target 已规范化的绝对路径
|
|
204
|
+
* @param windows 是否按 Windows 处理
|
|
205
|
+
* @returns 列举结果
|
|
206
|
+
*/
|
|
207
|
+
async function read(target: string, windows: boolean): Promise<BrowseResult> {
|
|
208
|
+
let raw: Dirent[]
|
|
209
|
+
try {
|
|
210
|
+
raw = await readdir(target, { withFileTypes: true })
|
|
211
|
+
} catch (err) {
|
|
212
|
+
const code = errorCode(err) ?? ""
|
|
213
|
+
const known = CODE_TABLE[code]
|
|
214
|
+
if (known !== undefined) return { ok: false, status: known.status, message: `${target} ${known.text}` }
|
|
215
|
+
return { ok: false, status: 500, message: `读取 ${target} 失败:${err instanceof Error ? err.message : String(err)}` }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const entries: BrowseEntry[] = []
|
|
219
|
+
for (const item of raw) {
|
|
220
|
+
const link = item.isSymbolicLink()
|
|
221
|
+
const dir = await isDirEntry(target, item, windows)
|
|
222
|
+
entries.push(link ? { name: item.name, dir, link: true } : { name: item.name, dir })
|
|
223
|
+
}
|
|
224
|
+
entries.sort(compareEntries)
|
|
225
|
+
|
|
226
|
+
// 先排序再截断:反过来的话「截掉的是哪些」取决于文件系统给出的顺序,
|
|
227
|
+
// 同一个目录两次请求可能截到不同的一批
|
|
228
|
+
const truncated = entries.length > MAX_ENTRIES
|
|
229
|
+
const parent = parentOf(target, windows)
|
|
230
|
+
return {
|
|
231
|
+
ok: true,
|
|
232
|
+
listing: {
|
|
233
|
+
path: target,
|
|
234
|
+
...(parent === undefined ? {} : { parent }),
|
|
235
|
+
entries: truncated ? entries.slice(0, MAX_ENTRIES) : entries,
|
|
236
|
+
truncated,
|
|
237
|
+
sep: pathOf(windows).sep
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|