@lyhue1991/dsh-soup 0.4.4 → 0.4.6
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/client.js +2851 -2729
- package/lib/client.js.map +7 -0
- package/lib/index.js +521 -646
- package/package.json +8 -1
package/lib/index.js
CHANGED
|
@@ -1,803 +1,678 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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]/
|
|
1
|
+
// src/host/speed.ts
|
|
2
|
+
function setupSpeedTracking(deps) {
|
|
3
|
+
const { ctx } = deps;
|
|
4
|
+
const CJK = /[\u3000-\u30ff\u3400-\u9fff\uf900-\ufaff\uac00-\ud7af]/;
|
|
160
5
|
function estimateChars(text) {
|
|
161
|
-
let cjk = 0
|
|
162
|
-
let rest = 0
|
|
163
|
-
for (const ch of text) {
|
|
164
|
-
|
|
6
|
+
let cjk = 0;
|
|
7
|
+
let rest = 0;
|
|
8
|
+
for (const ch of text) {
|
|
9
|
+
if (CJK.test(ch)) cjk++;
|
|
10
|
+
else rest++;
|
|
11
|
+
}
|
|
12
|
+
return cjk + rest / 4;
|
|
165
13
|
}
|
|
166
|
-
|
|
167
|
-
/** 会话级流状态:phase / token / tps / ttft。 */
|
|
168
|
-
const streams = new Map()
|
|
169
|
-
|
|
14
|
+
const streams = /* @__PURE__ */ new Map();
|
|
170
15
|
function extractKey(options) {
|
|
171
16
|
try {
|
|
172
|
-
if (!options) return null
|
|
173
|
-
if (typeof options.sessionId ===
|
|
174
|
-
if (options.session && typeof options.session.id ===
|
|
17
|
+
if (!options) return null;
|
|
18
|
+
if (typeof options.sessionId === "string") return options.sessionId;
|
|
19
|
+
if (options.session && typeof options.session.id === "string") return options.session.id;
|
|
175
20
|
if (options.agent) {
|
|
176
|
-
const a = options.agent
|
|
177
|
-
if (typeof a.sessionId ===
|
|
178
|
-
if (typeof a.id ===
|
|
179
|
-
if (a.session && typeof a.session.id ===
|
|
180
|
-
}
|
|
181
|
-
if (options.meta && typeof options.meta.sessionId ===
|
|
182
|
-
} catch (err) {
|
|
183
|
-
|
|
21
|
+
const a = options.agent;
|
|
22
|
+
if (typeof a.sessionId === "string") return a.sessionId;
|
|
23
|
+
if (typeof a.id === "string") return a.id;
|
|
24
|
+
if (a.session && typeof a.session.id === "string") return a.session.id;
|
|
25
|
+
}
|
|
26
|
+
if (options.meta && typeof options.meta.sessionId === "string") return options.meta.sessionId;
|
|
27
|
+
} catch (err) {
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
184
30
|
}
|
|
185
|
-
|
|
186
31
|
function extractChunk(chunk) {
|
|
187
|
-
let text =
|
|
188
|
-
let realTokens = null
|
|
32
|
+
let text = "";
|
|
33
|
+
let realTokens = null;
|
|
189
34
|
try {
|
|
190
|
-
if (!chunk) return { text, realTokens }
|
|
191
|
-
if (typeof chunk.text ===
|
|
192
|
-
if (typeof chunk.content ===
|
|
35
|
+
if (!chunk) return { text, realTokens };
|
|
36
|
+
if (typeof chunk.text === "string") text += chunk.text;
|
|
37
|
+
if (typeof chunk.content === "string") text += chunk.content;
|
|
193
38
|
if (Array.isArray(chunk.content)) {
|
|
194
|
-
for (const part of chunk.content) if (part && typeof part.text ===
|
|
39
|
+
for (const part of chunk.content) if (part && typeof part.text === "string") text += part.text;
|
|
195
40
|
}
|
|
196
41
|
if (chunk.delta) {
|
|
197
|
-
const d = chunk.delta
|
|
198
|
-
if (typeof d.text ===
|
|
199
|
-
if (typeof d.content ===
|
|
42
|
+
const d = chunk.delta;
|
|
43
|
+
if (typeof d.text === "string") text += d.text;
|
|
44
|
+
if (typeof d.content === "string") text += d.content;
|
|
200
45
|
if (d.usage) {
|
|
201
|
-
const ot = d.usage.outputTokens != null ? d.usage.outputTokens
|
|
202
|
-
|
|
203
|
-
: (d.usage.completion_tokens != null ? d.usage.completion_tokens : d.usage.completionTokens))
|
|
204
|
-
if (typeof ot === 'number') realTokens = ot
|
|
46
|
+
const ot = d.usage.outputTokens != null ? d.usage.outputTokens : d.usage.output_tokens != null ? d.usage.output_tokens : d.usage.completion_tokens != null ? d.usage.completion_tokens : d.usage.completionTokens;
|
|
47
|
+
if (typeof ot === "number") realTokens = ot;
|
|
205
48
|
}
|
|
206
49
|
}
|
|
207
50
|
if (chunk.usage) {
|
|
208
|
-
const u = chunk.usage
|
|
209
|
-
const ot = u.outputTokens != null ? u.outputTokens
|
|
210
|
-
|
|
211
|
-
: (u.completion_tokens != null ? u.completion_tokens : u.completionTokens))
|
|
212
|
-
if (typeof ot === 'number') realTokens = ot
|
|
51
|
+
const u = chunk.usage;
|
|
52
|
+
const ot = u.outputTokens != null ? u.outputTokens : u.output_tokens != null ? u.output_tokens : u.completion_tokens != null ? u.completion_tokens : u.completionTokens;
|
|
53
|
+
if (typeof ot === "number") realTokens = ot;
|
|
213
54
|
}
|
|
214
55
|
if (Array.isArray(chunk.choices)) {
|
|
215
56
|
for (const c of chunk.choices) {
|
|
216
|
-
const d = c && c.delta
|
|
217
|
-
if (d && typeof d.content ===
|
|
57
|
+
const d = c && c.delta;
|
|
58
|
+
if (d && typeof d.content === "string") text += d.content;
|
|
218
59
|
if (d && d.usage) {
|
|
219
|
-
const ot = d.usage.outputTokens != null ? d.usage.outputTokens : d.usage.output_tokens
|
|
220
|
-
if (typeof ot ===
|
|
60
|
+
const ot = d.usage.outputTokens != null ? d.usage.outputTokens : d.usage.output_tokens;
|
|
61
|
+
if (typeof ot === "number") realTokens = ot;
|
|
221
62
|
}
|
|
222
63
|
}
|
|
223
64
|
}
|
|
224
|
-
} catch (err) {
|
|
225
|
-
|
|
65
|
+
} catch (err) {
|
|
66
|
+
}
|
|
67
|
+
return { text, realTokens };
|
|
226
68
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const innerP = Promise.resolve(next())
|
|
69
|
+
ctx.on("llm/stream", (options, next) => {
|
|
70
|
+
const key = extractKey(options) || "_";
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
streams.set(key, { phase: "waiting", startedAt: now, firstChunkAt: null, charTokens: 0, realTokens: 0, hasReal: false, lastChunkAt: null, lastContentAt: null, samples: [], lastSeen: now });
|
|
73
|
+
const innerP = Promise.resolve(next());
|
|
233
74
|
return (async function* () {
|
|
234
75
|
try {
|
|
235
|
-
const inner = await innerP
|
|
76
|
+
const inner = await innerP;
|
|
236
77
|
for await (const chunk of inner) {
|
|
237
|
-
const st = streams.get(key)
|
|
78
|
+
const st = streams.get(key);
|
|
238
79
|
if (st) {
|
|
239
|
-
const parsed = extractChunk(chunk)
|
|
240
|
-
const t = Date.now()
|
|
241
|
-
if (st.firstChunkAt === null) {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
st.
|
|
80
|
+
const parsed = extractChunk(chunk);
|
|
81
|
+
const t = Date.now();
|
|
82
|
+
if (st.firstChunkAt === null) {
|
|
83
|
+
st.firstChunkAt = t;
|
|
84
|
+
st.phase = "streaming";
|
|
85
|
+
}
|
|
86
|
+
if (parsed.text) st.charTokens += estimateChars(parsed.text);
|
|
87
|
+
if (parsed.realTokens != null) {
|
|
88
|
+
st.realTokens = parsed.realTokens;
|
|
89
|
+
st.hasReal = true;
|
|
90
|
+
}
|
|
91
|
+
st.lastChunkAt = t;
|
|
92
|
+
if (parsed.text && parsed.text.length > 0 || parsed.realTokens != null) st.lastContentAt = t;
|
|
93
|
+
st.samples.push([t, st.hasReal ? st.realTokens : st.charTokens]);
|
|
94
|
+
if (st.samples.length > 128) st.samples.splice(0, st.samples.length - 128);
|
|
95
|
+
st.lastSeen = t;
|
|
252
96
|
}
|
|
253
|
-
yield chunk
|
|
97
|
+
yield chunk;
|
|
254
98
|
}
|
|
255
99
|
} finally {
|
|
256
|
-
const st = streams.get(key)
|
|
100
|
+
const st = streams.get(key);
|
|
257
101
|
if (st) {
|
|
258
|
-
st.phase =
|
|
259
|
-
st.lastSeen = Date.now()
|
|
102
|
+
st.phase = "done";
|
|
103
|
+
st.lastSeen = Date.now();
|
|
260
104
|
}
|
|
261
105
|
}
|
|
262
|
-
})()
|
|
263
|
-
})
|
|
264
|
-
|
|
106
|
+
})();
|
|
107
|
+
});
|
|
265
108
|
const stopReap = ctx.timer.interval(() => {
|
|
266
|
-
const now = Date.now()
|
|
109
|
+
const now = Date.now();
|
|
267
110
|
for (const [k, st] of streams) {
|
|
268
|
-
if (st.phase ===
|
|
111
|
+
if (st.phase === "done" && now - st.lastSeen > 4e3) streams.delete(k);
|
|
269
112
|
}
|
|
270
|
-
},
|
|
271
|
-
ctx.effect(stopReap,
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
const
|
|
275
|
-
const
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
/** 速率分母宽限:内容停止后分母最多延伸这么久,静默期不稀释速率。 */
|
|
280
|
-
const SPAN_GRACE_MS = 500
|
|
281
|
-
|
|
282
|
-
/** 返回当前会话的速度状态(无记录时回退到最近活跃流)。 */
|
|
283
|
-
function speedStatus(sid) {
|
|
284
|
-
let st = streams.get(sid)
|
|
113
|
+
}, 1e3);
|
|
114
|
+
ctx.effect(stopReap, "dsh-soup: speed stream reap");
|
|
115
|
+
const RATE_WINDOW_MS = 2e3;
|
|
116
|
+
const RATE_MIN_SPAN_MS = 500;
|
|
117
|
+
const STALL_MS = 2e3;
|
|
118
|
+
const MIN_REAL_TPS = 3;
|
|
119
|
+
const SPAN_GRACE_MS = 1500;
|
|
120
|
+
function speedStatus(sessionId) {
|
|
121
|
+
let st = sessionId ? streams.get(sessionId) : void 0;
|
|
285
122
|
if (!st) {
|
|
286
|
-
let latest = null
|
|
287
|
-
for (const s of streams.values()) if (!latest || s.lastSeen > latest.lastSeen) latest = s
|
|
288
|
-
st = latest
|
|
123
|
+
let latest = null;
|
|
124
|
+
for (const s of streams.values()) if (!latest || s.lastSeen > latest.lastSeen) latest = s;
|
|
125
|
+
st = latest;
|
|
289
126
|
}
|
|
290
|
-
if (!st) return { phase:
|
|
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 !==
|
|
295
|
-
return { phase: st.phase, tokens, tps: 0, ttft }
|
|
127
|
+
if (!st) return { phase: "idle" };
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
const tokens = st.hasReal ? st.realTokens : Math.round(st.charTokens);
|
|
130
|
+
const ttft = st.firstChunkAt ? st.firstChunkAt - st.startedAt : null;
|
|
131
|
+
if (st.phase !== "streaming" || !st.firstChunkAt) {
|
|
132
|
+
return { phase: st.phase, tokens, tps: 0, ttft };
|
|
296
133
|
}
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
const sinceContent = now - contentAt
|
|
134
|
+
const contentAt = st.lastContentAt || st.firstChunkAt;
|
|
135
|
+
const sinceContent = now - contentAt;
|
|
300
136
|
if (sinceContent > STALL_MS) {
|
|
301
|
-
return { phase:
|
|
137
|
+
return { phase: "waiting", tokens, tps: 0, ttft };
|
|
302
138
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
let base = null
|
|
307
|
-
let first = null
|
|
139
|
+
const cutoff = now - RATE_WINDOW_MS;
|
|
140
|
+
let base = null;
|
|
141
|
+
let first = null;
|
|
308
142
|
for (const s of st.samples) {
|
|
309
|
-
if (first === null) first = s
|
|
310
|
-
if (s[0] < cutoff) base = s
|
|
143
|
+
if (first === null) first = s;
|
|
144
|
+
if (s[0] < cutoff) base = s;
|
|
311
145
|
}
|
|
312
|
-
if (base === null) base = first
|
|
313
|
-
let tps = 0
|
|
146
|
+
if (base === null) base = first;
|
|
147
|
+
let tps = 0;
|
|
314
148
|
if (base && now - base[0] >= RATE_MIN_SPAN_MS) {
|
|
315
|
-
|
|
316
|
-
const spanEnd = Math.min(now, contentAt + SPAN_GRACE_MS)
|
|
149
|
+
const spanEnd = Math.min(now, contentAt + SPAN_GRACE_MS);
|
|
317
150
|
if (spanEnd - base[0] >= RATE_MIN_SPAN_MS) {
|
|
318
|
-
tps = Math.max(0, (tokens - base[1]) / ((spanEnd - base[0]) /
|
|
151
|
+
tps = Math.max(0, (tokens - base[1]) / ((spanEnd - base[0]) / 1e3));
|
|
319
152
|
}
|
|
320
153
|
}
|
|
321
|
-
// 近零进展护栏:内容已停顿超过最小跨度时,任何低于真实生成下限的读数
|
|
322
|
-
// (含 0 与负增量截断)都判回等待——0.3 t/s 必然是等待,不是极慢生成。
|
|
323
154
|
if (sinceContent >= RATE_MIN_SPAN_MS && tps < MIN_REAL_TPS) {
|
|
324
|
-
return { phase:
|
|
155
|
+
return { phase: "waiting", tokens, tps: 0, ttft };
|
|
325
156
|
}
|
|
326
|
-
return { phase:
|
|
157
|
+
return { phase: "streaming", tokens, tps, ttft };
|
|
327
158
|
}
|
|
159
|
+
return { speedStatus };
|
|
160
|
+
}
|
|
328
161
|
|
|
162
|
+
// src/host/files.ts
|
|
163
|
+
import { rename, mkdir, writeFile, appendFile, open as openFile, stat } from "node:fs/promises";
|
|
164
|
+
import { createReadStream } from "node:fs";
|
|
165
|
+
import { randomBytes } from "node:crypto";
|
|
166
|
+
import path2 from "node:path";
|
|
167
|
+
|
|
168
|
+
// src/host/confine.ts
|
|
169
|
+
import { realpath } from "node:fs/promises";
|
|
170
|
+
import path from "node:path";
|
|
171
|
+
function createConfine(deps) {
|
|
172
|
+
const { root, sessions } = deps;
|
|
173
|
+
const FOLD_CASE = deps.PLATFORM === "win";
|
|
174
|
+
function httpError(statusCode, message) {
|
|
175
|
+
return Object.assign(new Error(message), { statusCode });
|
|
176
|
+
}
|
|
177
|
+
async function realPathLenient(p) {
|
|
178
|
+
let cur = path.resolve(String(p));
|
|
179
|
+
const tail = [];
|
|
180
|
+
for (; ; ) {
|
|
181
|
+
try {
|
|
182
|
+
const rp = await realpath(cur);
|
|
183
|
+
return tail.length ? path.join(rp, ...tail) : rp;
|
|
184
|
+
} catch (err) {
|
|
185
|
+
const parent = path.dirname(cur);
|
|
186
|
+
if (!err || err.code !== "ENOENT" || parent === cur) throw err;
|
|
187
|
+
tail.unshift(path.basename(cur));
|
|
188
|
+
cur = parent;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
let rootRealCache = null;
|
|
193
|
+
async function rootReal() {
|
|
194
|
+
if (rootRealCache === null) {
|
|
195
|
+
try {
|
|
196
|
+
rootRealCache = await realpath(root);
|
|
197
|
+
} catch {
|
|
198
|
+
rootRealCache = path.resolve(root);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return rootRealCache;
|
|
202
|
+
}
|
|
203
|
+
function within(base, candidate) {
|
|
204
|
+
const b = FOLD_CASE ? base.toLowerCase() : base;
|
|
205
|
+
const c = FOLD_CASE ? candidate.toLowerCase() : candidate;
|
|
206
|
+
if (c === b) return true;
|
|
207
|
+
const rel = path.relative(b, c);
|
|
208
|
+
return rel !== "" && rel !== ".." && !rel.startsWith(".." + path.sep) && !path.isAbsolute(rel);
|
|
209
|
+
}
|
|
210
|
+
async function allowedBases(preferredSessionId) {
|
|
211
|
+
const bases = [await rootReal()];
|
|
212
|
+
const pushCwd = async (cwd) => {
|
|
213
|
+
if (typeof cwd === "string" && cwd) {
|
|
214
|
+
try {
|
|
215
|
+
bases.push(await realPathLenient(cwd));
|
|
216
|
+
} catch {
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
if (preferredSessionId && typeof sessions.get === "function") {
|
|
221
|
+
try {
|
|
222
|
+
const pref = sessions.get(preferredSessionId);
|
|
223
|
+
await pushCwd(pref && pref.header && pref.header.cwd);
|
|
224
|
+
} catch {
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
const list = typeof sessions.list === "function" ? sessions.list() : [];
|
|
229
|
+
for (const s of list) await pushCwd(s && s.header && s.header.cwd);
|
|
230
|
+
} catch {
|
|
231
|
+
}
|
|
232
|
+
return bases;
|
|
233
|
+
}
|
|
234
|
+
async function confine(rawPath, label, preferredSessionId) {
|
|
235
|
+
const name2 = label || "\u8DEF\u5F84";
|
|
236
|
+
const p = String(rawPath || "");
|
|
237
|
+
if (!p) throw httpError(400, `\u7F3A\u5C11${name2}`);
|
|
238
|
+
let rp;
|
|
239
|
+
try {
|
|
240
|
+
rp = await realPathLenient(p);
|
|
241
|
+
} catch (err) {
|
|
242
|
+
throw httpError(400, `${name2}\u4E0D\u53EF\u8FBE: ${p}`);
|
|
243
|
+
}
|
|
244
|
+
for (const base of await allowedBases(preferredSessionId)) {
|
|
245
|
+
if (within(base, rp)) return rp;
|
|
246
|
+
}
|
|
247
|
+
throw httpError(403, `${name2}\u8D85\u51FA\u5141\u8BB8\u8303\u56F4\uFF08\u4EC5\u9650\u5DE5\u4F5C\u533A\u4E0E\u4F1A\u8BDD\u76EE\u5F55\uFF09: ${p}`);
|
|
248
|
+
}
|
|
249
|
+
return { confine, httpError, realPathLenient };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// src/host/files.ts
|
|
253
|
+
function readBody(req) {
|
|
254
|
+
return new Promise((resolve, reject) => {
|
|
255
|
+
const chunks = [];
|
|
256
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
257
|
+
req.on("end", () => {
|
|
258
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
259
|
+
if (!text) {
|
|
260
|
+
resolve({});
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
resolve(JSON.parse(text));
|
|
265
|
+
} catch {
|
|
266
|
+
resolve({});
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
req.on("error", reject);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
function writeJson(res, code, data) {
|
|
273
|
+
const body = JSON.stringify(data);
|
|
274
|
+
res.writeHead(code, { "content-type": "application/json; charset=utf-8" });
|
|
275
|
+
res.end(body);
|
|
276
|
+
}
|
|
277
|
+
function setupFiles(deps) {
|
|
278
|
+
const { ctx, root, PLATFORM: PLATFORM2 } = deps;
|
|
279
|
+
const fs = ctx.fs;
|
|
280
|
+
const subprocess = ctx.subprocess;
|
|
281
|
+
const sessions = ctx.sessions;
|
|
282
|
+
const { confine, httpError } = createConfine({ root, PLATFORM: PLATFORM2, sessions });
|
|
329
283
|
async function runCmd(argv) {
|
|
330
284
|
try {
|
|
331
285
|
const proc = subprocess.spawn({
|
|
332
286
|
argv,
|
|
333
|
-
cwd:
|
|
334
|
-
stdio: { stdin:
|
|
335
|
-
graceMs:
|
|
336
|
-
})
|
|
337
|
-
const outcome = await proc.done
|
|
338
|
-
return { code: outcome.exitCode }
|
|
287
|
+
cwd: "/",
|
|
288
|
+
stdio: { stdin: "ignore", stdout: "ignore", stderr: { maxBytes: 4096 } },
|
|
289
|
+
graceMs: 6e3
|
|
290
|
+
});
|
|
291
|
+
const outcome = await proc.done;
|
|
292
|
+
return { code: outcome.exitCode };
|
|
339
293
|
} catch (err) {
|
|
340
|
-
return { error: String(
|
|
294
|
+
return { error: String(err && err.message || err) };
|
|
341
295
|
}
|
|
342
296
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
function joinPath(dir, name) {
|
|
346
|
-
return String(dir || '').replace(/[\\/]+$/, '') + '/' + name
|
|
297
|
+
function joinPath(dir, name2) {
|
|
298
|
+
return String(dir || "").replace(/[\\/]+$/, "") + "/" + name2;
|
|
347
299
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
if (
|
|
352
|
-
if (
|
|
353
|
-
|
|
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])
|
|
300
|
+
async function openPath(p) {
|
|
301
|
+
const pp = String(p || "");
|
|
302
|
+
if (!pp) return { code: -1, error: "\u7F3A\u5C11\u8DEF\u5F84" };
|
|
303
|
+
if (PLATFORM2 === "win") return runCmd(["cmd", "/c", "start", "", pp]);
|
|
304
|
+
if (PLATFORM2 === "linux") return runCmd(["xdg-open", pp]);
|
|
305
|
+
return runCmd(["/usr/bin/open", pp]);
|
|
360
306
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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+ 自带)' }
|
|
307
|
+
async function trashPath(p) {
|
|
308
|
+
const pp = String(p || "");
|
|
309
|
+
if (!pp) return { ok: false, error: "\u7F3A\u5C11\u8DEF\u5F84" };
|
|
310
|
+
if (PLATFORM2 === "win") {
|
|
311
|
+
const esc2 = String(pp).replace(/'/g, "''");
|
|
312
|
+
const script2 = `$sh = New-Object -ComObject Shell.Application; $sh.NameSpace(10).MoveHere('${esc2}'); Start-Sleep -Milliseconds 300`;
|
|
313
|
+
const res2 = await runCmd(["powershell", "-NoProfile", "-Command", script2]);
|
|
314
|
+
return res2.code === 0 ? { ok: true } : { ok: false, error: res2.error || "\u79FB\u52A8\u5931\u8D25", hint: "\u8BF7\u786E\u8BA4 PowerShell \u53EF\u7528\uFF08Windows 10+ \u81EA\u5E26\uFF09" };
|
|
374
315
|
}
|
|
375
|
-
if (
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
return { ok: false, error: (res.error || res2.error) || '移动失败', hint: '请安装 gio(glib2)或 trash-cli(如: sudo apt install trash-cli)' }
|
|
316
|
+
if (PLATFORM2 === "linux") {
|
|
317
|
+
const res2 = await runCmd(["gio", "trash", pp]);
|
|
318
|
+
if (res2.code === 0) return { ok: true };
|
|
319
|
+
const res22 = await runCmd(["trash", pp]);
|
|
320
|
+
if (res22.code === 0) return { ok: true };
|
|
321
|
+
return { ok: false, error: res2.error || res22.error || "\u79FB\u52A8\u5931\u8D25", hint: "\u8BF7\u5B89\u88C5 gio\uFF08glib2\uFF09\u6216 trash-cli\uFF08\u5982: sudo apt install trash-cli\uFF09" };
|
|
382
322
|
}
|
|
383
|
-
|
|
384
|
-
const
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
return res.code === 0
|
|
388
|
-
? { ok: true }
|
|
389
|
-
: { ok: false, error: res.error || '移动失败', hint: '如需移到废纸篓,请在 系统设置→隐私与安全性→自动化 中允许 DSH 控制 Finder' }
|
|
323
|
+
const esc = String(pp).replace(/"/g, '\\"');
|
|
324
|
+
const script = `tell application "Finder" to delete POSIX file "${esc}"`;
|
|
325
|
+
const res = await runCmd(["/usr/bin/osascript", "-e", script]);
|
|
326
|
+
return res.code === 0 ? { ok: true } : { ok: false, error: res.error || "\u79FB\u52A8\u5931\u8D25", hint: "\u5982\u9700\u79FB\u5230\u5E9F\u7EB8\u7BD3\uFF0C\u8BF7\u5728 \u7CFB\u7EDF\u8BBE\u7F6E\u2192\u9690\u79C1\u4E0E\u5B89\u5168\u6027\u2192\u81EA\u52A8\u5316 \u4E2D\u5141\u8BB8 DSH \u63A7\u5236 Finder" };
|
|
390
327
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
const TEXT_MAX_BYTES = 8 * 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。 */
|
|
328
|
+
const TEXT_MAX_BYTES = 8 * 1024 * 1024;
|
|
329
|
+
const IMAGE_MAX_BYTES = 8 * 1024 * 1024;
|
|
330
|
+
const PDF_MAX_BYTES = 20 * 1024 * 1024;
|
|
331
|
+
const NB_MAX_BYTES = 20 * 1024 * 1024;
|
|
332
|
+
const BINARY_SNIFF = 8192;
|
|
406
333
|
const IMAGE_MIME = {
|
|
407
|
-
png:
|
|
408
|
-
|
|
409
|
-
|
|
334
|
+
png: "image/png",
|
|
335
|
+
jpg: "image/jpeg",
|
|
336
|
+
jpeg: "image/jpeg",
|
|
337
|
+
gif: "image/gif",
|
|
338
|
+
webp: "image/webp",
|
|
339
|
+
svg: "image/svg+xml",
|
|
340
|
+
bmp: "image/bmp",
|
|
341
|
+
ico: "image/x-icon"
|
|
342
|
+
};
|
|
410
343
|
function extOf(p) {
|
|
411
|
-
const m = /\.([A-Za-z0-9]+)$/.exec(String(p ||
|
|
412
|
-
return m ? m[1].toLowerCase() :
|
|
344
|
+
const m = /\.([A-Za-z0-9]+)$/.exec(String(p || ""));
|
|
345
|
+
return m ? m[1].toLowerCase() : "";
|
|
413
346
|
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
*/
|
|
419
|
-
async function readFileForView(path) {
|
|
420
|
-
const p = String(path || '')
|
|
421
|
-
if (!p) return { ok: false, error: '缺少路径' }
|
|
422
|
-
let st
|
|
347
|
+
async function readFileForView(p) {
|
|
348
|
+
const pp = String(p || "");
|
|
349
|
+
if (!pp) return { ok: false, error: "\u7F3A\u5C11\u8DEF\u5F84" };
|
|
350
|
+
let st;
|
|
423
351
|
try {
|
|
424
|
-
st = await stat(
|
|
352
|
+
st = await stat(pp);
|
|
425
353
|
} catch (err) {
|
|
426
|
-
return { ok: false, error:
|
|
354
|
+
return { ok: false, error: `\u65E0\u6CD5\u8BFB\u53D6\u6587\u4EF6: ${String(err && err.message || err)}` };
|
|
427
355
|
}
|
|
428
|
-
if (st.isDirectory()) return { ok: false, error:
|
|
429
|
-
const ext = extOf(
|
|
356
|
+
if (st.isDirectory()) return { ok: false, error: "\u76EE\u6807\u662F\u76EE\u5F55" };
|
|
357
|
+
const ext = extOf(pp);
|
|
430
358
|
if (IMAGE_MIME[ext]) {
|
|
431
|
-
if (st.size > IMAGE_MAX_BYTES) return { ok: true, kind:
|
|
359
|
+
if (st.size > IMAGE_MAX_BYTES) return { ok: true, kind: "image-too-large", size: st.size, limit: IMAGE_MAX_BYTES };
|
|
432
360
|
try {
|
|
433
|
-
const fh = await openFile(
|
|
361
|
+
const fh = await openFile(pp, "r");
|
|
434
362
|
try {
|
|
435
|
-
const buf = Buffer.alloc(st.size)
|
|
436
|
-
await fh.read(buf, 0, st.size, 0)
|
|
437
|
-
return { ok: true, kind:
|
|
363
|
+
const buf = Buffer.alloc(st.size);
|
|
364
|
+
await fh.read(buf, 0, st.size, 0);
|
|
365
|
+
return { ok: true, kind: "image", mime: IMAGE_MIME[ext], data: buf.toString("base64"), size: st.size };
|
|
438
366
|
} finally {
|
|
439
|
-
await fh.close()
|
|
367
|
+
await fh.close();
|
|
440
368
|
}
|
|
441
369
|
} catch (err) {
|
|
442
|
-
return { ok: false, error:
|
|
370
|
+
return { ok: false, error: `\u65E0\u6CD5\u8BFB\u53D6\u6587\u4EF6: ${String(err && err.message || err)}` };
|
|
443
371
|
}
|
|
444
372
|
}
|
|
445
|
-
|
|
446
|
-
|
|
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 }
|
|
373
|
+
if (ext === "pdf") {
|
|
374
|
+
if (st.size > PDF_MAX_BYTES) return { ok: true, kind: "pdf-too-large", size: st.size, limit: PDF_MAX_BYTES };
|
|
449
375
|
try {
|
|
450
|
-
const fh = await openFile(
|
|
376
|
+
const fh = await openFile(pp, "r");
|
|
451
377
|
try {
|
|
452
|
-
const buf = Buffer.alloc(st.size)
|
|
453
|
-
await fh.read(buf, 0, st.size, 0)
|
|
454
|
-
return { ok: true, kind:
|
|
378
|
+
const buf = Buffer.alloc(st.size);
|
|
379
|
+
await fh.read(buf, 0, st.size, 0);
|
|
380
|
+
return { ok: true, kind: "pdf", data: buf.toString("base64"), size: st.size };
|
|
455
381
|
} finally {
|
|
456
|
-
await fh.close()
|
|
382
|
+
await fh.close();
|
|
457
383
|
}
|
|
458
384
|
} catch (err) {
|
|
459
|
-
return { ok: false, error:
|
|
385
|
+
return { ok: false, error: `\u65E0\u6CD5\u8BFB\u53D6\u6587\u4EF6: ${String(err && err.message || err)}` };
|
|
460
386
|
}
|
|
461
387
|
}
|
|
462
|
-
|
|
463
|
-
const
|
|
464
|
-
const want = Math.min(st.size, textCap + 1)
|
|
388
|
+
const textCap = ext === "ipynb" ? NB_MAX_BYTES : TEXT_MAX_BYTES;
|
|
389
|
+
const want = Math.min(st.size, textCap + 1);
|
|
465
390
|
try {
|
|
466
|
-
const fh = await openFile(
|
|
467
|
-
let bytesRead
|
|
468
|
-
let buf
|
|
391
|
+
const fh = await openFile(pp, "r");
|
|
392
|
+
let bytesRead;
|
|
393
|
+
let buf;
|
|
469
394
|
try {
|
|
470
|
-
buf = Buffer.alloc(want)
|
|
471
|
-
const r = await fh.read(buf, 0, want, 0)
|
|
472
|
-
bytesRead = r.bytesRead
|
|
395
|
+
buf = Buffer.alloc(want);
|
|
396
|
+
const r = await fh.read(buf, 0, want, 0);
|
|
397
|
+
bytesRead = r.bytesRead;
|
|
473
398
|
} finally {
|
|
474
|
-
await fh.close()
|
|
399
|
+
await fh.close();
|
|
475
400
|
}
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
401
|
+
const head = buf.subarray(0, Math.min(BINARY_SNIFF, bytesRead));
|
|
402
|
+
let isBinary = false;
|
|
403
|
+
for (let i = 0; i < head.length; i++) {
|
|
404
|
+
if (head[i] === 0) {
|
|
405
|
+
isBinary = true;
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
479
408
|
}
|
|
480
|
-
|
|
481
|
-
|
|
409
|
+
if (isBinary) return { ok: true, kind: "binary", size: st.size };
|
|
410
|
+
const truncated = st.size > textCap;
|
|
411
|
+
return { ok: true, kind: "text", content: buf.subarray(0, Math.min(bytesRead, textCap)).toString("utf-8"), truncated, size: st.size };
|
|
482
412
|
} catch (err) {
|
|
483
|
-
return { ok: false, error:
|
|
413
|
+
return { ok: false, error: `\u65E0\u6CD5\u8BFB\u53D6\u6587\u4EF6: ${String(err && err.message || err)}` };
|
|
484
414
|
}
|
|
485
415
|
}
|
|
486
|
-
|
|
487
|
-
// ------------------------------------------------------------------
|
|
488
|
-
// 下载票据:POST 换取一次性短时效 URL,/dl 流式路由凭票发送。
|
|
489
|
-
// 票据不可伪造 → GET 通道无需鉴权头也能保持与 POST 同级的门禁强度。
|
|
490
|
-
// ------------------------------------------------------------------
|
|
491
|
-
const downloadTickets = new Map()
|
|
416
|
+
const downloadTickets = /* @__PURE__ */ new Map();
|
|
492
417
|
ctx.effect(
|
|
493
418
|
() => ctx.timer.interval(() => {
|
|
494
|
-
const now = Date.now()
|
|
495
|
-
for (const [k, v] of downloadTickets) if (v.exp < now) downloadTickets.delete(k)
|
|
496
|
-
},
|
|
497
|
-
|
|
498
|
-
)
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
const fallback = name.replace(/[^\x20-\x7E]|["\\;\r\n]/g, '_') || 'download'
|
|
503
|
-
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(name)}`
|
|
419
|
+
const now = Date.now();
|
|
420
|
+
for (const [k, v] of downloadTickets) if (v.exp < now) downloadTickets.delete(k);
|
|
421
|
+
}, 3e4),
|
|
422
|
+
"dsh-soup: download ticket sweep"
|
|
423
|
+
);
|
|
424
|
+
function contentDisposition(name2) {
|
|
425
|
+
const fallback = name2.replace(/[^\x20-\x7E]|["\\;\r\n]/g, "_") || "download";
|
|
426
|
+
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(name2)}`;
|
|
504
427
|
}
|
|
505
|
-
|
|
506
|
-
/** 流式下载路由:凭票单次发送,支持客户端中断即停读。 */
|
|
507
428
|
async function dlHandler(req, res) {
|
|
508
429
|
try {
|
|
509
|
-
const token = new URL(req.url ||
|
|
510
|
-
const entry = downloadTickets.get(token)
|
|
430
|
+
const token = new URL(req.url || "/?t=", "http://local").searchParams.get("t") || "";
|
|
431
|
+
const entry = downloadTickets.get(token);
|
|
511
432
|
if (!entry) {
|
|
512
|
-
writeJson(res, 404, { ok: false, error:
|
|
513
|
-
return
|
|
433
|
+
writeJson(res, 404, { ok: false, error: "\u4E0B\u8F7D\u7968\u636E\u65E0\u6548\u6216\u5DF2\u4F7F\u7528" });
|
|
434
|
+
return;
|
|
514
435
|
}
|
|
515
|
-
downloadTickets.delete(token)
|
|
516
|
-
const st = await stat(entry.path)
|
|
436
|
+
downloadTickets.delete(token);
|
|
437
|
+
const st = await stat(entry.path);
|
|
517
438
|
res.writeHead(200, {
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
stream.
|
|
439
|
+
"content-type": "application/octet-stream",
|
|
440
|
+
"content-length": String(st.size),
|
|
441
|
+
"content-disposition": contentDisposition(entry.name)
|
|
442
|
+
});
|
|
443
|
+
const stream = createReadStream(entry.path);
|
|
444
|
+
stream.on("error", () => res.end());
|
|
445
|
+
stream.pipe(res);
|
|
446
|
+
req.on("close", () => stream.destroy());
|
|
526
447
|
} catch (err) {
|
|
527
|
-
|
|
528
|
-
|
|
448
|
+
try {
|
|
449
|
+
writeJson(res, 500, { ok: false, error: String(err && err.message || err) });
|
|
450
|
+
} catch {
|
|
451
|
+
}
|
|
529
452
|
}
|
|
530
453
|
}
|
|
531
|
-
|
|
532
|
-
() => ctx.webServer.register({ kind: 'exact', path: '/api/dsh-soup/dl', handler: dlHandler }),
|
|
533
|
-
'dsh-soup: streaming download route',
|
|
534
|
-
)
|
|
535
|
-
|
|
536
|
-
/**
|
|
537
|
-
* 图片内联端点:GET /api/dsh-soup/img?p=<路径>。
|
|
538
|
-
* 浏览器端 Markdown 预览会把「相对路径图片」改写为这里的绝对 URL,
|
|
539
|
-
* 由 <img> 内联加载真实图片字节(JupyterLab md 图片同思路)。只读、无
|
|
540
|
-
* Content-Disposition(内联显示而非下载)。
|
|
541
|
-
* 安全模型:confine 围栏(只读工作区/会话目录)+ 仅图片类型 + 大小上限 +
|
|
542
|
-
* 不返回任何 CORS 头 + 拒绝跨站(Sec-Fetch-Site: cross-site 与不同源 Origin)。
|
|
543
|
-
* 注意 <img> 只能确认「是否 200/404」,无法读取像素,跨站利用面很小。
|
|
544
|
-
*/
|
|
545
|
-
async function imageHandler(req, res) {
|
|
454
|
+
async function handleImgRoute(req, res) {
|
|
546
455
|
try {
|
|
547
|
-
const
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
456
|
+
const url = new URL(req.url || "/?p=", "http://local");
|
|
457
|
+
const p = url.searchParams.get("p") || "";
|
|
458
|
+
if (!p) {
|
|
459
|
+
writeJson(res, 400, { ok: false, error: "\u7F3A\u5C11\u8DEF\u5F84" });
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const rp = await confine(p, "\u56FE\u7247\u8DEF\u5F84");
|
|
463
|
+
const st = await stat(rp);
|
|
464
|
+
if (st.isDirectory()) {
|
|
465
|
+
writeJson(res, 400, { ok: false, error: "\u76EE\u6807\u662F\u76EE\u5F55" });
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
const ext = extOf(rp);
|
|
469
|
+
const mime = IMAGE_MIME[ext];
|
|
470
|
+
if (!mime) {
|
|
471
|
+
writeJson(res, 415, { ok: false, error: "\u4E0D\u652F\u6301\u7684\u56FE\u7247\u7C7B\u578B" });
|
|
472
|
+
return;
|
|
561
473
|
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
const
|
|
567
|
-
if (!mime) { writeJson(res, 415, { ok: false, error: '不是可预览的图片类型' }); return }
|
|
568
|
-
let st
|
|
569
|
-
try { st = await stat(rp) } catch { writeJson(res, 404, { ok: false, error: '图片不可读' }); return }
|
|
570
|
-
if (st.isDirectory() || st.size > IMAGE_MAX_BYTES) {
|
|
571
|
-
writeJson(res, 413, { ok: false, error: '图片过大或目标为目录' }); return
|
|
572
|
-
}
|
|
573
|
-
const fh = await openFile(rp, 'r')
|
|
574
|
-
let buf
|
|
474
|
+
if (st.size > IMAGE_MAX_BYTES) {
|
|
475
|
+
writeJson(res, 413, { ok: false, error: "\u56FE\u7247\u8FC7\u5927" });
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const fh = await openFile(rp, "r");
|
|
575
479
|
try {
|
|
576
|
-
buf = Buffer.alloc(st.size)
|
|
577
|
-
await fh.read(buf, 0, st.size, 0)
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
})
|
|
584
|
-
res.end(buf)
|
|
480
|
+
const buf = Buffer.alloc(st.size);
|
|
481
|
+
await fh.read(buf, 0, st.size, 0);
|
|
482
|
+
res.writeHead(200, { "content-type": mime, "cache-control": "private, max-age=3" });
|
|
483
|
+
res.end(buf);
|
|
484
|
+
} finally {
|
|
485
|
+
await fh.close();
|
|
486
|
+
}
|
|
585
487
|
} catch (err) {
|
|
586
|
-
|
|
587
|
-
|
|
488
|
+
try {
|
|
489
|
+
writeJson(res, err && err.statusCode || 500, { ok: false, error: String(err && err.message || err) });
|
|
490
|
+
} catch {
|
|
491
|
+
}
|
|
588
492
|
}
|
|
589
493
|
}
|
|
590
|
-
ctx.effect(
|
|
591
|
-
() => ctx.webServer.register({ kind: 'exact', path: '/api/dsh-soup/img', handler: imageHandler }),
|
|
592
|
-
'dsh-soup: inline image route',
|
|
593
|
-
)
|
|
594
|
-
|
|
595
|
-
/** 统一路由 dispatch:每个 action 返回可 JSON 序列化的结果。 */
|
|
596
494
|
async function handleAction(body) {
|
|
597
|
-
const action = body
|
|
598
|
-
const args =
|
|
599
|
-
const sessionIdArg = args
|
|
495
|
+
const action = String(body.action || "");
|
|
496
|
+
const args = body.args || {};
|
|
497
|
+
const sessionIdArg = args.sessionId || void 0;
|
|
600
498
|
switch (action) {
|
|
601
|
-
case
|
|
602
|
-
return { ok: true, root }
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
const
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
let target
|
|
613
|
-
let entries
|
|
499
|
+
case "root":
|
|
500
|
+
return { ok: true, root };
|
|
501
|
+
case "sessionCwd": {
|
|
502
|
+
const session = typeof sessions.get === "function" ? sessions.get(args.sessionId) : void 0;
|
|
503
|
+
const cwd = session && session.header && session.header.cwd;
|
|
504
|
+
return cwd ? { ok: true, cwd } : { ok: false };
|
|
505
|
+
}
|
|
506
|
+
case "list": {
|
|
507
|
+
const rp = await confine(String(args.path || "") || root, "\u76EE\u5F55", sessionIdArg);
|
|
508
|
+
let target;
|
|
509
|
+
let entries;
|
|
614
510
|
try {
|
|
615
|
-
target = await fs.resolve(rp, { cwd: root })
|
|
616
|
-
entries = await fs.listDir(target)
|
|
511
|
+
target = await fs.resolve(rp, { cwd: root });
|
|
512
|
+
entries = await fs.listDir(target);
|
|
617
513
|
} catch (err) {
|
|
618
|
-
return { ok: false, error:
|
|
514
|
+
return { ok: false, error: `\u65E0\u6CD5\u8BFB\u53D6\u76EE\u5F55: ${String(err && err.message || err)}` };
|
|
619
515
|
}
|
|
620
|
-
const basePath =
|
|
516
|
+
const basePath = target && target.displayPath || rp;
|
|
621
517
|
const out = entries.map((entry) => {
|
|
622
518
|
const item = {
|
|
623
519
|
name: entry.name,
|
|
624
520
|
type: entry.type,
|
|
625
|
-
path: entry.target && entry.target.displayPath ? entry.target.displayPath : `${basePath}/${entry.name}
|
|
626
|
-
}
|
|
627
|
-
if (typeof entry.size ===
|
|
628
|
-
return item
|
|
629
|
-
})
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
return
|
|
636
|
-
}
|
|
637
|
-
case
|
|
638
|
-
const rp = await confine(args.path,
|
|
639
|
-
return
|
|
640
|
-
}
|
|
641
|
-
case
|
|
642
|
-
const
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
const
|
|
647
|
-
|
|
521
|
+
path: entry.target && entry.target.displayPath ? entry.target.displayPath : `${basePath}/${entry.name}`
|
|
522
|
+
};
|
|
523
|
+
if (typeof entry.size === "number" && entry.size >= 0) item.size = entry.size;
|
|
524
|
+
return item;
|
|
525
|
+
});
|
|
526
|
+
out.sort((a, b) => {
|
|
527
|
+
const dirDiff = (a.type === "directory" ? 0 : 1) - (b.type === "directory" ? 0 : 1);
|
|
528
|
+
if (dirDiff !== 0) return dirDiff;
|
|
529
|
+
return a.name.localeCompare(b.name, void 0, { numeric: true, sensitivity: "base" });
|
|
530
|
+
});
|
|
531
|
+
return { ok: true, root, path: basePath, entries: out };
|
|
532
|
+
}
|
|
533
|
+
case "open": {
|
|
534
|
+
const rp = await confine(args.path, "\u6253\u5F00\u8DEF\u5F84", sessionIdArg);
|
|
535
|
+
return openPath(rp);
|
|
536
|
+
}
|
|
537
|
+
case "trash": {
|
|
538
|
+
const rp = await confine(args.path, "\u5E9F\u7EB8\u7BD3\u8DEF\u5F84", sessionIdArg);
|
|
539
|
+
return trashPath(rp);
|
|
540
|
+
}
|
|
541
|
+
case "move": {
|
|
542
|
+
const fromRp = await confine(args.from, "\u6E90\u8DEF\u5F84", sessionIdArg);
|
|
543
|
+
const toRp = await confine(args.to, "\u76EE\u6807\u8DEF\u5F84", sessionIdArg);
|
|
648
544
|
try {
|
|
649
|
-
await
|
|
650
|
-
|
|
545
|
+
await mkdir(path2.dirname(toRp), { recursive: true });
|
|
546
|
+
await rename(fromRp, toRp);
|
|
547
|
+
return { ok: true, path: toRp };
|
|
651
548
|
} catch (err) {
|
|
652
|
-
return { ok: false, error:
|
|
549
|
+
return { ok: false, error: `\u79FB\u52A8\u5931\u8D25: ${String(err && err.message || err)}` };
|
|
653
550
|
}
|
|
654
551
|
}
|
|
655
|
-
case
|
|
656
|
-
const
|
|
657
|
-
const newName = String((args && args.name) || '')
|
|
658
|
-
const isDir = Boolean(args && args.isDir)
|
|
659
|
-
if (!dir || !newName || newName === '.' || newName === '..' || newName.includes('/') || newName.includes('\\') || newName.includes('\0')) {
|
|
660
|
-
return { ok: false, error: '无效文件名' }
|
|
661
|
-
}
|
|
662
|
-
const dirRp = await confine(dir, '目录', sessionIdArg)
|
|
663
|
-
// 二次围栏:name 若与既有符号链接同名,lenient realpath 会解析出
|
|
664
|
-
// 链接真实指向,越界即拒绝。
|
|
665
|
-
const target = await confine(joinPath(dirRp, newName), '创建路径', sessionIdArg)
|
|
552
|
+
case "create": {
|
|
553
|
+
const rp = await confine(args.path, "\u521B\u5EFA\u8DEF\u5F84", sessionIdArg);
|
|
666
554
|
try {
|
|
667
|
-
if (isDir) {
|
|
668
|
-
await mkdir(
|
|
669
|
-
|
|
670
|
-
await writeFile(target, '')
|
|
555
|
+
if (args.isDir) {
|
|
556
|
+
await mkdir(rp, { recursive: true });
|
|
557
|
+
return { ok: true, path: rp };
|
|
671
558
|
}
|
|
672
|
-
|
|
559
|
+
await mkdir(path2.dirname(rp), { recursive: true });
|
|
560
|
+
await writeFile(rp, "", { flag: "wx" });
|
|
561
|
+
return { ok: true, path: rp };
|
|
673
562
|
} catch (err) {
|
|
674
|
-
return { ok: false, error:
|
|
563
|
+
if (err && err.code === "EEXIST") return { ok: false, error: "\u6587\u4EF6\u5DF2\u5B58\u5728" };
|
|
564
|
+
return { ok: false, error: `\u521B\u5EFA\u5931\u8D25: ${String(err && err.message || err)}` };
|
|
675
565
|
}
|
|
676
566
|
}
|
|
677
|
-
case
|
|
678
|
-
const
|
|
679
|
-
const
|
|
680
|
-
|
|
681
|
-
// 文件名与 create 同规:禁路径分隔符与相对段,杜绝 ../ 穿越。
|
|
682
|
-
if (!dir || !fileName || fileName === '.' || fileName === '..' || fileName.includes('/') || fileName.includes('\\') || fileName.includes('\0')) {
|
|
683
|
-
return { ok: false, error: '无效文件名' }
|
|
684
|
-
}
|
|
685
|
-
const dirRp = await confine(dir, '目录', sessionIdArg)
|
|
686
|
-
const target = await confine(joinPath(dirRp, fileName), '上传路径', sessionIdArg)
|
|
687
|
-
// 分块上传(JupyterLab 同款语义):chunk=1/缺省覆盖写,chunk>=2 追加。
|
|
688
|
-
// 无需服务端状态——按块序到达即顺序落盘。
|
|
689
|
-
const chunk = Number((args && args.chunk) || 0)
|
|
690
|
-
if (chunk < 0 || !Number.isInteger(chunk)) return { ok: false, error: '无效的 chunk 序号' }
|
|
567
|
+
case "upload": {
|
|
568
|
+
const rp = await confine(args.path, "\u4E0A\u4F20\u8DEF\u5F84", sessionIdArg);
|
|
569
|
+
const chunk = typeof args.chunk === "number" ? args.chunk : 1;
|
|
570
|
+
if (chunk < 0 || !Number.isInteger(chunk)) return { ok: false, error: "\u65E0\u6548\u7684 chunk \u5E8F\u53F7" };
|
|
691
571
|
try {
|
|
692
|
-
const bytes = Buffer.from(data.replace(/\s+/g,
|
|
693
|
-
if (chunk >= 2) await appendFile(
|
|
694
|
-
else await writeFile(
|
|
695
|
-
return { ok: true, path:
|
|
572
|
+
const bytes = Buffer.from(String(args.data || "").replace(/\s+/g, ""), "base64");
|
|
573
|
+
if (chunk >= 2) await appendFile(rp, bytes);
|
|
574
|
+
else await writeFile(rp, bytes);
|
|
575
|
+
return { ok: true, path: rp };
|
|
696
576
|
} catch (err) {
|
|
697
|
-
return { ok: false, error:
|
|
577
|
+
return { ok: false, error: `\u5199\u5165\u5931\u8D25: ${String(err && err.message || err)}` };
|
|
698
578
|
}
|
|
699
579
|
}
|
|
700
|
-
case
|
|
701
|
-
const rp = await confine(args.path,
|
|
702
|
-
return readFileForView(rp)
|
|
580
|
+
case "read": {
|
|
581
|
+
const rp = await confine(args.path, "\u6587\u4EF6\u8DEF\u5F84", sessionIdArg);
|
|
582
|
+
return readFileForView(rp);
|
|
703
583
|
}
|
|
704
|
-
case
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
const rp = await confine(args.path, '下载路径', sessionIdArg)
|
|
708
|
-
let dst
|
|
584
|
+
case "download": {
|
|
585
|
+
const rp = await confine(args.path, "\u4E0B\u8F7D\u8DEF\u5F84", sessionIdArg);
|
|
586
|
+
let dst;
|
|
709
587
|
try {
|
|
710
|
-
dst = await stat(rp)
|
|
588
|
+
dst = await stat(rp);
|
|
711
589
|
} catch (err) {
|
|
712
|
-
return { ok: false, error:
|
|
590
|
+
return { ok: false, error: "\u65E0\u6CD5\u8BFB\u53D6\u6587\u4EF6" };
|
|
713
591
|
}
|
|
714
|
-
if (dst.isDirectory()) return { ok: false, error:
|
|
715
|
-
const token = randomBytes(24).toString(
|
|
716
|
-
downloadTickets.set(token, { path: rp, name:
|
|
717
|
-
return { ok: true, name:
|
|
718
|
-
}
|
|
719
|
-
case
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
// 上限 64 个路径;越界/不可达的路径返回 null(客户端视为消失)。
|
|
723
|
-
const paths = Array.isArray(args && args.paths) ? args.paths.slice(0, 64) : []
|
|
724
|
-
const mtimes = {}
|
|
592
|
+
if (dst.isDirectory()) return { ok: false, error: "\u76EE\u6807\u662F\u76EE\u5F55\uFF0C\u4E0D\u652F\u6301\u4E0B\u8F7D" };
|
|
593
|
+
const token = randomBytes(24).toString("hex");
|
|
594
|
+
downloadTickets.set(token, { path: rp, name: path2.basename(rp), exp: Date.now() + 12e4 });
|
|
595
|
+
return { ok: true, name: path2.basename(rp), size: dst.size, url: `/api/dsh-soup/dl?t=${token}` };
|
|
596
|
+
}
|
|
597
|
+
case "mtime": {
|
|
598
|
+
const paths = Array.isArray(args && args.paths) ? args.paths.slice(0, 64) : [];
|
|
599
|
+
const mtimes = {};
|
|
725
600
|
for (const raw of paths) {
|
|
726
|
-
const p = String(raw ||
|
|
727
|
-
if (!p) continue
|
|
601
|
+
const p = String(raw || "");
|
|
602
|
+
if (!p) continue;
|
|
728
603
|
try {
|
|
729
|
-
const rp = await confine(p,
|
|
730
|
-
const st = await stat(rp)
|
|
731
|
-
mtimes[p] =
|
|
604
|
+
const rp = await confine(p, "\u63A2\u6D4B\u8DEF\u5F84", sessionIdArg);
|
|
605
|
+
const st = await stat(rp);
|
|
606
|
+
mtimes[p] = { m: st.mtimeMs, s: st.size, d: st.isDirectory() };
|
|
732
607
|
} catch {
|
|
733
|
-
mtimes[p] = null
|
|
608
|
+
mtimes[p] = null;
|
|
734
609
|
}
|
|
735
610
|
}
|
|
736
|
-
return { ok: true, mtimes }
|
|
611
|
+
return { ok: true, mtimes };
|
|
737
612
|
}
|
|
738
|
-
case
|
|
739
|
-
|
|
740
|
-
return { ok: true, ...speedStatus(sid) }
|
|
613
|
+
case "speed-status": {
|
|
614
|
+
return { ok: true, ...deps.speedStatus(args.sessionId) };
|
|
741
615
|
}
|
|
742
616
|
default:
|
|
743
|
-
return { ok: false, error:
|
|
617
|
+
return { ok: false, error: `\u672A\u77E5\u64CD\u4F5C: ${action}` };
|
|
744
618
|
}
|
|
745
619
|
}
|
|
746
|
-
|
|
747
|
-
/**
|
|
748
|
-
* 请求门:同源校验 + 强制自定义头。
|
|
749
|
-
* - 仅接受 POST;OPTIONS 直接 403(让浏览器 preflight 失败),其余 405;
|
|
750
|
-
* - 带 Origin 时必须与 Host 同源——挡跨站 CSRF 与 DNS rebinding
|
|
751
|
-
* (rebinding 页面的 Origin 是攻击者域名,与 Host 必不相同);
|
|
752
|
-
* - 必须携带 x-dsh-soup: 1 且 content-type 为 application/json:
|
|
753
|
-
* 自定义头会强制浏览器先走 preflight,而本路由永不回 CORS 头,
|
|
754
|
-
* 因此恶意网页无法用 text/plain「简单请求」盲打写操作。
|
|
755
|
-
* 非浏览器本地工具(无 Origin 头)不受影响。
|
|
756
|
-
*/
|
|
757
620
|
function gate(req, res) {
|
|
758
|
-
const
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
const origin = headers.origin
|
|
770
|
-
if (typeof origin === 'string' && origin !== '') {
|
|
771
|
-
let originHost = ''
|
|
772
|
-
try { originHost = new URL(origin).host } catch { originHost = '' }
|
|
773
|
-
const host = typeof headers.host === 'string' ? headers.host : ''
|
|
774
|
-
if (!originHost || originHost !== host) {
|
|
775
|
-
writeJson(res, 403, { ok: false, error: '跨源请求已拒绝' })
|
|
776
|
-
return true
|
|
621
|
+
const headers = req.headers || {};
|
|
622
|
+
const origin = String(headers.origin || "");
|
|
623
|
+
const host = String(headers.host || "");
|
|
624
|
+
if (origin) {
|
|
625
|
+
try {
|
|
626
|
+
const originHost = new URL(origin).host;
|
|
627
|
+
if (!originHost || originHost !== host) {
|
|
628
|
+
writeJson(res, 403, { ok: false, error: "\u8DE8\u6E90\u8BF7\u6C42\u5DF2\u62D2\u7EDD" });
|
|
629
|
+
return true;
|
|
630
|
+
}
|
|
631
|
+
} catch {
|
|
777
632
|
}
|
|
778
633
|
}
|
|
779
|
-
const contentType = String(headers[
|
|
780
|
-
if (headers[
|
|
781
|
-
writeJson(res, 403, { ok: false, error:
|
|
782
|
-
return true
|
|
634
|
+
const contentType = String(headers["content-type"] || "").toLowerCase();
|
|
635
|
+
if (headers["x-dsh-soup"] !== "1" || !contentType.startsWith("application/json")) {
|
|
636
|
+
writeJson(res, 403, { ok: false, error: "\u7F3A\u5C11\u5FC5\u8981\u7684\u8BF7\u6C42\u5934" });
|
|
637
|
+
return true;
|
|
783
638
|
}
|
|
784
|
-
return false
|
|
639
|
+
return false;
|
|
785
640
|
}
|
|
786
|
-
|
|
787
|
-
/** HTTP 处理器:请求门 → 解析 body → dispatch → JSON 响应。 */
|
|
788
641
|
async function handler(req, res) {
|
|
789
642
|
try {
|
|
790
|
-
if (gate(req, res)) return
|
|
791
|
-
const body = await readBody(req)
|
|
792
|
-
const result = await handleAction(body)
|
|
793
|
-
writeJson(res, 200, result)
|
|
643
|
+
if (gate(req, res)) return;
|
|
644
|
+
const body = await readBody(req);
|
|
645
|
+
const result = await handleAction(body);
|
|
646
|
+
writeJson(res, 200, result);
|
|
794
647
|
} catch (err) {
|
|
795
|
-
writeJson(res,
|
|
648
|
+
writeJson(res, err && err.statusCode || 500, { ok: false, error: String(err && err.message || err) });
|
|
796
649
|
}
|
|
797
650
|
}
|
|
798
|
-
|
|
799
651
|
ctx.effect(
|
|
800
|
-
() => ctx.webServer.register({ kind:
|
|
801
|
-
|
|
802
|
-
)
|
|
652
|
+
() => ctx.webServer.register({ kind: "exact", path: "/api/dsh-soup", handler }),
|
|
653
|
+
"dsh-soup: file http route"
|
|
654
|
+
);
|
|
655
|
+
ctx.effect(
|
|
656
|
+
() => ctx.webServer.register({ kind: "prefix", path: "/api/dsh-soup/img", handler: handleImgRoute }),
|
|
657
|
+
"dsh-soup: image inline route"
|
|
658
|
+
);
|
|
659
|
+
ctx.effect(
|
|
660
|
+
() => ctx.webServer.register({ kind: "prefix", path: "/api/dsh-soup/dl", handler: dlHandler }),
|
|
661
|
+
"dsh-soup: download route"
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// src/host/index.ts
|
|
666
|
+
var name = "ui-dsh-soup";
|
|
667
|
+
var inject = ["webServer", "fs", "subprocess", "sandboxPolicy", "sessions", "timer"];
|
|
668
|
+
var PLATFORM = process.platform === "win32" ? "win" : process.platform === "darwin" ? "mac" : process.platform === "linux" ? "linux" : "mac";
|
|
669
|
+
function apply(ctx) {
|
|
670
|
+
const root = ctx.sandboxPolicy && ctx.sandboxPolicy.workspaceRoot || "/";
|
|
671
|
+
const { speedStatus } = setupSpeedTracking({ ctx });
|
|
672
|
+
setupFiles({ ctx, root, PLATFORM, speedStatus });
|
|
803
673
|
}
|
|
674
|
+
export {
|
|
675
|
+
apply,
|
|
676
|
+
inject,
|
|
677
|
+
name
|
|
678
|
+
};
|