dsh-my-computer 0.0.0-stage → 0.2.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 +21 -0
- package/README.md +257 -2
- package/cordis.patch.yml +11 -0
- package/lib/client.js +1566 -0
- package/lib/index.js +840 -0
- package/lib/zip.js +365 -0
- package/package.json +56 -4
- package/tools/client-check.mjs +429 -0
- package/tools/right-tab-check.mjs +118 -0
- package/tools/smoke.mjs +489 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,840 @@
|
|
|
1
|
+
// dsh-my-computer —— host 半侧(cordis 宿主插件,ESM,零 npm 依赖)
|
|
2
|
+
//
|
|
3
|
+
// 职责:把「整台 Windows」的文件系统暴露成一组同源 HTTP 路由,给右栏的
|
|
4
|
+
// 「我的电脑」面板用。用户人不在电脑旁,只能用手机通过 DSH 网页远程管理文件,
|
|
5
|
+
// 所以这里刻意不设 workspace 门卫——路径只校验「绝对 Windows 路径 + 存在」,
|
|
6
|
+
// 其余交给 Windows 自己的 ACL(出错就报错,不静默降级)。
|
|
7
|
+
//
|
|
8
|
+
// 安全模型(重要,别随手放宽):
|
|
9
|
+
// 1) 路由挂在 DSH 的 /api 共享 fetch 通道下。DSH 的 connection 服务在分发
|
|
10
|
+
// 之前会检查 peer.principal.kind === "owner"(见 dsh-client-connection
|
|
11
|
+
// createSharedFetchHandler),也就是说没有通过 DSH 鉴权的请求根本到不了这里。
|
|
12
|
+
// 注意:不要改用 ctx.webServer.register 的原生前缀路由——那条路不查 cookie、
|
|
13
|
+
// 不查 Host/Origin(见 dsh-host-webserver readResource 注释),等于把全盘
|
|
14
|
+
// 裸奔在端口上。
|
|
15
|
+
// 2) 另外自己再挡一层跨站:带 Origin 的请求其 host 必须与 Host 头一致,
|
|
16
|
+
// 防止别的网站用 form 提交打到这台机器(DSH 的 cookie 是 SameSite 保护的,
|
|
17
|
+
// 这层是纵深防御)。
|
|
18
|
+
// 3) dsh web 只 bind 127.0.0.1,外部经 tailscale serve / --public-url 转发,
|
|
19
|
+
// 因此"本机 loopback"由监听地址保证,不在这里判断 socket 地址。
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs'
|
|
22
|
+
import fsp from 'node:fs/promises'
|
|
23
|
+
import path from 'node:path'
|
|
24
|
+
import { Readable } from 'node:stream'
|
|
25
|
+
import { pipeline } from 'node:stream/promises'
|
|
26
|
+
import { createZipArchive } from './zip.js'
|
|
27
|
+
|
|
28
|
+
export const name = 'dsh-my-computer'
|
|
29
|
+
|
|
30
|
+
// connection:必须,取 connection.fetch.register
|
|
31
|
+
export const inject = ['connection']
|
|
32
|
+
|
|
33
|
+
/** 路由前缀。必须是 /api/... 开头:connection 的精确路由只在该通道下分发。 */
|
|
34
|
+
const PREFIX = '/api/my-computer'
|
|
35
|
+
|
|
36
|
+
const DEFAULT_READ_BYTES = 512 * 1024
|
|
37
|
+
const HARD_READ_CAP = 8 * 1024 * 1024
|
|
38
|
+
const MAX_JSON_BODY = 8 * 1024 * 1024
|
|
39
|
+
|
|
40
|
+
const VERSION = '0.2.0'
|
|
41
|
+
|
|
42
|
+
/* ------------------------------------------------------------------ *
|
|
43
|
+
* 错误与响应
|
|
44
|
+
* ------------------------------------------------------------------ */
|
|
45
|
+
|
|
46
|
+
class CodedError extends Error {
|
|
47
|
+
constructor(code, message, status = 400) {
|
|
48
|
+
super(message)
|
|
49
|
+
this.name = 'CodedError'
|
|
50
|
+
this.code = code
|
|
51
|
+
this.status = status
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function fail(code, message, status = 400) {
|
|
56
|
+
throw new CodedError(code, message, status)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const JSON_HEADERS = {
|
|
60
|
+
'content-type': 'application/json; charset=utf-8',
|
|
61
|
+
'cache-control': 'no-store',
|
|
62
|
+
'x-content-type-options': 'nosniff',
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function json(body, status = 200) {
|
|
66
|
+
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 把任意异常收敛成 {code, message, status},绝不把栈泄露给客户端。 */
|
|
70
|
+
function normalizeError(error) {
|
|
71
|
+
if (error instanceof CodedError) {
|
|
72
|
+
return { code: error.code, message: error.message, status: error.status }
|
|
73
|
+
}
|
|
74
|
+
const errno = typeof error?.code === 'string' ? error.code : ''
|
|
75
|
+
const message = String(error?.message || error || '未知错误')
|
|
76
|
+
switch (errno) {
|
|
77
|
+
case 'ENOENT':
|
|
78
|
+
return { code: 'NOT_FOUND', message, status: 404 }
|
|
79
|
+
case 'EACCES':
|
|
80
|
+
case 'EPERM':
|
|
81
|
+
return { code: 'PERMISSION_DENIED', message, status: 403 }
|
|
82
|
+
case 'EEXIST':
|
|
83
|
+
return { code: 'ALREADY_EXISTS', message, status: 409 }
|
|
84
|
+
case 'ENOTDIR':
|
|
85
|
+
return { code: 'NOT_DIRECTORY', message, status: 400 }
|
|
86
|
+
case 'EISDIR':
|
|
87
|
+
return { code: 'IS_DIRECTORY', message, status: 400 }
|
|
88
|
+
case 'ENOTEMPTY':
|
|
89
|
+
return { code: 'NOT_EMPTY', message, status: 409 }
|
|
90
|
+
case 'EBUSY':
|
|
91
|
+
return { code: 'BUSY', message, status: 423 }
|
|
92
|
+
case 'ENOSPC':
|
|
93
|
+
return { code: 'NO_SPACE', message, status: 507 }
|
|
94
|
+
case 'ENAMETOOLONG':
|
|
95
|
+
return { code: 'INVALID_NAME', message, status: 400 }
|
|
96
|
+
default:
|
|
97
|
+
return { code: errno || 'IO_ERROR', message, status: 500 }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function codeOf(error) {
|
|
102
|
+
return normalizeError(error).code
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function messageOf(error) {
|
|
106
|
+
return normalizeError(error).message
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 出错时把没读完的请求体丢弃,否则桥接层会因为 body 未读完而挂住连接。 */
|
|
110
|
+
async function drain(request) {
|
|
111
|
+
try {
|
|
112
|
+
await request.body?.cancel()
|
|
113
|
+
} catch {
|
|
114
|
+
/* 已读完或已关闭 */
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* ------------------------------------------------------------------ *
|
|
119
|
+
* 路径工具
|
|
120
|
+
* ------------------------------------------------------------------ */
|
|
121
|
+
|
|
122
|
+
/** 校验:必须是绝对 Windows 路径(盘符根或 UNC)。用户要全盘权限,这里不做门卫。 */
|
|
123
|
+
function requirePath(raw, label = 'path') {
|
|
124
|
+
const value = typeof raw === 'string' ? raw.trim() : ''
|
|
125
|
+
if (!value) fail('INVALID_PATH', `缺少 ${label} 参数(需要绝对 Windows 路径,如 D:\\foo)`, 400)
|
|
126
|
+
if (value.includes('\u0000')) fail('INVALID_PATH', `${label} 含 NUL 字符`, 400)
|
|
127
|
+
const isDrive = /^[A-Za-z]:[\\/]/.test(value)
|
|
128
|
+
const isUnc = /^\\\\[^\\]/.test(value)
|
|
129
|
+
if (!isDrive && !isUnc) {
|
|
130
|
+
fail('INVALID_PATH', `${label} 必须是绝对路径(如 D:\\foo 或 \\\\server\\share),收到 ${JSON.stringify(value)}`, 400)
|
|
131
|
+
}
|
|
132
|
+
const normalized = path.win32.normalize(value)
|
|
133
|
+
// 盘符统一大写,避免 C: 与 c: 被当成两个位置
|
|
134
|
+
if (isDrive) return normalized.charAt(0).toUpperCase() + normalized.slice(1)
|
|
135
|
+
return normalized
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isDriveRoot(p) {
|
|
139
|
+
return /^[A-Za-z]:\\$/.test(p)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 目录的上一级;盘符根(及 UNC 根)返回 null。 */
|
|
143
|
+
function parentOf(p) {
|
|
144
|
+
const normalized = path.win32.normalize(p)
|
|
145
|
+
if (isDriveRoot(normalized)) return null
|
|
146
|
+
const dir = path.win32.dirname(normalized)
|
|
147
|
+
if (dir === normalized) return null
|
|
148
|
+
return dir
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 取路径所属盘符的容量(拿不到就返回 null)。 */
|
|
152
|
+
function capacityOf(p) {
|
|
153
|
+
let root = p.slice(0, 3)
|
|
154
|
+
if (!/^[A-Za-z]:\\$/.test(root)) {
|
|
155
|
+
const parsed = path.win32.parse(p)
|
|
156
|
+
root = parsed.root
|
|
157
|
+
}
|
|
158
|
+
if (!root) return null
|
|
159
|
+
try {
|
|
160
|
+
const sfs = fs.statfsSync(root)
|
|
161
|
+
return { total: sfs.bsize * sfs.blocks, free: sfs.bsize * sfs.bavail }
|
|
162
|
+
} catch {
|
|
163
|
+
return null
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function statOrFail(p, label = '路径') {
|
|
168
|
+
try {
|
|
169
|
+
return await fsp.stat(p)
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if (error?.code === 'ENOENT') fail('NOT_FOUND', `${label}不存在:${p}`, 404)
|
|
172
|
+
throw error
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/* ------------------------------------------------------------------ *
|
|
177
|
+
* 文件名 / MIME / Content-Disposition
|
|
178
|
+
* ------------------------------------------------------------------ */
|
|
179
|
+
|
|
180
|
+
const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i
|
|
181
|
+
|
|
182
|
+
function sanitizeName(raw) {
|
|
183
|
+
const name = typeof raw === 'string' ? raw.trim() : ''
|
|
184
|
+
if (!name) fail('INVALID_NAME', '缺少文件名(name 参数)', 400)
|
|
185
|
+
if (name === '.' || name === '..') fail('INVALID_NAME', '文件名不能是 . 或 ..', 400)
|
|
186
|
+
if (/[\\/]/.test(name)) fail('INVALID_NAME', '文件名不能包含路径分隔符', 400)
|
|
187
|
+
// Windows 非法字符 + 控制字符
|
|
188
|
+
if (/[<>:"|?*\u0000-\u001f]/.test(name)) fail('INVALID_NAME', '文件名包含 Windows 不允许的字符', 400)
|
|
189
|
+
if (/[. ]$/.test(name)) fail('INVALID_NAME', '文件名不能以点或空格结尾(Windows 会截断)', 400)
|
|
190
|
+
if (WINDOWS_RESERVED.test(name)) fail('INVALID_NAME', `"${name}" 是 Windows 保留设备名`, 400)
|
|
191
|
+
if (name.length > 200) fail('INVALID_NAME', '文件名过长(>200 字符)', 400)
|
|
192
|
+
return name
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const MIME_BY_EXT = {
|
|
196
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
197
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
198
|
+
'.markdown': 'text/markdown; charset=utf-8',
|
|
199
|
+
'.json': 'application/json; charset=utf-8',
|
|
200
|
+
'.jsonl': 'application/x-ndjson; charset=utf-8',
|
|
201
|
+
'.yml': 'text/yaml; charset=utf-8',
|
|
202
|
+
'.yaml': 'text/yaml; charset=utf-8',
|
|
203
|
+
'.toml': 'text/plain; charset=utf-8',
|
|
204
|
+
'.ini': 'text/plain; charset=utf-8',
|
|
205
|
+
'.cfg': 'text/plain; charset=utf-8',
|
|
206
|
+
'.log': 'text/plain; charset=utf-8',
|
|
207
|
+
'.csv': 'text/csv; charset=utf-8',
|
|
208
|
+
'.tsv': 'text/tab-separated-values; charset=utf-8',
|
|
209
|
+
'.html': 'text/html; charset=utf-8',
|
|
210
|
+
'.htm': 'text/html; charset=utf-8',
|
|
211
|
+
'.css': 'text/css; charset=utf-8',
|
|
212
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
213
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
214
|
+
'.cjs': 'text/javascript; charset=utf-8',
|
|
215
|
+
'.ts': 'text/plain; charset=utf-8',
|
|
216
|
+
'.tsx': 'text/plain; charset=utf-8',
|
|
217
|
+
'.jsx': 'text/plain; charset=utf-8',
|
|
218
|
+
'.py': 'text/plain; charset=utf-8',
|
|
219
|
+
'.sh': 'text/plain; charset=utf-8',
|
|
220
|
+
'.bat': 'text/plain; charset=utf-8',
|
|
221
|
+
'.cmd': 'text/plain; charset=utf-8',
|
|
222
|
+
'.ps1': 'text/plain; charset=utf-8',
|
|
223
|
+
'.xml': 'application/xml; charset=utf-8',
|
|
224
|
+
'.svg': 'image/svg+xml',
|
|
225
|
+
'.png': 'image/png',
|
|
226
|
+
'.jpg': 'image/jpeg',
|
|
227
|
+
'.jpeg': 'image/jpeg',
|
|
228
|
+
'.gif': 'image/gif',
|
|
229
|
+
'.webp': 'image/webp',
|
|
230
|
+
'.bmp': 'image/bmp',
|
|
231
|
+
'.ico': 'image/x-icon',
|
|
232
|
+
'.avif': 'image/avif',
|
|
233
|
+
'.pdf': 'application/pdf',
|
|
234
|
+
'.mp3': 'audio/mpeg',
|
|
235
|
+
'.wav': 'audio/wav',
|
|
236
|
+
'.mp4': 'video/mp4',
|
|
237
|
+
'.webm': 'video/webm',
|
|
238
|
+
'.zip': 'application/zip',
|
|
239
|
+
'.7z': 'application/x-7z-compressed',
|
|
240
|
+
'.rar': 'application/vnd.rar',
|
|
241
|
+
'.gz': 'application/gzip',
|
|
242
|
+
'.exe': 'application/vnd.microsoft.portable-executable',
|
|
243
|
+
'.dll': 'application/vnd.microsoft.portable-executable',
|
|
244
|
+
'.db': 'application/vnd.sqlite3',
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function mimeOf(filePath) {
|
|
248
|
+
const ext = path.win32.extname(filePath).toLowerCase()
|
|
249
|
+
return MIME_BY_EXT[ext] || 'application/octet-stream'
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** RFC 6266 / 5987:ASCII 兜底 + UTF-8 文件名,中文名下载才不会变成乱码。 */
|
|
253
|
+
function contentDisposition(fileName, kind = 'attachment') {
|
|
254
|
+
const ascii = fileName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_')
|
|
255
|
+
const encoded = encodeURIComponent(fileName).replace(/['()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase())
|
|
256
|
+
return `${kind}; filename="${ascii}"; filename*=UTF-8''${encoded}`
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/* ------------------------------------------------------------------ *
|
|
260
|
+
* 请求体 / 同源校验
|
|
261
|
+
* ------------------------------------------------------------------ */
|
|
262
|
+
|
|
263
|
+
async function readJson(request) {
|
|
264
|
+
const contentType = String(request.headers.get('content-type') || '').toLowerCase()
|
|
265
|
+
if (!contentType.startsWith('application/json')) {
|
|
266
|
+
fail('INVALID_REQUEST', '需要 application/json 请求体', 415)
|
|
267
|
+
}
|
|
268
|
+
const declared = Number(request.headers.get('content-length') || 0)
|
|
269
|
+
if (declared > MAX_JSON_BODY) fail('TOO_LARGE', '请求体过大', 413)
|
|
270
|
+
const text = await request.text()
|
|
271
|
+
if (text.length > MAX_JSON_BODY) fail('TOO_LARGE', '请求体过大', 413)
|
|
272
|
+
let payload
|
|
273
|
+
try {
|
|
274
|
+
payload = JSON.parse(text)
|
|
275
|
+
} catch {
|
|
276
|
+
fail('INVALID_REQUEST', '请求体不是合法 JSON', 400)
|
|
277
|
+
}
|
|
278
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
279
|
+
fail('INVALID_REQUEST', '请求体必须是 JSON 对象', 400)
|
|
280
|
+
}
|
|
281
|
+
return payload
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** 统一比较用的 host 形态:小写、去掉默认端口(有的反代会给 Host 补 :443)。 */
|
|
285
|
+
function normalizeHost(value, protocol) {
|
|
286
|
+
const lower = String(value || '').toLowerCase()
|
|
287
|
+
if (protocol === 'https:' && lower.endsWith(':443')) return lower.slice(0, -4)
|
|
288
|
+
if (protocol === 'http:' && lower.endsWith(':80')) return lower.slice(0, -3)
|
|
289
|
+
return lower
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** 跨站防护:带 Origin 的请求,其 host 必须与 Host 头一致。 */
|
|
293
|
+
function assertSameOrigin(request) {
|
|
294
|
+
// 兜底开关:万一某天反代改写了 Host 导致这里误判,可以设
|
|
295
|
+
// DSH_MY_COMPUTER_ALLOW_CROSS_ORIGIN=1 关掉这一层(只影响跨站判断,鉴权仍在 /api 通道上)。
|
|
296
|
+
if (String(process.env.DSH_MY_COMPUTER_ALLOW_CROSS_ORIGIN || '') === '1') return
|
|
297
|
+
const origin = request.headers.get('origin')
|
|
298
|
+
if (!origin) return // 同源 GET 不带 Origin;非浏览器客户端也不带
|
|
299
|
+
let parsed
|
|
300
|
+
try {
|
|
301
|
+
parsed = new URL(origin)
|
|
302
|
+
} catch {
|
|
303
|
+
fail('FORBIDDEN', `Origin 头格式不合法:${origin}`, 403)
|
|
304
|
+
}
|
|
305
|
+
const host = request.headers.get('host')
|
|
306
|
+
if (!host) return // 拿不到 Host(非 HTTP 桥接)时不阻塞
|
|
307
|
+
const left = normalizeHost(parsed.host, parsed.protocol)
|
|
308
|
+
const right = normalizeHost(host, parsed.protocol)
|
|
309
|
+
if (left !== right) {
|
|
310
|
+
fail('FORBIDDEN', `跨站请求被拒绝(Origin=${left},Host=${right})`, 403)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/* ------------------------------------------------------------------ *
|
|
315
|
+
* 各路由实现
|
|
316
|
+
* ------------------------------------------------------------------ */
|
|
317
|
+
|
|
318
|
+
/** GET /drives —— 探测 A:–Z:,能访问的都列出来。 */
|
|
319
|
+
function handleDrives() {
|
|
320
|
+
const systemDrive = String(process.env.SystemDrive || 'C:').toUpperCase()
|
|
321
|
+
const drives = []
|
|
322
|
+
for (let i = 0; i < 26; i += 1) {
|
|
323
|
+
const letter = String.fromCharCode(65 + i) + ':'
|
|
324
|
+
const root = letter + '\\'
|
|
325
|
+
let st
|
|
326
|
+
try {
|
|
327
|
+
st = fs.statSync(root)
|
|
328
|
+
} catch {
|
|
329
|
+
continue // 没这个盘符 / 盘符没插介质
|
|
330
|
+
}
|
|
331
|
+
if (!st.isDirectory()) continue
|
|
332
|
+
const drive = {
|
|
333
|
+
letter,
|
|
334
|
+
label: letter.toUpperCase() === systemDrive ? '系统盘' : '本地磁盘',
|
|
335
|
+
ok: true,
|
|
336
|
+
}
|
|
337
|
+
try {
|
|
338
|
+
const sfs = fs.statfsSync(root)
|
|
339
|
+
drive.total = sfs.bsize * sfs.blocks
|
|
340
|
+
drive.free = sfs.bsize * sfs.bavail
|
|
341
|
+
} catch {
|
|
342
|
+
drive.total = null
|
|
343
|
+
drive.free = null
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
fs.accessSync(root, fs.constants.W_OK)
|
|
347
|
+
drive.writable = true
|
|
348
|
+
} catch {
|
|
349
|
+
drive.writable = false
|
|
350
|
+
}
|
|
351
|
+
drives.push(drive)
|
|
352
|
+
}
|
|
353
|
+
return json({ ok: true, drives })
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** GET /list?path= —— 列目录,目录在前、按名字排序。 */
|
|
357
|
+
async function handleList(request) {
|
|
358
|
+
const url = new URL(request.url)
|
|
359
|
+
const target = requirePath(url.searchParams.get('path'))
|
|
360
|
+
const st = await statOrFail(target)
|
|
361
|
+
if (!st.isDirectory()) fail('NOT_DIRECTORY', `不是目录:${target}`, 400)
|
|
362
|
+
|
|
363
|
+
let dirents
|
|
364
|
+
try {
|
|
365
|
+
dirents = await fsp.readdir(target, { withFileTypes: true })
|
|
366
|
+
} catch (error) {
|
|
367
|
+
throw error // 交给 normalizeError(EACCES 等)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const entries = []
|
|
371
|
+
for (const dirent of dirents) {
|
|
372
|
+
const full = path.win32.join(target, dirent.name)
|
|
373
|
+
const base = {
|
|
374
|
+
name: dirent.name,
|
|
375
|
+
path: full,
|
|
376
|
+
ext: path.win32.extname(dirent.name).toLowerCase(),
|
|
377
|
+
}
|
|
378
|
+
let info
|
|
379
|
+
try {
|
|
380
|
+
info = await fsp.lstat(full)
|
|
381
|
+
} catch (error) {
|
|
382
|
+
// 单个条目 stat 失败不能拖垮整个列表:标成 other 并把原因带上
|
|
383
|
+
entries.push({ ...base, kind: 'other', size: null, mtime: null, statError: messageOf(error) })
|
|
384
|
+
continue
|
|
385
|
+
}
|
|
386
|
+
let kind = 'other'
|
|
387
|
+
if (info.isSymbolicLink()) kind = 'link'
|
|
388
|
+
else if (info.isDirectory()) kind = 'dir'
|
|
389
|
+
else if (info.isFile()) kind = 'file'
|
|
390
|
+
|
|
391
|
+
let meta = info
|
|
392
|
+
let targetKind = null
|
|
393
|
+
if (kind === 'link') {
|
|
394
|
+
// 符号链接 / junction:真实属性的 kind 决定面板能不能点进去
|
|
395
|
+
try {
|
|
396
|
+
const real = await fsp.stat(full)
|
|
397
|
+
meta = real
|
|
398
|
+
targetKind = real.isDirectory() ? 'dir' : real.isFile() ? 'file' : 'other'
|
|
399
|
+
} catch {
|
|
400
|
+
targetKind = 'unknown' // 断链
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
entries.push({
|
|
405
|
+
...base,
|
|
406
|
+
kind,
|
|
407
|
+
targetKind,
|
|
408
|
+
size: targetKind === 'unknown' ? null : meta.size,
|
|
409
|
+
mtime: targetKind === 'unknown' ? null : meta.mtimeMs,
|
|
410
|
+
})
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const rankOf = (entry) => {
|
|
414
|
+
const effective = entry.kind === 'link' ? entry.targetKind : entry.kind
|
|
415
|
+
if (effective === 'dir') return 0
|
|
416
|
+
if (effective === 'file') return 1
|
|
417
|
+
if (effective === 'other') return 2
|
|
418
|
+
return 3 // link 但目标未知(断链)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
entries.sort((a, b) => {
|
|
422
|
+
const ra = rankOf(a)
|
|
423
|
+
const rb = rankOf(b)
|
|
424
|
+
if (ra !== rb) return ra - rb
|
|
425
|
+
return a.name.localeCompare(b.name, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' })
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
const capacity = capacityOf(target)
|
|
429
|
+
return json({
|
|
430
|
+
ok: true,
|
|
431
|
+
path: target,
|
|
432
|
+
parent: parentOf(target),
|
|
433
|
+
entries,
|
|
434
|
+
count: entries.length,
|
|
435
|
+
total: capacity ? capacity.total : null,
|
|
436
|
+
free: capacity ? capacity.free : null,
|
|
437
|
+
})
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const BOM_UTF8 = Buffer.from([0xef, 0xbb, 0xbf])
|
|
441
|
+
|
|
442
|
+
/** GET /read?path=&maxBytes= —— 文本预览,UTF-8 优先、GB18030 兜底、二进制明确拒绝。 */
|
|
443
|
+
async function handleRead(request) {
|
|
444
|
+
const url = new URL(request.url)
|
|
445
|
+
const target = requirePath(url.searchParams.get('path'))
|
|
446
|
+
const rawMax = Number(url.searchParams.get('maxBytes'))
|
|
447
|
+
const maxBytes = Number.isFinite(rawMax) && rawMax > 0
|
|
448
|
+
? Math.min(Math.floor(rawMax), HARD_READ_CAP)
|
|
449
|
+
: DEFAULT_READ_BYTES
|
|
450
|
+
|
|
451
|
+
const st = await statOrFail(target)
|
|
452
|
+
if (st.isDirectory()) fail('IS_DIRECTORY', `这是目录,不能做文本预览:${target}`, 400)
|
|
453
|
+
|
|
454
|
+
const take = Math.min(st.size, maxBytes)
|
|
455
|
+
const handle = await fsp.open(target, 'r')
|
|
456
|
+
let buffer
|
|
457
|
+
try {
|
|
458
|
+
buffer = Buffer.alloc(take)
|
|
459
|
+
let offset = 0
|
|
460
|
+
while (offset < take) {
|
|
461
|
+
const { bytesRead } = await handle.read(buffer, offset, take - offset, offset)
|
|
462
|
+
if (bytesRead === 0) break
|
|
463
|
+
offset += bytesRead
|
|
464
|
+
}
|
|
465
|
+
if (offset < take) buffer = buffer.subarray(0, offset)
|
|
466
|
+
} finally {
|
|
467
|
+
await handle.close()
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// 二进制嗅探:前 8KB 里有 NUL 基本就不是文本
|
|
471
|
+
const sniff = buffer.subarray(0, Math.min(buffer.length, 8192))
|
|
472
|
+
if (sniff.includes(0)) {
|
|
473
|
+
fail('BINARY_FILE', `这是二进制文件(${st.size} 字节),无法做文本预览,请用「下载」`, 415)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
let encoding = 'utf-8'
|
|
477
|
+
let text = null
|
|
478
|
+
try {
|
|
479
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(buffer)
|
|
480
|
+
} catch {
|
|
481
|
+
try {
|
|
482
|
+
// Node 24 自带 full-icu,GB18030 可直接解;中文 Windows 的老文件多半是它
|
|
483
|
+
text = new TextDecoder('gb18030', { fatal: true }).decode(buffer)
|
|
484
|
+
encoding = 'gb18030'
|
|
485
|
+
} catch {
|
|
486
|
+
fail('BINARY_FILE', '文件不是可识别的文本编码(既不是 UTF-8 也不是 GB18030),请用「下载」', 415)
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (encoding === 'utf-8' && text.charCodeAt(0) === 0xfeff) text = text.slice(1)
|
|
491
|
+
else if (buffer.subarray(0, 3).equals(BOM_UTF8)) text = text.replace(/^\ufeff/, '')
|
|
492
|
+
|
|
493
|
+
return json({
|
|
494
|
+
ok: true,
|
|
495
|
+
path: target,
|
|
496
|
+
size: st.size,
|
|
497
|
+
mtime: st.mtimeMs,
|
|
498
|
+
maxBytes,
|
|
499
|
+
truncated: st.size > take,
|
|
500
|
+
encoding,
|
|
501
|
+
text,
|
|
502
|
+
})
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** 同目录临时文件名(隐藏前缀,避免和用户文件撞名)。 */
|
|
506
|
+
function tempNameNear(dir, tag) {
|
|
507
|
+
const rand = Math.random().toString(36).slice(2, 10)
|
|
508
|
+
return path.win32.join(dir, `.dsh-mc-${tag}-${Date.now()}-${rand}.tmp`)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** POST /write {path, text} —— 先写临时文件再 rename,避免写一半断电留下半截文件。 */
|
|
512
|
+
async function handleWrite(request) {
|
|
513
|
+
const payload = await readJson(request)
|
|
514
|
+
const target = requirePath(payload.path)
|
|
515
|
+
if (typeof payload.text !== 'string') fail('INVALID_TEXT', 'text 必须是字符串', 400)
|
|
516
|
+
|
|
517
|
+
const dir = path.win32.dirname(target)
|
|
518
|
+
const dirStat = await statOrFail(dir, '目标目录')
|
|
519
|
+
if (!dirStat.isDirectory()) fail('NOT_DIRECTORY', `目标目录不是目录:${dir}`, 400)
|
|
520
|
+
|
|
521
|
+
const tmp = tempNameNear(dir, 'write')
|
|
522
|
+
try {
|
|
523
|
+
await fsp.writeFile(tmp, payload.text, 'utf8')
|
|
524
|
+
await fsp.rename(tmp, target)
|
|
525
|
+
} catch (error) {
|
|
526
|
+
await fsp.rm(tmp, { force: true }).catch(() => {})
|
|
527
|
+
throw error
|
|
528
|
+
}
|
|
529
|
+
const st = await fsp.stat(target)
|
|
530
|
+
return json({ ok: true, path: target, size: st.size, mtime: st.mtimeMs })
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** GET /download?path= —— 单文件流式下载;目录明确拒绝(MVP 不做 zip)。 */
|
|
534
|
+
async function handleDownload(request) {
|
|
535
|
+
const url = new URL(request.url)
|
|
536
|
+
const target = requirePath(url.searchParams.get('path'))
|
|
537
|
+
const st = await statOrFail(target)
|
|
538
|
+
if (st.isDirectory()) {
|
|
539
|
+
fail('IS_DIRECTORY', '这是目录,单文件下载不支持;整目录请用 /download-dir 打包成 zip', 400)
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const headers = {
|
|
543
|
+
'content-type': mimeOf(target),
|
|
544
|
+
'content-length': String(st.size),
|
|
545
|
+
'content-disposition': contentDisposition(path.win32.basename(target)),
|
|
546
|
+
'cache-control': 'no-store',
|
|
547
|
+
'x-content-type-options': 'nosniff',
|
|
548
|
+
}
|
|
549
|
+
const stream = fs.createReadStream(target)
|
|
550
|
+
return new Response(Readable.toWeb(stream), { status: 200, headers })
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* GET /download-dir?path= —— 整目录打包成 zip 流式下发(不落临时文件)。
|
|
555
|
+
*
|
|
556
|
+
* 发响应头之前已经把目录扫了一遍(条目数 / 总字节 / 可读性),所以「太大」
|
|
557
|
+
* 「空目录」这类问题会以 JSON 错误体正常返回,而不是下载到一半断掉。
|
|
558
|
+
* 中间读失败的文件会跳过,并把清单打在宿主日志里(压缩包本身仍然完整)。
|
|
559
|
+
*/
|
|
560
|
+
async function handleDownloadDir(request) {
|
|
561
|
+
const url = new URL(request.url)
|
|
562
|
+
const target = requirePath(url.searchParams.get('path'))
|
|
563
|
+
const st = await statOrFail(target)
|
|
564
|
+
if (!st.isDirectory()) {
|
|
565
|
+
fail('NOT_DIRECTORY', `不是目录:${target}(单文件请用 /download)`, 400)
|
|
566
|
+
}
|
|
567
|
+
if (isDriveRoot(target)) {
|
|
568
|
+
fail('FORBIDDEN', '整盘打包请进入具体目录后再下载(盘符根通常几十万文件)', 403)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const warnings = []
|
|
572
|
+
const archive = await createZipArchive(target, {
|
|
573
|
+
fail,
|
|
574
|
+
onWarn: (info) => {
|
|
575
|
+
warnings.push(info)
|
|
576
|
+
if (warnings.length <= 20) {
|
|
577
|
+
console.warn(`[dsh-my-computer] zip 跳过 ${info.path}:${info.message}`)
|
|
578
|
+
}
|
|
579
|
+
},
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
const headers = {
|
|
583
|
+
'content-type': 'application/zip',
|
|
584
|
+
'content-disposition': contentDisposition(archive.filename),
|
|
585
|
+
'cache-control': 'no-store',
|
|
586
|
+
'x-content-type-options': 'nosniff',
|
|
587
|
+
// 面板拿来显示进度提示(非标准头,纯提示;跨站读不到也无所谓)
|
|
588
|
+
'x-zip-entries': String(archive.entries),
|
|
589
|
+
'x-zip-bytes': String(archive.bytes),
|
|
590
|
+
'x-zip-skipped': String(warnings.length),
|
|
591
|
+
}
|
|
592
|
+
return new Response(Readable.toWeb(Readable.from(archive.parts)), { status: 200, headers })
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** PUT /upload?path=<目录>&name=<文件名>[&overwrite=1] —— 原始二进制流式落盘。 */
|
|
596
|
+
async function handleUpload(request) {
|
|
597
|
+
const url = new URL(request.url)
|
|
598
|
+
const dir = requirePath(url.searchParams.get('path'))
|
|
599
|
+
const name = sanitizeName(url.searchParams.get('name'))
|
|
600
|
+
const overwrite = url.searchParams.get('overwrite') === '1'
|
|
601
|
+
|
|
602
|
+
const dirStat = await statOrFail(dir, '目标目录')
|
|
603
|
+
if (!dirStat.isDirectory()) fail('NOT_DIRECTORY', `目标不是目录:${dir}`, 400)
|
|
604
|
+
|
|
605
|
+
const target = path.win32.join(dir, name)
|
|
606
|
+
let existing = null
|
|
607
|
+
try {
|
|
608
|
+
existing = await fsp.lstat(target)
|
|
609
|
+
} catch (error) {
|
|
610
|
+
if (error?.code !== 'ENOENT') throw error
|
|
611
|
+
}
|
|
612
|
+
if (existing) {
|
|
613
|
+
if (existing.isDirectory()) fail('ALREADY_EXISTS', `同名目录已存在:${name}`, 409)
|
|
614
|
+
if (!overwrite) {
|
|
615
|
+
fail('ALREADY_EXISTS', `同名文件已存在:${name}(覆盖请带 ?overwrite=1)`, 409)
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
if (!request.body) fail('INVALID_REQUEST', '上传请求体为空', 400)
|
|
619
|
+
|
|
620
|
+
const tmp = tempNameNear(dir, 'upload')
|
|
621
|
+
try {
|
|
622
|
+
await pipeline(Readable.fromWeb(request.body), fs.createWriteStream(tmp))
|
|
623
|
+
if (existing) await fsp.rm(target, { force: true })
|
|
624
|
+
await fsp.rename(tmp, target)
|
|
625
|
+
} catch (error) {
|
|
626
|
+
await fsp.rm(tmp, { force: true }).catch(() => {})
|
|
627
|
+
throw error
|
|
628
|
+
}
|
|
629
|
+
const st = await fsp.stat(target)
|
|
630
|
+
return json({
|
|
631
|
+
ok: true,
|
|
632
|
+
path: target,
|
|
633
|
+
name,
|
|
634
|
+
size: st.size,
|
|
635
|
+
mtime: st.mtimeMs,
|
|
636
|
+
overwritten: Boolean(existing),
|
|
637
|
+
})
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** POST /mkdir {path, name} —— path 是父目录。 */
|
|
641
|
+
async function handleMkdir(request) {
|
|
642
|
+
const payload = await readJson(request)
|
|
643
|
+
const dir = requirePath(payload.path)
|
|
644
|
+
const name = sanitizeName(payload.name)
|
|
645
|
+
const dirStat = await statOrFail(dir, '父目录')
|
|
646
|
+
if (!dirStat.isDirectory()) fail('NOT_DIRECTORY', `父路径不是目录:${dir}`, 400)
|
|
647
|
+
|
|
648
|
+
const target = path.win32.join(dir, name)
|
|
649
|
+
try {
|
|
650
|
+
await fsp.mkdir(target) // 不带 recursive:已存在要报错,不能静默成功
|
|
651
|
+
} catch (error) {
|
|
652
|
+
if (error?.code === 'EEXIST') fail('ALREADY_EXISTS', `同名文件或文件夹已存在:${name}`, 409)
|
|
653
|
+
throw error
|
|
654
|
+
}
|
|
655
|
+
const st = await fsp.stat(target)
|
|
656
|
+
return json({ ok: true, path: target, name, mtime: st.mtimeMs })
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** POST /rename {path, name} —— 同目录改名(不做跨目录移动,那是 /move)。 */
|
|
660
|
+
async function handleRename(request) {
|
|
661
|
+
const payload = await readJson(request)
|
|
662
|
+
const source = requirePath(payload.path)
|
|
663
|
+
const name = sanitizeName(payload.name)
|
|
664
|
+
if (isDriveRoot(source)) fail('FORBIDDEN', '不能重命名磁盘根目录', 403)
|
|
665
|
+
|
|
666
|
+
await statOrFail(source)
|
|
667
|
+
const target = path.win32.join(path.win32.dirname(source), name)
|
|
668
|
+
if (target === source) return json({ ok: true, path: target, name, unchanged: true })
|
|
669
|
+
|
|
670
|
+
let targetExists = false
|
|
671
|
+
try {
|
|
672
|
+
await fsp.lstat(target)
|
|
673
|
+
targetExists = true
|
|
674
|
+
} catch (error) {
|
|
675
|
+
if (error?.code !== 'ENOENT') throw error
|
|
676
|
+
}
|
|
677
|
+
if (targetExists) fail('ALREADY_EXISTS', `目标已存在:${name}`, 409)
|
|
678
|
+
|
|
679
|
+
await fsp.rename(source, target)
|
|
680
|
+
return json({ ok: true, path: target, name, from: source })
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** POST /delete {paths:[...], confirm:true} —— 递归删除,必须显式确认。 */
|
|
684
|
+
async function handleDelete(request) {
|
|
685
|
+
const payload = await readJson(request)
|
|
686
|
+
if (payload.confirm !== true) {
|
|
687
|
+
fail('CONFIRMATION_REQUIRED', '删除是不可逆操作,需要 confirm:true 才执行', 400)
|
|
688
|
+
}
|
|
689
|
+
const rawList = Array.isArray(payload.paths) ? payload.paths : payload.path ? [payload.path] : []
|
|
690
|
+
if (rawList.length === 0) fail('INVALID_REQUEST', 'paths 不能为空', 400)
|
|
691
|
+
|
|
692
|
+
const deleted = []
|
|
693
|
+
const failed = []
|
|
694
|
+
for (const raw of rawList) {
|
|
695
|
+
try {
|
|
696
|
+
const target = requirePath(raw)
|
|
697
|
+
if (isDriveRoot(target)) fail('FORBIDDEN', '拒绝删除整个磁盘根目录', 403)
|
|
698
|
+
await fsp.rm(target, { recursive: true, force: false, maxRetries: 2 })
|
|
699
|
+
deleted.push(target)
|
|
700
|
+
} catch (error) {
|
|
701
|
+
failed.push({ path: String(raw), code: codeOf(error), message: messageOf(error) })
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return json(
|
|
705
|
+
{ ok: failed.length === 0, deleted, failed },
|
|
706
|
+
failed.length === 0 ? 200 : 207,
|
|
707
|
+
)
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** POST /move {sources:[...], targetDir} —— 剪切粘贴;跨盘自动退化成 复制+删除。 */
|
|
711
|
+
async function handleMove(request) {
|
|
712
|
+
const payload = await readJson(request)
|
|
713
|
+
const sources = Array.isArray(payload.sources) ? payload.sources : []
|
|
714
|
+
if (sources.length === 0) fail('INVALID_REQUEST', 'sources 不能为空', 400)
|
|
715
|
+
const targetDir = requirePath(payload.targetDir, 'targetDir')
|
|
716
|
+
const dirStat = await statOrFail(targetDir, '目标目录')
|
|
717
|
+
if (!dirStat.isDirectory()) fail('NOT_DIRECTORY', `目标不是目录:${targetDir}`, 400)
|
|
718
|
+
|
|
719
|
+
const moved = []
|
|
720
|
+
const failed = []
|
|
721
|
+
for (const raw of sources) {
|
|
722
|
+
try {
|
|
723
|
+
const source = requirePath(raw)
|
|
724
|
+
if (isDriveRoot(source)) fail('FORBIDDEN', '拒绝移动整个磁盘根目录', 403)
|
|
725
|
+
const name = path.win32.basename(source)
|
|
726
|
+
const target = path.win32.join(targetDir, name)
|
|
727
|
+
if (path.win32.dirname(source).toLowerCase() === targetDir.toLowerCase() && name === path.win32.basename(source)) {
|
|
728
|
+
// 同目录"移动"= 什么都不做
|
|
729
|
+
moved.push({ from: source, to: target, unchanged: true })
|
|
730
|
+
continue
|
|
731
|
+
}
|
|
732
|
+
let targetExists = false
|
|
733
|
+
try {
|
|
734
|
+
await fsp.lstat(target)
|
|
735
|
+
targetExists = true
|
|
736
|
+
} catch (error) {
|
|
737
|
+
if (error?.code !== 'ENOENT') throw error
|
|
738
|
+
}
|
|
739
|
+
if (targetExists) fail('ALREADY_EXISTS', `目标已存在:${target}`, 409)
|
|
740
|
+
// 目标不能是自己的子目录
|
|
741
|
+
if (target.toLowerCase().startsWith(source.toLowerCase() + '\\')) {
|
|
742
|
+
fail('SELF_DESCENDANT', '不能把目录移动到它自己里面', 400)
|
|
743
|
+
}
|
|
744
|
+
try {
|
|
745
|
+
await fsp.rename(source, target)
|
|
746
|
+
} catch (error) {
|
|
747
|
+
if (error?.code !== 'EXDEV') throw error
|
|
748
|
+
await fsp.cp(source, target, { recursive: true, errorOnExist: true, force: false })
|
|
749
|
+
await fsp.rm(source, { recursive: true, force: false })
|
|
750
|
+
}
|
|
751
|
+
moved.push({ from: source, to: target })
|
|
752
|
+
} catch (error) {
|
|
753
|
+
failed.push({ path: String(raw), code: codeOf(error), message: messageOf(error) })
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
return json({ ok: failed.length === 0, moved, failed }, failed.length === 0 ? 200 : 207)
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** GET /health —— 面板自检用(也方便人工 curl 确认插件活着)。 */
|
|
760
|
+
function handleHealth() {
|
|
761
|
+
return json({
|
|
762
|
+
ok: true,
|
|
763
|
+
plugin: 'dsh-my-computer',
|
|
764
|
+
version: VERSION,
|
|
765
|
+
platform: process.platform,
|
|
766
|
+
node: process.versions.node,
|
|
767
|
+
root: '整盘(无 workspace 限制)',
|
|
768
|
+
at: Date.now(),
|
|
769
|
+
})
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/* ------------------------------------------------------------------ *
|
|
773
|
+
* 路由表
|
|
774
|
+
* ------------------------------------------------------------------ */
|
|
775
|
+
|
|
776
|
+
const ROUTES = [
|
|
777
|
+
{ id: 'health', path: PREFIX + '/health', methods: ['GET'], requestBody: 'buffered', run: handleHealth },
|
|
778
|
+
{ id: 'drives', path: PREFIX + '/drives', methods: ['GET'], requestBody: 'buffered', run: handleDrives },
|
|
779
|
+
{ id: 'list', path: PREFIX + '/list', methods: ['GET'], requestBody: 'buffered', run: handleList },
|
|
780
|
+
{ id: 'read', path: PREFIX + '/read', methods: ['GET'], requestBody: 'buffered', run: handleRead },
|
|
781
|
+
{ id: 'download', path: PREFIX + '/download', methods: ['GET'], requestBody: 'buffered', run: handleDownload },
|
|
782
|
+
{ id: 'downloadDir', path: PREFIX + '/download-dir', methods: ['GET'], requestBody: 'buffered', run: handleDownloadDir },
|
|
783
|
+
{ id: 'write', path: PREFIX + '/write', methods: ['POST'], requestBody: 'buffered', run: handleWrite },
|
|
784
|
+
{ id: 'upload', path: PREFIX + '/upload', methods: ['PUT'], requestBody: 'streaming', run: handleUpload },
|
|
785
|
+
{ id: 'mkdir', path: PREFIX + '/mkdir', methods: ['POST'], requestBody: 'buffered', run: handleMkdir },
|
|
786
|
+
{ id: 'rename', path: PREFIX + '/rename', methods: ['POST'], requestBody: 'buffered', run: handleRename },
|
|
787
|
+
{ id: 'delete', path: PREFIX + '/delete', methods: ['POST'], requestBody: 'buffered', run: handleDelete },
|
|
788
|
+
{ id: 'move', path: PREFIX + '/move', methods: ['POST'], requestBody: 'buffered', run: handleMove },
|
|
789
|
+
]
|
|
790
|
+
|
|
791
|
+
/** 把单个路由包成「永不抛异常」的 fetch handler。 */
|
|
792
|
+
function wrap(route) {
|
|
793
|
+
return async (request) => {
|
|
794
|
+
try {
|
|
795
|
+
assertSameOrigin(request)
|
|
796
|
+
return await route.run(request)
|
|
797
|
+
} catch (error) {
|
|
798
|
+
await drain(request)
|
|
799
|
+
const problem = normalizeError(error)
|
|
800
|
+
// 只有真正意外(5xx)才往宿主日志里写一行;400/404/409 这类是面板的正常
|
|
801
|
+
// 交互结果,不该把 dsh.log 刷满。
|
|
802
|
+
if (problem.status >= 500) {
|
|
803
|
+
try {
|
|
804
|
+
console.warn(`[dsh-my-computer] ${route.id} 失败:${problem.code} ${problem.message}`)
|
|
805
|
+
} catch {
|
|
806
|
+
/* 日志本身失败也不管 */
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return json({ ok: false, error: { code: problem.code, message: problem.message }, route: route.id }, problem.status)
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/* ------------------------------------------------------------------ *
|
|
815
|
+
* 插件入口
|
|
816
|
+
* ------------------------------------------------------------------ */
|
|
817
|
+
|
|
818
|
+
export async function apply(ctx, config) {
|
|
819
|
+
const enabled = config?.enabled === false ? false : true
|
|
820
|
+
if (!enabled) {
|
|
821
|
+
ctx.logger?.info?.('[dsh-my-computer] 配置 enabled=false,跳过注册')
|
|
822
|
+
return
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
for (const route of ROUTES) {
|
|
826
|
+
const registration = {
|
|
827
|
+
path: route.path,
|
|
828
|
+
methods: route.methods,
|
|
829
|
+
requestBody: route.requestBody,
|
|
830
|
+
fetch: wrap(route),
|
|
831
|
+
}
|
|
832
|
+
ctx.effect(() => ctx.connection.fetch.register(registration))
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const label = ROUTES.map((r) => `${r.methods.join('/')} ${r.path}`).join(', ')
|
|
836
|
+
ctx.logger?.info?.(`[dsh-my-computer] 已注册 ${ROUTES.length} 条路由:${label}`)
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/** 给 tools/smoke.mjs 复用:直接拿到未包壳的 handler 也能测。 */
|
|
840
|
+
export const internals = { ROUTES, wrap, PREFIX, VERSION, requirePath, parentOf, sanitizeName }
|