@lyhue1991/dsh-soup 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,725 @@
1
+ /**
2
+ * dsh-soup — 宿主半区:资源管理器的文件能力供给器。
3
+ *
4
+ * 通过 `webServer` 注册一个同源 HTTP 路由 `/api/dsh-soup`(POST JSON),
5
+ * 为浏览器半区提供对文件系统的只具名操作:列目录、系统打开、移到废纸篓、
6
+ * 移动/重命名、新建、上传。永久插件(profile bundle)不经过动态 runner,
7
+ * 因此不依赖 dynamic 半区的 `harness.handle`/`host.call`,而是走
8
+ * 宿主 HTTP 路由 + 浏览器 `fetch` 的规范桥梁。
9
+ *
10
+ * 跨平台:move/create/upload 直接用 `node:fs/promises`(mac/linux/win 通用),
11
+ * 仅 open/trash 这类"唤起系统"的动作按 `process.platform` 分支选命令。
12
+ */
13
+
14
+ import { rename, mkdir, writeFile, appendFile, open as openFile, stat, realpath } from 'node:fs/promises'
15
+ import { createReadStream } from 'node:fs'
16
+ import { randomBytes } from 'node:crypto'
17
+ import path from 'node:path'
18
+
19
+ /** 稳定插件名(与 cordis.patch.yml 的 insert id 一致)。 */
20
+ export const name = 'ui-dsh-soup'
21
+
22
+ /** 注入的宿主服务。 */
23
+ export const inject = ['webServer', 'fs', 'subprocess', 'sandboxPolicy', 'sessions', 'timer']
24
+
25
+ /** 当前运行平台:'mac' | 'linux' | 'win'(其余回退 mac 逻辑)。 */
26
+ const PLATFORM = process.platform === 'win32' ? 'win'
27
+ : process.platform === 'darwin' ? 'mac'
28
+ : process.platform === 'linux' ? 'linux' : 'mac'
29
+
30
+ /**
31
+ * 读取一次请求体为 JSON 对象(损坏或不存在的 body 返回空对象)。
32
+ * @param req - node:http IncomingMessage。
33
+ */
34
+ function readBody(req) {
35
+ return new Promise((resolve, reject) => {
36
+ const chunks = []
37
+ req.on('data', (chunk) => chunks.push(chunk))
38
+ req.on('end', () => {
39
+ const text = Buffer.concat(chunks).toString('utf8')
40
+ if (!text) { resolve({}); return }
41
+ try { resolve(JSON.parse(text)) } catch { resolve({}) }
42
+ })
43
+ req.on('error', reject)
44
+ })
45
+ }
46
+
47
+ /** 写一个 JSON 响应。 */
48
+ function writeJson(res, code, data) {
49
+ const body = JSON.stringify(data)
50
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' })
51
+ res.end(body)
52
+ }
53
+
54
+ /**
55
+ * 应用宿主半区:注册文件操作路由。
56
+ * @param ctx - cordis 宿主上下文。
57
+ */
58
+ export function apply(ctx) {
59
+ const fs = ctx.fs
60
+ const subprocess = ctx.subprocess
61
+ const sessions = ctx.sessions
62
+ const root = (ctx.sandboxPolicy && ctx.sandboxPolicy.workspaceRoot) || '/'
63
+
64
+ // ------------------------------------------------------------------
65
+ // 路径围栏:除 list 走沙箱 fs 外,其余具名文件操作(read/move/
66
+ // trash/open/create/upload)一律先过 confine(),只允许落在
67
+ // 「workspace 根 ∪ 所有已知会话 cwd」的子树内。解析走 realpath,
68
+ // 防符号链接逃逸;容忍目标末级尚不存在(新建/上传/移动目标)。
69
+ // 已知残留:confine 与实际操作之间存在 TOCTOU 窗口(符号链接竞态),
70
+ // Node 可移植 API 下无法根除,见 README 安全模型。
71
+ // ------------------------------------------------------------------
72
+ /** win 文件系统大小写不敏感,比较时折叠大小写。 */
73
+ const FOLD_CASE = PLATFORM === 'win'
74
+
75
+ /** 带 HTTP 状态码的错误对象(handler 据此写响应码)。 */
76
+ function httpError(statusCode, message) {
77
+ return Object.assign(new Error(message), { statusCode })
78
+ }
79
+
80
+ /** 容忍末级不存在的 realpath:向上找最近一个已存在的祖先再拼回剩余段。 */
81
+ async function realPathLenient(p) {
82
+ let cur = path.resolve(String(p))
83
+ const tail = []
84
+ for (;;) {
85
+ try {
86
+ const rp = await realpath(cur)
87
+ return tail.length ? path.join(rp, ...tail) : rp
88
+ } catch (err) {
89
+ const parent = path.dirname(cur)
90
+ if (!err || err.code !== 'ENOENT' || parent === cur) throw err
91
+ tail.unshift(path.basename(cur))
92
+ cur = parent
93
+ }
94
+ }
95
+ }
96
+
97
+ let rootRealCache = null
98
+ async function rootReal() {
99
+ if (rootRealCache === null) {
100
+ try { rootRealCache = await realpath(root) } catch { rootRealCache = path.resolve(root) }
101
+ }
102
+ return rootRealCache
103
+ }
104
+
105
+ /** candidate 是否位于 base 子树内(含 base 本身)。 */
106
+ function within(base, candidate) {
107
+ const b = FOLD_CASE ? base.toLowerCase() : base
108
+ const c = FOLD_CASE ? candidate.toLowerCase() : candidate
109
+ if (c === b) return true
110
+ const rel = path.relative(b, c)
111
+ return rel !== '' && rel !== '..' && !rel.startsWith('..' + path.sep) && !path.isAbsolute(rel)
112
+ }
113
+
114
+ /** 允许的基目录集合:workspace 根 + 所有已知会话的 cwd(尽力而为)。 */
115
+ async function allowedBases(preferredSessionId) {
116
+ const bases = [await rootReal()]
117
+ const pushCwd = async (cwd) => {
118
+ if (typeof cwd === 'string' && cwd) {
119
+ try { bases.push(await realPathLenient(cwd)) } catch { /* 会话目录不可达则跳过 */ }
120
+ }
121
+ }
122
+ // 指定会话优先直查:新会话刚创建时 list 枚举可能尚未纳入其 cwd。
123
+ if (preferredSessionId && typeof sessions.get === 'function') {
124
+ try {
125
+ const pref = sessions.get(preferredSessionId)
126
+ await pushCwd(pref && pref.header && pref.header.cwd)
127
+ } catch { /* ignore */ }
128
+ }
129
+ try {
130
+ const list = typeof sessions.list === 'function' ? sessions.list() : []
131
+ for (const s of list) await pushCwd(s && s.header && s.header.cwd)
132
+ } catch { /* 宿主无 list API 时仅用 workspace 根 */ }
133
+ return bases
134
+ }
135
+
136
+ /**
137
+ * 围栏校验:解析真实路径并确认落在允许基目录集合内。
138
+ * 通过则返回规范化真实路径;越界/不可达抛带 statusCode 的错误。
139
+ */
140
+ async function confine(rawPath, label, preferredSessionId) {
141
+ const name = label || '路径'
142
+ const p = String(rawPath || '')
143
+ if (!p) throw httpError(400, `缺少${name}`)
144
+ let rp
145
+ try {
146
+ rp = await realPathLenient(p)
147
+ } catch (err) {
148
+ throw httpError(400, `${name}不可达: ${p}`)
149
+ }
150
+ for (const base of await allowedBases(preferredSessionId)) {
151
+ if (within(base, rp)) return rp
152
+ }
153
+ throw httpError(403, `${name}超出允许范围(仅限工作区与会话目录): ${p}`)
154
+ }
155
+
156
+ // ------------------------------------------------------------------
157
+ // 速度徽标:包 llm/stream 统计 token 吞吐(等待/流式/done 三态)
158
+ // ------------------------------------------------------------------
159
+ const CJK = /[\u3000-\u30ff\u3400-\u9fff\uf900-\ufaff\uac00-\ud7af]/
160
+ function estimateChars(text) {
161
+ let cjk = 0
162
+ let rest = 0
163
+ for (const ch of text) { if (CJK.test(ch)) cjk++; else rest++ }
164
+ return cjk + rest / 4
165
+ }
166
+
167
+ /** 会话级流状态:phase / token / tps / ttft。 */
168
+ const streams = new Map()
169
+
170
+ function extractKey(options) {
171
+ try {
172
+ if (!options) return null
173
+ if (typeof options.sessionId === 'string') return options.sessionId
174
+ if (options.session && typeof options.session.id === 'string') return options.session.id
175
+ if (options.agent) {
176
+ const a = options.agent
177
+ if (typeof a.sessionId === 'string') return a.sessionId
178
+ if (typeof a.id === 'string') return a.id
179
+ if (a.session && typeof a.session.id === 'string') return a.session.id
180
+ }
181
+ if (options.meta && typeof options.meta.sessionId === 'string') return options.meta.sessionId
182
+ } catch (err) { /* ignore */ }
183
+ return null
184
+ }
185
+
186
+ function extractChunk(chunk) {
187
+ let text = ''
188
+ let realTokens = null
189
+ try {
190
+ if (!chunk) return { text, realTokens }
191
+ if (typeof chunk.text === 'string') text += chunk.text
192
+ if (typeof chunk.content === 'string') text += chunk.content
193
+ if (Array.isArray(chunk.content)) {
194
+ for (const part of chunk.content) if (part && typeof part.text === 'string') text += part.text
195
+ }
196
+ if (chunk.delta) {
197
+ const d = chunk.delta
198
+ if (typeof d.text === 'string') text += d.text
199
+ if (typeof d.content === 'string') text += d.content
200
+ if (d.usage) {
201
+ const ot = d.usage.outputTokens != null ? d.usage.outputTokens
202
+ : (d.usage.output_tokens != null ? d.usage.output_tokens
203
+ : (d.usage.completion_tokens != null ? d.usage.completion_tokens : d.usage.completionTokens))
204
+ if (typeof ot === 'number') realTokens = ot
205
+ }
206
+ }
207
+ if (chunk.usage) {
208
+ const u = chunk.usage
209
+ const ot = u.outputTokens != null ? u.outputTokens
210
+ : (u.output_tokens != null ? u.output_tokens
211
+ : (u.completion_tokens != null ? u.completion_tokens : u.completionTokens))
212
+ if (typeof ot === 'number') realTokens = ot
213
+ }
214
+ if (Array.isArray(chunk.choices)) {
215
+ for (const c of chunk.choices) {
216
+ const d = c && c.delta
217
+ if (d && typeof d.content === 'string') text += d.content
218
+ if (d && d.usage) {
219
+ const ot = d.usage.outputTokens != null ? d.usage.outputTokens : d.usage.output_tokens
220
+ if (typeof ot === 'number') realTokens = ot
221
+ }
222
+ }
223
+ }
224
+ } catch (err) { /* ignore */ }
225
+ return { text, realTokens }
226
+ }
227
+
228
+ ctx.on('llm/stream', (options, next) => {
229
+ const key = extractKey(options) || '_'
230
+ const now = Date.now()
231
+ streams.set(key, { phase: 'waiting', startedAt: now, firstChunkAt: null, charTokens: 0, realTokens: 0, hasReal: false, lastChunkAt: null, lastContentAt: null, samples: [], lastSeen: now })
232
+ const innerP = Promise.resolve(next())
233
+ return (async function* () {
234
+ try {
235
+ const inner = await innerP
236
+ for await (const chunk of inner) {
237
+ const st = streams.get(key)
238
+ if (st) {
239
+ const parsed = extractChunk(chunk)
240
+ const t = Date.now()
241
+ if (st.firstChunkAt === null) { st.firstChunkAt = t; st.phase = 'streaming' }
242
+ if (parsed.text) st.charTokens += estimateChars(parsed.text)
243
+ if (parsed.realTokens != null) { st.realTokens = parsed.realTokens; st.hasReal = true }
244
+ st.lastChunkAt = t
245
+ // 只有携带真实内容的 chunk 才算「有进展」:keepalive / 空 delta
246
+ // 不刷新内容时钟,避免等待期被空包滴漏伪装成慢速流式。
247
+ if ((parsed.text && parsed.text.length > 0) || parsed.realTokens != null) st.lastContentAt = t
248
+ // 滑动窗口采样:[时刻, 累计 token],用于算瞬时速率而非全程平均
249
+ st.samples.push([t, st.hasReal ? st.realTokens : st.charTokens])
250
+ if (st.samples.length > 128) st.samples.splice(0, st.samples.length - 128)
251
+ st.lastSeen = t
252
+ }
253
+ yield chunk
254
+ }
255
+ } finally {
256
+ const st = streams.get(key)
257
+ if (st) {
258
+ st.phase = 'done'
259
+ st.lastSeen = Date.now()
260
+ }
261
+ }
262
+ })()
263
+ })
264
+
265
+ const stopReap = ctx.timer.interval(() => {
266
+ const now = Date.now()
267
+ for (const [k, st] of streams) {
268
+ if (st.phase === 'done' && now - st.lastSeen > 4000) streams.delete(k)
269
+ }
270
+ }, 1000)
271
+ ctx.effect(stopReap, 'dsh-soup: speed stream reap')
272
+
273
+ /** 瞬时速率窗口 / 最小统计跨度 / 停顿判定阈值。 */
274
+ const RATE_WINDOW_MS = 2000
275
+ const RATE_MIN_SPAN_MS = 500
276
+ const STALL_MS = 2000
277
+ /** 真实生成下限:低于此速率必然是等待被误标(DeepSeek 正常输出远高于此)。 */
278
+ const MIN_REAL_TPS = 3
279
+ /** 速率分母宽限:内容停止后分母最多延伸这么久,静默期不稀释速率。 */
280
+ const SPAN_GRACE_MS = 500
281
+
282
+ /** 返回当前会话的速度状态(无记录时回退到最近活跃流)。 */
283
+ function speedStatus(sid) {
284
+ let st = streams.get(sid)
285
+ if (!st) {
286
+ let latest = null
287
+ for (const s of streams.values()) if (!latest || s.lastSeen > latest.lastSeen) latest = s
288
+ st = latest
289
+ }
290
+ if (!st) return { phase: 'idle' }
291
+ const now = Date.now()
292
+ const tokens = st.hasReal ? st.realTokens : Math.round(st.charTokens)
293
+ const ttft = st.firstChunkAt ? st.firstChunkAt - st.startedAt : null
294
+ if (st.phase !== 'streaming' || !st.firstChunkAt) {
295
+ return { phase: st.phase, tokens, tps: 0, ttft }
296
+ }
297
+ // 停顿按「内容时钟」判定:keepalive / 空 delta 滴漏不算活动。
298
+ const contentAt = st.lastContentAt || st.firstChunkAt
299
+ const sinceContent = now - contentAt
300
+ if (sinceContent > STALL_MS) {
301
+ return { phase: 'waiting', tokens, tps: 0, ttft }
302
+ }
303
+ // 滑动窗口瞬时速率:基线优先取窗口前最后一个样本(burst 场景也能算出增量),
304
+ // 否则取窗口内第一个样本。
305
+ const cutoff = now - RATE_WINDOW_MS
306
+ let base = null
307
+ let first = null
308
+ for (const s of st.samples) {
309
+ if (first === null) first = s
310
+ if (s[0] < cutoff) base = s
311
+ }
312
+ if (base === null) base = first
313
+ let tps = 0
314
+ if (base && now - base[0] >= RATE_MIN_SPAN_MS) {
315
+ // 分母封顶到最后内容时刻 + 宽限:静默期不再把速率摊薄成 0.x t/s。
316
+ const spanEnd = Math.min(now, contentAt + SPAN_GRACE_MS)
317
+ if (spanEnd - base[0] >= RATE_MIN_SPAN_MS) {
318
+ tps = Math.max(0, (tokens - base[1]) / ((spanEnd - base[0]) / 1000))
319
+ }
320
+ }
321
+ // 近零进展护栏:内容已停顿超过最小跨度时,任何低于真实生成下限的读数
322
+ // (含 0 与负增量截断)都判回等待——0.3 t/s 必然是等待,不是极慢生成。
323
+ if (sinceContent >= RATE_MIN_SPAN_MS && tps < MIN_REAL_TPS) {
324
+ return { phase: 'waiting', tokens, tps: 0, ttft }
325
+ }
326
+ return { phase: 'streaming', tokens, tps, ttft }
327
+ }
328
+
329
+ async function runCmd(argv) {
330
+ try {
331
+ const proc = subprocess.spawn({
332
+ argv,
333
+ cwd: '/',
334
+ stdio: { stdin: 'ignore', stdout: 'ignore', stderr: { maxBytes: 4096 } },
335
+ graceMs: 6000,
336
+ })
337
+ const outcome = await proc.done
338
+ return { code: outcome.exitCode }
339
+ } catch (err) {
340
+ return { error: String((err && err.message) || err) }
341
+ }
342
+ }
343
+
344
+ /** 拼接目录与子项(统一正斜杠,兼容各平台)。 */
345
+ function joinPath(dir, name) {
346
+ return String(dir || '').replace(/[\\/]+$/, '') + '/' + name
347
+ }
348
+
349
+ /** 系统打开一个路径(按平台选命令)。 */
350
+ async function openPath(path) { const p = String(path || '')
351
+ if (!p) return { code: -1, error: '缺少路径' }
352
+ if (PLATFORM === 'win') {
353
+ // explorer /select 打开所在位置;单文件用 start 调默认程序
354
+ return runCmd(['cmd', '/c', 'start', '', p])
355
+ }
356
+ if (PLATFORM === 'linux') {
357
+ return runCmd(['xdg-open', p])
358
+ }
359
+ return runCmd(['/usr/bin/open', p])
360
+ }
361
+
362
+ /** 移到废纸篓(按平台选实现)。 */
363
+ async function trashPath(path) {
364
+ const p = String(path || '')
365
+ if (!p) return { ok: false, error: '缺少路径' }
366
+ if (PLATFORM === 'win') {
367
+ // PowerShell Shell.Application 的 NameSpace(10) = 回收站
368
+ const esc = String(p).replace(/'/g, "''")
369
+ const script = `$sh = New-Object -ComObject Shell.Application; $sh.NameSpace(10).MoveHere('${esc}'); Start-Sleep -Milliseconds 300`
370
+ const res = await runCmd(['powershell', '-NoProfile', '-Command', script])
371
+ return res.code === 0
372
+ ? { ok: true }
373
+ : { ok: false, error: res.error || '移动失败', hint: '请确认 PowerShell 可用(Windows 10+ 自带)' }
374
+ }
375
+ if (PLATFORM === 'linux') {
376
+ // 优先 gio trash(GNOME 自带),回退 trash-cli 的 trash 命令
377
+ const res = await runCmd(['gio', 'trash', p])
378
+ if (res.code === 0) return { ok: true }
379
+ const res2 = await runCmd(['trash', p])
380
+ if (res2.code === 0) return { ok: true }
381
+ return { ok: false, error: (res.error || res2.error) || '移动失败', hint: '请安装 gio(glib2)或 trash-cli(如: sudo apt install trash-cli)' }
382
+ }
383
+ // mac: Finder osascript
384
+ const esc = String(p).replace(/"/g, '\\"')
385
+ const script = `tell application "Finder" to delete POSIX file "${esc}"`
386
+ const res = await runCmd(['/usr/bin/osascript', '-e', script])
387
+ return res.code === 0
388
+ ? { ok: true }
389
+ : { ok: false, error: res.error || '移动失败', hint: '如需移到废纸篓,请在 系统设置→隐私与安全性→自动化 中允许 DSH 控制 Finder' }
390
+ }
391
+
392
+ // ------------------------------------------------------------------
393
+ // 文件只读预览:read(文本/图片/二进制三态);无 write——预览-only 不提供保存
394
+ // ------------------------------------------------------------------
395
+ /** 文本读取上限(超过则截断标记 truncated)。 */
396
+ const TEXT_MAX_BYTES = 2 * 1024 * 1024
397
+ /** 图片内联上限。 */
398
+ const IMAGE_MAX_BYTES = 8 * 1024 * 1024
399
+ /** PDF 内联上限。 */
400
+ const PDF_MAX_BYTES = 20 * 1024 * 1024
401
+ /** Notebook(.ipynb)文本上限——含图片输出的 base64,体积膨胀快,宽于普通文本。 */
402
+ const NB_MAX_BYTES = 20 * 1024 * 1024
403
+ /** 二进制嗅探窗口:头部出现 NUL 即判二进制。 */
404
+ const BINARY_SNIFF = 8192
405
+ /** 图片扩展名 → MIME。 */
406
+ const IMAGE_MIME = {
407
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
408
+ webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp', ico: 'image/x-icon',
409
+ }
410
+ function extOf(p) {
411
+ const m = /\.([A-Za-z0-9]+)$/.exec(String(p || ''))
412
+ return m ? m[1].toLowerCase() : ''
413
+ }
414
+
415
+ /**
416
+ * 读取文件供 tab 展示:图片返回 base64,文本返回内容(可截断),
417
+ * 二进制只返回元信息。统一走 node:fs/promises(跨平台、无 shell 注入面)。
418
+ */
419
+ async function readFileForView(path) {
420
+ const p = String(path || '')
421
+ if (!p) return { ok: false, error: '缺少路径' }
422
+ let st
423
+ try {
424
+ st = await stat(p)
425
+ } catch (err) {
426
+ return { ok: false, error: `无法读取文件: ${String((err && err.message) || err)}` }
427
+ }
428
+ if (st.isDirectory()) return { ok: false, error: '目标是目录' }
429
+ const ext = extOf(p)
430
+ if (IMAGE_MIME[ext]) {
431
+ if (st.size > IMAGE_MAX_BYTES) return { ok: true, kind: 'image-too-large', size: st.size, limit: IMAGE_MAX_BYTES }
432
+ try {
433
+ const fh = await openFile(p, 'r')
434
+ try {
435
+ const buf = Buffer.alloc(st.size)
436
+ await fh.read(buf, 0, st.size, 0)
437
+ return { ok: true, kind: 'image', mime: IMAGE_MIME[ext], data: buf.toString('base64'), size: st.size }
438
+ } finally {
439
+ await fh.close()
440
+ }
441
+ } catch (err) {
442
+ return { ok: false, error: `无法读取文件: ${String((err && err.message) || err)}` }
443
+ }
444
+ }
445
+ // PDF:整文件 base64 返回,交给浏览器原生查看器渲染(JupyterLab
446
+ // pdf-extension 同思路:b64 → Blob → object 嵌入)。超限返回元信息。
447
+ if (ext === 'pdf') {
448
+ if (st.size > PDF_MAX_BYTES) return { ok: true, kind: 'pdf-too-large', size: st.size, limit: PDF_MAX_BYTES }
449
+ try {
450
+ const fh = await openFile(p, 'r')
451
+ try {
452
+ const buf = Buffer.alloc(st.size)
453
+ await fh.read(buf, 0, st.size, 0)
454
+ return { ok: true, kind: 'pdf', data: buf.toString('base64'), size: st.size }
455
+ } finally {
456
+ await fh.close()
457
+ }
458
+ } catch (err) {
459
+ return { ok: false, error: `无法读取文件: ${String((err && err.message) || err)}` }
460
+ }
461
+ }
462
+ // .ipynb 含图片输出的 base64,体积膨胀快——单独放宽文本上限
463
+ const textCap = ext === 'ipynb' ? NB_MAX_BYTES : TEXT_MAX_BYTES
464
+ const want = Math.min(st.size, textCap + 1)
465
+ try {
466
+ const fh = await openFile(p, 'r')
467
+ let bytesRead
468
+ let buf
469
+ try {
470
+ buf = Buffer.alloc(want)
471
+ const r = await fh.read(buf, 0, want, 0)
472
+ bytesRead = r.bytesRead
473
+ } finally {
474
+ await fh.close()
475
+ }
476
+ const sniffEnd = Math.min(bytesRead, BINARY_SNIFF)
477
+ for (let i = 0; i < sniffEnd; i++) {
478
+ if (buf[i] === 0) return { ok: true, kind: 'binary', size: st.size }
479
+ }
480
+ const truncated = st.size > textCap
481
+ return { ok: true, kind: 'text', content: buf.toString('utf8', 0, Math.min(bytesRead, textCap)), size: st.size, truncated }
482
+ } catch (err) {
483
+ return { ok: false, error: `无法读取文件: ${String((err && err.message) || err)}` }
484
+ }
485
+ }
486
+
487
+ // ------------------------------------------------------------------
488
+ // 下载票据:POST 换取一次性短时效 URL,/dl 流式路由凭票发送。
489
+ // 票据不可伪造 → GET 通道无需鉴权头也能保持与 POST 同级的门禁强度。
490
+ // ------------------------------------------------------------------
491
+ const downloadTickets = new Map()
492
+ ctx.effect(
493
+ () => ctx.timer.interval(() => {
494
+ const now = Date.now()
495
+ for (const [k, v] of downloadTickets) if (v.exp < now) downloadTickets.delete(k)
496
+ }, 30000),
497
+ 'dsh-soup: download ticket sweep',
498
+ )
499
+
500
+ /** RFC 5987/6266 下载头:ASCII 兜底 + UTF-8 真名。 */
501
+ function contentDisposition(name) {
502
+ const fallback = name.replace(/[^\x20-\x7E]|["\\;\r\n]/g, '_') || 'download'
503
+ return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(name)}`
504
+ }
505
+
506
+ /** 流式下载路由:凭票单次发送,支持客户端中断即停读。 */
507
+ async function dlHandler(req, res) {
508
+ try {
509
+ const token = new URL(req.url || '/?t=', 'http://local').searchParams.get('t') || ''
510
+ const entry = downloadTickets.get(token)
511
+ if (!entry) {
512
+ writeJson(res, 404, { ok: false, error: '下载票据无效或已使用' })
513
+ return
514
+ }
515
+ downloadTickets.delete(token)
516
+ const st = await stat(entry.path)
517
+ res.writeHead(200, {
518
+ 'content-type': 'application/octet-stream',
519
+ 'content-length': String(st.size),
520
+ 'content-disposition': contentDisposition(entry.name),
521
+ 'cache-control': 'no-store',
522
+ })
523
+ const stream = createReadStream(entry.path)
524
+ req.on('close', () => stream.destroy())
525
+ stream.pipe(res)
526
+ } catch (err) {
527
+ if (!res.headersSent) writeJson(res, 500, { ok: false, error: String((err && err.message) || err) })
528
+ else res.end()
529
+ }
530
+ }
531
+ ctx.effect(
532
+ () => ctx.webServer.register({ kind: 'exact', path: '/api/dsh-soup/dl', handler: dlHandler }),
533
+ 'dsh-soup: streaming download route',
534
+ )
535
+
536
+ /** 统一路由 dispatch:每个 action 返回可 JSON 序列化的结果。 */
537
+ async function handleAction(body) {
538
+ const action = body && body.action
539
+ const args = (body && body.args) || {}
540
+ const sessionIdArg = args && args.sessionId ? String(args.sessionId) : undefined
541
+ switch (action) {
542
+ case 'root': {
543
+ return { ok: true, root }
544
+ }
545
+ case 'sessionCwd': {
546
+ const id = args.sessionId
547
+ const session = id ? sessions.get(id) : undefined
548
+ const cwd = session && session.header && session.header.cwd
549
+ return cwd ? { ok: true, cwd } : { ok: false }
550
+ }
551
+ case 'list': {
552
+ const rp = await confine(String(args.path || '') || root, '目录', sessionIdArg)
553
+ let target
554
+ let entries
555
+ try {
556
+ target = await fs.resolve(rp, { cwd: root })
557
+ entries = await fs.listDir(target)
558
+ } catch (err) {
559
+ return { ok: false, error: `无法读取目录: ${String((err && err.message) || err)}` }
560
+ }
561
+ const basePath = (target && target.displayPath) || rp
562
+ const out = entries.map((entry) => {
563
+ const item = {
564
+ name: entry.name,
565
+ type: entry.type,
566
+ path: entry.target && entry.target.displayPath ? entry.target.displayPath : `${basePath}/${entry.name}`,
567
+ }
568
+ if (typeof entry.size === 'number' && entry.size >= 0) item.size = entry.size
569
+ return item
570
+ })
571
+ return { ok: true, root, path: basePath, entries: out }
572
+ }
573
+ case 'open': {
574
+ const rp = await confine(args.path, '打开路径', sessionIdArg)
575
+ const res = await openPath(rp)
576
+ return res.code === 0 ? { ok: true } : { ok: false, error: res.error || '打开失败' }
577
+ }
578
+ case 'trash': {
579
+ const rp = await confine(args.path, '废纸篓路径', sessionIdArg)
580
+ return trashPath(rp)
581
+ }
582
+ case 'move': {
583
+ const from = String((args && args.from) || '')
584
+ const to = String((args && args.to) || '')
585
+ if (!from || !to) return { ok: false, error: '缺少路径' }
586
+ const fromRp = await confine(from, '源路径', sessionIdArg)
587
+ const toRp = await confine(to, '目标路径', sessionIdArg)
588
+ if (toRp !== fromRp && within(fromRp, toRp)) return { ok: false, error: '不能把文件夹移入其自身子目录' }
589
+ try {
590
+ await rename(fromRp, toRp)
591
+ return { ok: true }
592
+ } catch (err) {
593
+ return { ok: false, error: `移动/重命名失败: ${String((err && err.message) || err)}` }
594
+ }
595
+ }
596
+ case 'create': {
597
+ const dir = String((args && args.dir) || '')
598
+ const newName = String((args && args.name) || '')
599
+ const isDir = Boolean(args && args.isDir)
600
+ if (!dir || !newName || newName === '.' || newName === '..' || newName.includes('/') || newName.includes('\\') || newName.includes('\0')) {
601
+ return { ok: false, error: '无效文件名' }
602
+ }
603
+ const dirRp = await confine(dir, '目录', sessionIdArg)
604
+ // 二次围栏:name 若与既有符号链接同名,lenient realpath 会解析出
605
+ // 链接真实指向,越界即拒绝。
606
+ const target = await confine(joinPath(dirRp, newName), '创建路径', sessionIdArg)
607
+ try {
608
+ if (isDir) {
609
+ await mkdir(target, { recursive: true })
610
+ } else {
611
+ await writeFile(target, '')
612
+ }
613
+ return { ok: true, path: target }
614
+ } catch (err) {
615
+ return { ok: false, error: `${isDir ? '新建文件夹' : '新建文件'}失败: ${String((err && err.message) || err)}` }
616
+ }
617
+ }
618
+ case 'upload': {
619
+ const dir = String((args && args.dir) || '')
620
+ const fileName = String((args && args.name) || '')
621
+ const data = String((args && args.data) || '')
622
+ // 文件名与 create 同规:禁路径分隔符与相对段,杜绝 ../ 穿越。
623
+ if (!dir || !fileName || fileName === '.' || fileName === '..' || fileName.includes('/') || fileName.includes('\\') || fileName.includes('\0')) {
624
+ return { ok: false, error: '无效文件名' }
625
+ }
626
+ const dirRp = await confine(dir, '目录', sessionIdArg)
627
+ const target = await confine(joinPath(dirRp, fileName), '上传路径', sessionIdArg)
628
+ // 分块上传(JupyterLab 同款语义):chunk=1/缺省覆盖写,chunk>=2 追加。
629
+ // 无需服务端状态——按块序到达即顺序落盘。
630
+ const chunk = Number((args && args.chunk) || 0)
631
+ if (chunk < 0 || !Number.isInteger(chunk)) return { ok: false, error: '无效的 chunk 序号' }
632
+ try {
633
+ const bytes = Buffer.from(data.replace(/\s+/g, ''), 'base64')
634
+ if (chunk >= 2) await appendFile(target, bytes)
635
+ else await writeFile(target, bytes)
636
+ return { ok: true, path: target }
637
+ } catch (err) {
638
+ return { ok: false, error: `写入失败: ${String((err && err.message) || err)}` }
639
+ }
640
+ }
641
+ case 'read': {
642
+ const rp = await confine(args.path, '文件路径', sessionIdArg)
643
+ return readFileForView(rp)
644
+ }
645
+ case 'download': {
646
+ // 流式下载签发:围栏校验通过后发一张一次性、2 分钟时效的票据 URL,
647
+ // 真正的字节由 /api/dsh-soup/dl 流式发送——任意大小文件零内存放大。
648
+ const rp = await confine(args.path, '下载路径', sessionIdArg)
649
+ let dst
650
+ try {
651
+ dst = await stat(rp)
652
+ } catch (err) {
653
+ return { ok: false, error: '无法读取文件' }
654
+ }
655
+ if (dst.isDirectory()) return { ok: false, error: '目标是目录,不支持下载' }
656
+ const token = randomBytes(24).toString('hex')
657
+ downloadTickets.set(token, { path: rp, name: path.basename(rp), exp: Date.now() + 120000 })
658
+ return { ok: true, name: path.basename(rp), size: dst.size, url: `/api/dsh-soup/dl?t=${token}` }
659
+ }
660
+ case 'speed-status': {
661
+ const sid = args && args.sessionId ? String(args.sessionId) : ''
662
+ return { ok: true, ...speedStatus(sid) }
663
+ }
664
+ default:
665
+ return { ok: false, error: `unknown action: ${String(action)}` }
666
+ }
667
+ }
668
+
669
+ /**
670
+ * 请求门:同源校验 + 强制自定义头。
671
+ * - 仅接受 POST;OPTIONS 直接 403(让浏览器 preflight 失败),其余 405;
672
+ * - 带 Origin 时必须与 Host 同源——挡跨站 CSRF 与 DNS rebinding
673
+ * (rebinding 页面的 Origin 是攻击者域名,与 Host 必不相同);
674
+ * - 必须携带 x-dsh-soup: 1 且 content-type 为 application/json:
675
+ * 自定义头会强制浏览器先走 preflight,而本路由永不回 CORS 头,
676
+ * 因此恶意网页无法用 text/plain「简单请求」盲打写操作。
677
+ * 非浏览器本地工具(无 Origin 头)不受影响。
678
+ */
679
+ function gate(req, res) {
680
+ const method = String(req.method || 'POST').toUpperCase()
681
+ if (method === 'OPTIONS') {
682
+ writeJson(res, 403, { ok: false, error: 'forbidden' })
683
+ return true
684
+ }
685
+ if (method !== 'POST') {
686
+ res.setHeader('Allow', 'POST')
687
+ writeJson(res, 405, { ok: false, error: '仅支持 POST' })
688
+ return true
689
+ }
690
+ const headers = req.headers || {}
691
+ const origin = headers.origin
692
+ if (typeof origin === 'string' && origin !== '') {
693
+ let originHost = ''
694
+ try { originHost = new URL(origin).host } catch { originHost = '' }
695
+ const host = typeof headers.host === 'string' ? headers.host : ''
696
+ if (!originHost || originHost !== host) {
697
+ writeJson(res, 403, { ok: false, error: '跨源请求已拒绝' })
698
+ return true
699
+ }
700
+ }
701
+ const contentType = String(headers['content-type'] || '').toLowerCase()
702
+ if (headers['x-dsh-soup'] !== '1' || !contentType.startsWith('application/json')) {
703
+ writeJson(res, 403, { ok: false, error: '缺少必要的请求头' })
704
+ return true
705
+ }
706
+ return false
707
+ }
708
+
709
+ /** HTTP 处理器:请求门 → 解析 body → dispatch → JSON 响应。 */
710
+ async function handler(req, res) {
711
+ try {
712
+ if (gate(req, res)) return
713
+ const body = await readBody(req)
714
+ const result = await handleAction(body)
715
+ writeJson(res, 200, result)
716
+ } catch (err) {
717
+ writeJson(res, (err && err.statusCode) || 500, { ok: false, error: String((err && err.message) || err) })
718
+ }
719
+ }
720
+
721
+ ctx.effect(
722
+ () => ctx.webServer.register({ kind: 'exact', path: '/api/dsh-soup', handler }),
723
+ 'dsh-soup: file http route',
724
+ )
725
+ }