@skillpie/dsh-remote 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -2
- package/cordis.patch.yml +11 -0
- package/icon.svg +14 -0
- package/lib/bridge.js +377 -0
- package/lib/client.js +493 -0
- package/lib/console.js +434 -0
- package/lib/engine.js +367 -0
- package/lib/file-route.js +159 -0
- package/lib/index.js +496 -0
- package/lib/pipe.js +83 -0
- package/lib/protocol.js +122 -0
- package/lib/relay-core.js +1025 -0
- package/lib/remote.js +181 -0
- package/lib/ws-adapter.js +49 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +50 -4
package/lib/engine.js
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-remote — host-side visitor command engine.
|
|
3
|
+
*
|
|
4
|
+
* One engine per host process. It speaks the control-channel frames over one
|
|
5
|
+
* OR MORE transports at a time (relay WebSocket and the local direct pipe
|
|
6
|
+
* are both common), and drives the in-process sessionController exactly
|
|
7
|
+
* where the web GUI does: list, state, create, prompt (queued), cancel, plus
|
|
8
|
+
* per-session durable event and state streams shared across observing
|
|
9
|
+
* visitors.
|
|
10
|
+
*
|
|
11
|
+
* Routing rule: a reply or addressed push always rides the transport the
|
|
12
|
+
* visitor came in on; session-wide fan-out rides every transport with an
|
|
13
|
+
* observer. The engine never reaches the model or the filesystem directly;
|
|
14
|
+
* every capability is a sessionController call, so a visitor can do remotely
|
|
15
|
+
* what the owner could do in the GUI — and nothing else.
|
|
16
|
+
*/
|
|
17
|
+
import { generateId, VISITOR_COMMANDS, INTERNAL_COMMANDS, PROTOCOL_VERSION } from './protocol.js'
|
|
18
|
+
|
|
19
|
+
const HEARTBEAT_MS = 15_000
|
|
20
|
+
const MAX_TEXT = 32 * 1024
|
|
21
|
+
const LOG_CAP = 200
|
|
22
|
+
const HISTORY_MAX_MESSAGES = 40
|
|
23
|
+
/** sessionController's remote signatures require a signal; cap one-shot reads. */
|
|
24
|
+
const READ_TIMEOUT_MS = 20_000
|
|
25
|
+
|
|
26
|
+
/** A fresh bounded signal for one-shot sessionController reads. */
|
|
27
|
+
const readSignal = () => AbortSignal.timeout(READ_TIMEOUT_MS)
|
|
28
|
+
|
|
29
|
+
export class HostEngine {
|
|
30
|
+
/**
|
|
31
|
+
* @param services lazy service access: { sessionController(): object | undefined }
|
|
32
|
+
* @param host static host facts: { name, dsh, code } (plain values)
|
|
33
|
+
*/
|
|
34
|
+
constructor({ services, host, log = () => {} }) {
|
|
35
|
+
this.services = services
|
|
36
|
+
this.host = host
|
|
37
|
+
this.log = log
|
|
38
|
+
/** channel records: { channel, heartbeat } */
|
|
39
|
+
this.channels = new Map()
|
|
40
|
+
/** visitorId → { connectedAt, sessions:Set, channel } */
|
|
41
|
+
this.visitors = new Map()
|
|
42
|
+
/** sessionId → { refs, controller, cleanups[] } */
|
|
43
|
+
this.streams = new Map()
|
|
44
|
+
this.allowControl = true
|
|
45
|
+
this.startedAt = Date.now()
|
|
46
|
+
this.broadcastTimer = undefined
|
|
47
|
+
this.closed = false
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Wire the engine to a transport. The transport speaks hello itself. */
|
|
51
|
+
attach(channel) {
|
|
52
|
+
const record = { channel, heartbeat: undefined }
|
|
53
|
+
this.channels.set(channel, record)
|
|
54
|
+
record.offMessage = channel.onMessage((text) => this.onFrame(text, channel))
|
|
55
|
+
record.offClose = channel.onClose(() => this.detach(channel))
|
|
56
|
+
record.heartbeat = setInterval(() => {
|
|
57
|
+
if (this.channels.has(channel)) channel.send({ t: 'hb' })
|
|
58
|
+
}, HEARTBEAT_MS)
|
|
59
|
+
this.log('engine', 'channel attached')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Remove one transport; its visitors go with it. */
|
|
63
|
+
detach(channel) {
|
|
64
|
+
const record = this.channels.get(channel)
|
|
65
|
+
if (!record) return
|
|
66
|
+
this.channels.delete(channel)
|
|
67
|
+
record.offMessage?.()
|
|
68
|
+
record.offClose?.()
|
|
69
|
+
if (record.heartbeat) clearInterval(record.heartbeat)
|
|
70
|
+
for (const [visitorId, visitor] of [...this.visitors]) {
|
|
71
|
+
if (visitor.channel === channel) {
|
|
72
|
+
for (const sessionId of visitor.sessions) this.unobserve(visitorId, sessionId)
|
|
73
|
+
this.visitors.delete(visitorId)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Full teardown for plugin unload. */
|
|
79
|
+
close() {
|
|
80
|
+
if (this.closed) return
|
|
81
|
+
this.closed = true
|
|
82
|
+
for (const channel of [...this.channels.keys()]) this.detach(channel)
|
|
83
|
+
if (this.broadcastTimer) clearTimeout(this.broadcastTimer)
|
|
84
|
+
this.broadcastTimer = undefined
|
|
85
|
+
for (const stream of this.streams.values()) stream.controller.abort()
|
|
86
|
+
this.streams.clear()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
onFrame(text, channel) {
|
|
90
|
+
if (this.closed) return
|
|
91
|
+
const frame = (() => {
|
|
92
|
+
try { return JSON.parse(text) } catch { return null }
|
|
93
|
+
})()
|
|
94
|
+
if (!frame || typeof frame !== 'object') return
|
|
95
|
+
if (frame.t === 'hb') return
|
|
96
|
+
if (frame.t !== 'cmd') return
|
|
97
|
+
const { id, from, cmd } = frame
|
|
98
|
+
if (typeof id !== 'string' || typeof from !== 'string' || typeof cmd !== 'string') return
|
|
99
|
+
const known = VISITOR_COMMANDS.has(cmd) || INTERNAL_COMMANDS.has(cmd)
|
|
100
|
+
if (!known) return this.reply(id, false, undefined, `unknown command: ${cmd}`, channel)
|
|
101
|
+
Promise.resolve()
|
|
102
|
+
.then(() => this.dispatch(cmd, frame.p, from, channel))
|
|
103
|
+
.then((data) => this.reply(id, true, data, undefined, channel))
|
|
104
|
+
.catch((error) => {
|
|
105
|
+
this.log('cmd-error', `${cmd}: ${error?.message ?? String(error)}`)
|
|
106
|
+
this.reply(id, false, undefined, error?.message ?? String(error), channel)
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
reply(id, ok, data, error, channel) {
|
|
111
|
+
channel.send({ t: 'res', id, ok, ...(ok ? { data } : { error }) })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
controller() {
|
|
115
|
+
return this.services.sessionController?.()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
requireControl() {
|
|
119
|
+
if (!this.allowControl) throw new Error('远程控制已关闭(只读模式)')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async dispatch(cmd, p = {}, from, channel) {
|
|
123
|
+
const controller = this.controller()
|
|
124
|
+
if (!controller) throw new Error('sessionController 服务不可用')
|
|
125
|
+
switch (cmd) {
|
|
126
|
+
case 'visitor.hello': {
|
|
127
|
+
const previous = this.visitors.get(from)
|
|
128
|
+
if (previous) {
|
|
129
|
+
for (const sessionId of previous.sessions) this.unobserve(from, sessionId)
|
|
130
|
+
}
|
|
131
|
+
this.visitors.set(from, { id: from, connectedAt: Date.now(), sessions: new Set(), channel })
|
|
132
|
+
this.log('visitor', `访客接入 ${shortId(from)}`)
|
|
133
|
+
return {
|
|
134
|
+
host: {
|
|
135
|
+
name: this.host.name,
|
|
136
|
+
dsh: this.host.dsh,
|
|
137
|
+
code: this.host.code,
|
|
138
|
+
allowControl: this.allowControl,
|
|
139
|
+
},
|
|
140
|
+
sessions: await this.listSessions(),
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
case 'visitor.gone': {
|
|
144
|
+
const record = this.visitors.get(from)
|
|
145
|
+
if (record) {
|
|
146
|
+
for (const sessionId of record.sessions) this.unobserve(from, sessionId)
|
|
147
|
+
this.visitors.delete(from)
|
|
148
|
+
}
|
|
149
|
+
this.log('visitor', `访客断开 ${shortId(from)}`)
|
|
150
|
+
return { ok: true }
|
|
151
|
+
}
|
|
152
|
+
case 'host.info': {
|
|
153
|
+
return { name: this.host.name, dsh: this.host.dsh, allowControl: this.allowControl }
|
|
154
|
+
}
|
|
155
|
+
case 'sessions.list': {
|
|
156
|
+
return { sessions: await this.listSessions() }
|
|
157
|
+
}
|
|
158
|
+
case 'sessions.state': {
|
|
159
|
+
const state = await controller.state({ sessionId: p.sessionId }, readSignal())
|
|
160
|
+
return { state: projectState(state) }
|
|
161
|
+
}
|
|
162
|
+
case 'sessions.create': {
|
|
163
|
+
this.requireControl()
|
|
164
|
+
const created = await controller.create({})
|
|
165
|
+
this.log('cmd', `访客新建会话 ${shortId(created.sessionId)}`)
|
|
166
|
+
this.scheduleSessionsBroadcast()
|
|
167
|
+
return { sessionId: created.sessionId, agentPreset: created.agentPreset }
|
|
168
|
+
}
|
|
169
|
+
case 'sessions.send': {
|
|
170
|
+
this.requireControl()
|
|
171
|
+
const text = typeof p.text === 'string' ? p.text.trim() : ''
|
|
172
|
+
if (!text) throw new Error('消息内容为空')
|
|
173
|
+
if (text.length > MAX_TEXT) throw new Error('消息过长')
|
|
174
|
+
const sessionId = p.sessionId
|
|
175
|
+
await controller.prompt({
|
|
176
|
+
requestId: generateId(),
|
|
177
|
+
sessionId,
|
|
178
|
+
mode: 'queue',
|
|
179
|
+
content: [{ type: 'text', text }],
|
|
180
|
+
clientTimeZone: 'Asia/Shanghai',
|
|
181
|
+
}, readSignal())
|
|
182
|
+
this.log('cmd', `访客向会话 ${shortId(sessionId)} 发送指令 (${text.length} 字)`)
|
|
183
|
+
return { accepted: true }
|
|
184
|
+
}
|
|
185
|
+
case 'sessions.cancel': {
|
|
186
|
+
this.requireControl()
|
|
187
|
+
await controller.cancel({ sessionId: p.sessionId })
|
|
188
|
+
this.log('cmd', `访客中断会话 ${shortId(p.sessionId)}`)
|
|
189
|
+
return { accepted: true }
|
|
190
|
+
}
|
|
191
|
+
case 'sessions.observe': {
|
|
192
|
+
const sessionId = p.sessionId
|
|
193
|
+
const visitor = this.visitors.get(from)
|
|
194
|
+
if (!visitor) throw new Error('访客会话不存在(请重连)')
|
|
195
|
+
for (const previous of visitor.sessions) {
|
|
196
|
+
if (previous !== sessionId) this.unobserve(from, previous)
|
|
197
|
+
}
|
|
198
|
+
visitor.sessions.add(sessionId)
|
|
199
|
+
const stream = this.streamFor(sessionId)
|
|
200
|
+
const [state, history] = await Promise.all([
|
|
201
|
+
controller.state({ sessionId }, readSignal()),
|
|
202
|
+
controller.events({
|
|
203
|
+
address: { kind: 'session', sessionId },
|
|
204
|
+
maxMessages: HISTORY_MAX_MESSAGES,
|
|
205
|
+
turnWindow: { minMessages: 10, minTurns: 2 },
|
|
206
|
+
}, readSignal()),
|
|
207
|
+
])
|
|
208
|
+
this.pushTo(from, {
|
|
209
|
+
t: 'session-history',
|
|
210
|
+
sessionId,
|
|
211
|
+
records: history.records,
|
|
212
|
+
hasMore: history.hasMore,
|
|
213
|
+
state: projectState(state),
|
|
214
|
+
})
|
|
215
|
+
this.log('cmd', `访客查看会话 ${shortId(sessionId)}`)
|
|
216
|
+
return { cursor: state.cursor, state: projectState(state) }
|
|
217
|
+
}
|
|
218
|
+
case 'sessions.unobserve': {
|
|
219
|
+
this.unobserve(from, p.sessionId)
|
|
220
|
+
return { ok: true }
|
|
221
|
+
}
|
|
222
|
+
default:
|
|
223
|
+
throw new Error(`unknown command: ${cmd}`)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async listSessions() {
|
|
228
|
+
const controller = this.controller()
|
|
229
|
+
if (!controller) return []
|
|
230
|
+
const { items } = await controller.list({}, readSignal())
|
|
231
|
+
return items
|
|
232
|
+
.filter((item) => !item.blank && !item.origin)
|
|
233
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
234
|
+
.slice(0, 50)
|
|
235
|
+
.map((item) => projectSummary(item))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
streamFor(sessionId) {
|
|
239
|
+
let stream = this.streams.get(sessionId)
|
|
240
|
+
if (stream) {
|
|
241
|
+
stream.refs += 1
|
|
242
|
+
return stream
|
|
243
|
+
}
|
|
244
|
+
const controller = this.controller()
|
|
245
|
+
if (!controller) throw new Error('sessionController 服务不可用')
|
|
246
|
+
const ctl = new AbortController()
|
|
247
|
+
const cleanups = []
|
|
248
|
+
stream = { refs: 1, controller: ctl, cleanups }
|
|
249
|
+
this.streams.set(sessionId, stream)
|
|
250
|
+
// Durable event feed: 'ready' then one entry per committed event.
|
|
251
|
+
void this.pump(
|
|
252
|
+
ctl,
|
|
253
|
+
controller.observeEvent({ address: { kind: 'session', sessionId } }, ctl.signal),
|
|
254
|
+
(event) => this.pushSession(sessionId, { t: 'session-event', sessionId, event }),
|
|
255
|
+
)
|
|
256
|
+
// Passive state feed: running flag, queue, pending approvals.
|
|
257
|
+
void this.pump(
|
|
258
|
+
ctl,
|
|
259
|
+
controller.observeState({ sessionId }, ctl.signal),
|
|
260
|
+
(state) => this.pushSession(sessionId, { t: 'session-state', sessionId, state: projectState(state) }),
|
|
261
|
+
)
|
|
262
|
+
cleanups.push(() => this.streams.delete(sessionId))
|
|
263
|
+
return stream
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async pump(ctl, iterator, onFrame) {
|
|
267
|
+
try {
|
|
268
|
+
for await (const frame of iterator) {
|
|
269
|
+
if (ctl.signal.aborted) return
|
|
270
|
+
if (frame && frame.type !== 'ready') onFrame(frame)
|
|
271
|
+
}
|
|
272
|
+
} catch (error) {
|
|
273
|
+
if (!ctl.signal.aborted) this.log('stream', `stream error: ${error?.message ?? String(error)}`)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
unobserve(visitorId, sessionId) {
|
|
278
|
+
const visitor = this.visitors.get(visitorId)
|
|
279
|
+
visitor?.sessions.delete(sessionId)
|
|
280
|
+
const stream = this.streams.get(sessionId)
|
|
281
|
+
if (!stream) return
|
|
282
|
+
stream.refs -= 1
|
|
283
|
+
if (stream.refs <= 0) {
|
|
284
|
+
stream.controller.abort()
|
|
285
|
+
for (const cleanup of stream.cleanups) cleanup()
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
pushSession(sessionId, frame) {
|
|
290
|
+
for (const visitor of this.visitors.values()) {
|
|
291
|
+
if (visitor.sessions.has(sessionId)) visitor.channel.send({ t: 'push', to: visitor.id, f: frame })
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
pushTo(visitorId, frame) {
|
|
296
|
+
const visitor = this.visitors.get(visitorId)
|
|
297
|
+
visitor?.channel.send({ t: 'push', to: visitorId, f: frame })
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Fan a refreshed session list to every visitor, throttled. */
|
|
301
|
+
scheduleSessionsBroadcast() {
|
|
302
|
+
if (this.broadcastTimer) return
|
|
303
|
+
this.broadcastTimer = setTimeout(async () => {
|
|
304
|
+
this.broadcastTimer = undefined
|
|
305
|
+
if (this.visitors.size === 0) return
|
|
306
|
+
try {
|
|
307
|
+
const sessions = await this.listSessions()
|
|
308
|
+
for (const visitor of this.visitors.values()) {
|
|
309
|
+
visitor.channel.send({ t: 'push', to: visitor.id, f: { t: 'sessions-updated', sessions } })
|
|
310
|
+
}
|
|
311
|
+
} catch {
|
|
312
|
+
// listing raced a disposal; the next trigger retries
|
|
313
|
+
}
|
|
314
|
+
}, 400)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Wire session lifecycle events, best-effort; degrades to action-triggered refresh. */
|
|
318
|
+
observeSessionEvents(on) {
|
|
319
|
+
const names = ['api-session/added', 'api-session/removed', 'api-session/status', 'api-session/activity']
|
|
320
|
+
const offs = []
|
|
321
|
+
for (const name of names) {
|
|
322
|
+
try {
|
|
323
|
+
offs.push(on(name, () => this.scheduleSessionsBroadcast()))
|
|
324
|
+
} catch {
|
|
325
|
+
// event bus not offered in this assembly
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return () => { for (const off of offs) off() }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
status() {
|
|
332
|
+
return {
|
|
333
|
+
protocol: PROTOCOL_VERSION,
|
|
334
|
+
startedAt: this.startedAt,
|
|
335
|
+
visitors: [...this.visitors.entries()].map(([id, visitor]) => ({
|
|
336
|
+
id,
|
|
337
|
+
connectedAt: visitor.connectedAt,
|
|
338
|
+
sessions: [...visitor.sessions],
|
|
339
|
+
})),
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function projectSummary(item) {
|
|
345
|
+
return {
|
|
346
|
+
id: item.sessionId,
|
|
347
|
+
title: typeof item.projections?.values?.title === 'string' ? item.projections.values.title : null,
|
|
348
|
+
updatedAt: item.updatedAt,
|
|
349
|
+
running: item.running === true,
|
|
350
|
+
agentAvailable: item.agentAvailable === true,
|
|
351
|
+
cwd: typeof item.cwd === 'string' ? item.cwd : undefined,
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function projectState(state) {
|
|
356
|
+
return {
|
|
357
|
+
cursor: state?.cursor ?? 0,
|
|
358
|
+
active: state?.active === true,
|
|
359
|
+
running: state?.running === true,
|
|
360
|
+
queued: Array.isArray(state?.queue) ? state.queue.length : 0,
|
|
361
|
+
pendingInput: Array.isArray(state?.pendingInput) ? state.pendingInput : [],
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function shortId(id) {
|
|
366
|
+
return typeof id === 'string' && id.length > 10 ? `${id.slice(0, 8)}…` : String(id)
|
|
367
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-remote — host-side visitor file download route.
|
|
3
|
+
*
|
|
4
|
+
* Visitors of the real GUI have no way to save a generated file locally: the
|
|
5
|
+
* built-in file menu only hands paths to the Host desktop (open / reveal),
|
|
6
|
+
* which is invisible to a remote browser. This route reads a file from this
|
|
7
|
+
* host's disk and streams it back with `Content-Disposition: attachment`,
|
|
8
|
+
* so the visitor's browser opens its own save dialog.
|
|
9
|
+
*
|
|
10
|
+
* The route registers on the HOST's own web server under `/dsh-remote-file`;
|
|
11
|
+
* visitor requests reach it through the tunnel (relay prefix proxy or direct
|
|
12
|
+
* pipe) with the bridge-minted owner cookie attached. Everything else is
|
|
13
|
+
* gated by a shared-secret key derived from the host id (`?k=` or the
|
|
14
|
+
* `x-dsh-remote-key` header) so loopback strangers cannot read arbitrary
|
|
15
|
+
* files. The same derivation runs on the relay to stamp the key into the
|
|
16
|
+
* visitor shim, so no protocol change carries it.
|
|
17
|
+
*/
|
|
18
|
+
import { createReadStream } from 'node:fs'
|
|
19
|
+
import { stat } from 'node:fs/promises'
|
|
20
|
+
import { createHash } from 'node:crypto'
|
|
21
|
+
|
|
22
|
+
/** Largest file one download may serve. */
|
|
23
|
+
const MAX_DOWNLOAD_BYTES = 512 * 1024 * 1024
|
|
24
|
+
|
|
25
|
+
const MIME_BY_EXT = new Map([
|
|
26
|
+
['.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
|
|
27
|
+
['.xlsm', 'application/vnd.ms-excel.sheet.macroEnabled.12'],
|
|
28
|
+
['.xls', 'application/vnd.ms-excel'],
|
|
29
|
+
['.csv', 'text/csv; charset=utf-8'],
|
|
30
|
+
['.tsv', 'text/tab-separated-values; charset=utf-8'],
|
|
31
|
+
['.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
|
32
|
+
['.doc', 'application/msword'],
|
|
33
|
+
['.pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'],
|
|
34
|
+
['.ppt', 'application/vnd.ms-powerpoint'],
|
|
35
|
+
['.pdf', 'application/pdf'],
|
|
36
|
+
['.json', 'application/json; charset=utf-8'],
|
|
37
|
+
['.txt', 'text/plain; charset=utf-8'],
|
|
38
|
+
['.md', 'text/markdown; charset=utf-8'],
|
|
39
|
+
['.html', 'text/html; charset=utf-8'],
|
|
40
|
+
['.htm', 'text/html; charset=utf-8'],
|
|
41
|
+
['.xml', 'application/xml; charset=utf-8'],
|
|
42
|
+
['.yml', 'text/yaml; charset=utf-8'],
|
|
43
|
+
['.yaml', 'text/yaml; charset=utf-8'],
|
|
44
|
+
['.png', 'image/png'],
|
|
45
|
+
['.jpg', 'image/jpeg'],
|
|
46
|
+
['.jpeg', 'image/jpeg'],
|
|
47
|
+
['.gif', 'image/gif'],
|
|
48
|
+
['.webp', 'image/webp'],
|
|
49
|
+
['.svg', 'image/svg+xml'],
|
|
50
|
+
['.zip', 'application/zip'],
|
|
51
|
+
['.gz', 'application/gzip'],
|
|
52
|
+
['.mp4', 'video/mp4'],
|
|
53
|
+
['.mp3', 'audio/mpeg'],
|
|
54
|
+
['.wav', 'audio/wav'],
|
|
55
|
+
])
|
|
56
|
+
|
|
57
|
+
export function mimeOf(name) {
|
|
58
|
+
const at = name.lastIndexOf('.')
|
|
59
|
+
if (at === -1) return 'application/octet-stream'
|
|
60
|
+
return MIME_BY_EXT.get(name.slice(at).toLowerCase()) ?? 'application/octet-stream'
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** attachment disposition with an UTF-8 form plus an ASCII fallback. */
|
|
64
|
+
export function dispositionOf(name) {
|
|
65
|
+
const clean = String(name ?? '').replace(/[\r\n]/g, ' ').trim() || 'download'
|
|
66
|
+
const fallback = clean.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_')
|
|
67
|
+
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(clean)}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Shared secret gating the host's file route. Derived from the host id only,
|
|
72
|
+
* so it survives token regeneration and needs no protocol change: the relay
|
|
73
|
+
* derives the same value from the hello hostId to stamp the visitor shim.
|
|
74
|
+
*/
|
|
75
|
+
export function fileRouteKey(hostId) {
|
|
76
|
+
return createHash('sha256').update(`dsh-remote-file:v1:${hostId}`).digest('hex').slice(0, 32)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Constant-time-ish comparison for short hex strings. */
|
|
80
|
+
function keyMatches(given, expected) {
|
|
81
|
+
const a = Buffer.from(String(given ?? ''))
|
|
82
|
+
const b = Buffer.from(String(expected ?? ''))
|
|
83
|
+
if (a.length !== b.length) return false
|
|
84
|
+
let diff = 0
|
|
85
|
+
for (let i = 0; i < b.length; i++) diff |= a[i] ^ b[i]
|
|
86
|
+
return diff === 0
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Register the download route on the host web server.
|
|
91
|
+
* @param services { webServer: object with register(route) → disposer }
|
|
92
|
+
* @param key string from fileRouteKey(identity.hostId)
|
|
93
|
+
* @param log fn(kind, text)
|
|
94
|
+
* @returns disposer
|
|
95
|
+
*/
|
|
96
|
+
export function registerFileRoute({ webServer, key, log = () => {} }) {
|
|
97
|
+
return webServer.register({
|
|
98
|
+
kind: 'prefix',
|
|
99
|
+
path: '/dsh-remote-file',
|
|
100
|
+
handler: (req, res) => {
|
|
101
|
+
try {
|
|
102
|
+
void handle(req, res)
|
|
103
|
+
} catch (error) {
|
|
104
|
+
log('file-route', `下载路由处理异常(已隔离): ${error?.message ?? error}`)
|
|
105
|
+
try { res.writeHead(500).end() } catch { try { res.destroy() } catch { /* gone */ } }
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
async function handle(req, res) {
|
|
111
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
112
|
+
const send = (status, text) => {
|
|
113
|
+
if (res.headersSent) { try { res.destroy() } catch { res.end() } ; return }
|
|
114
|
+
res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' })
|
|
115
|
+
res.end(`${text}\n`)
|
|
116
|
+
}
|
|
117
|
+
if (!url.pathname.startsWith('/dsh-remote-file')) return send(404, 'not found')
|
|
118
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return send(405, 'method not allowed')
|
|
119
|
+
// Loopback strangers and forged pages must not read files; only requests
|
|
120
|
+
// carrying the key survive (the visitor shim stamps it, tunnel or direct).
|
|
121
|
+
const given = url.searchParams.get('k') ?? req.headers['x-dsh-remote-key'] ?? ''
|
|
122
|
+
if (!keyMatches(given, key)) return send(403, 'bad download key')
|
|
123
|
+
const filePath = url.searchParams.get('path') ?? ''
|
|
124
|
+
if (!filePath || !filePath.startsWith('/') || filePath.includes('\0')) return send(400, 'path must be absolute')
|
|
125
|
+
let info
|
|
126
|
+
try {
|
|
127
|
+
info = await stat(filePath)
|
|
128
|
+
} catch {
|
|
129
|
+
return send(404, 'file not found')
|
|
130
|
+
}
|
|
131
|
+
if (!info.isFile()) return send(404, 'not a regular file')
|
|
132
|
+
if (info.size > MAX_DOWNLOAD_BYTES) return send(413, 'file too large')
|
|
133
|
+
const name = filePath.split('/').pop() || 'download'
|
|
134
|
+
let stream
|
|
135
|
+
try {
|
|
136
|
+
stream = createReadStream(filePath)
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return send(404, `file unreadable: ${error?.message ?? error}`)
|
|
139
|
+
}
|
|
140
|
+
res.writeHead(200, {
|
|
141
|
+
'Content-Type': mimeOf(name),
|
|
142
|
+
'Content-Length': String(info.size),
|
|
143
|
+
'Content-Disposition': dispositionOf(name),
|
|
144
|
+
'Cache-Control': 'no-store',
|
|
145
|
+
'X-Content-Type-Options': 'nosniff',
|
|
146
|
+
})
|
|
147
|
+
stream.on('error', (error) => {
|
|
148
|
+
log('file-route', `下载流中断: ${error?.message ?? error}`)
|
|
149
|
+
try { res.destroy() } catch { res.end() }
|
|
150
|
+
})
|
|
151
|
+
req.on('close', () => { try { stream.destroy() } catch { /* gone */ } })
|
|
152
|
+
if (req.method === 'HEAD') {
|
|
153
|
+
stream.destroy()
|
|
154
|
+
res.end()
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
stream.pipe(res)
|
|
158
|
+
}
|
|
159
|
+
}
|