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/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 }