@cowwo/dsh-lookout 0.5.2 → 0.5.4

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/client/client.js CHANGED
@@ -7293,6 +7293,11 @@ window.__ModuleLoader__.load({
7293
7293
  const ROW_MENU_BODY_SELECTOR = "[data-files-body]";
7294
7294
  const ROW_MENU_ROWS_SELECTOR = 'li[data-files-entry="file"],li[data-files-entry="directory"]';
7295
7295
  const ROW_MENU_PATH_ATTR = "data-files-path";
7296
+ /** Same-origin ticket route: a signed-in POST mints a one-shot download URL. */
7297
+ const ROW_MENU_TICKET_ROUTE = "/api/lookout.ticket";
7298
+ /** Where the current session id is read from, sidebar marker first, storage second. */
7299
+ const ROW_MENU_SESSION_ATTR = "data-sidebar-right-session";
7300
+ const ROW_MENU_SESSION_KEY = "dsh.sessions.current";
7296
7301
  /** A run that places nothing at all, this many times in a row, gives up. */
7297
7302
  const ROW_MENU_MAX_FAILURES = 3;
7298
7303
  const ROW_MENU_SCAN_DEBOUNCE = 200;
@@ -7322,12 +7327,14 @@ window.__ModuleLoader__.load({
7322
7327
  download: "下载",
7323
7328
  folderUnsupported: "文件夹暂不支持",
7324
7329
  copyFailed: "复制失败,请手动选择路径",
7330
+ err400: "下载参数不完整:缺少会话或文件路径",
7325
7331
  err401: "登录已过期,请刷新页面重新登录",
7326
- err413: "文件太大,超出服务器允许的下载上限",
7327
7332
  err404: "文件不存在或已被删除",
7328
- err403: "没有权限读取这个文件",
7333
+ err403: "只能下载当前会话工作区内的文件",
7334
+ err410: "下载链接已失效或被用尽,请重新点击下载",
7329
7335
  errOther: "下载失败(HTTP {status})",
7330
7336
  errNetwork: "下载失败,请检查连接",
7337
+ errSession: "无法确定当前会话,已取消下载",
7331
7338
  givingUp: "文件行「⋯」菜单连续注入失败,已自动停止;文件树本身不受影响。",
7332
7339
  dismiss: "不再提示"
7333
7340
  };
@@ -7340,12 +7347,14 @@ window.__ModuleLoader__.load({
7340
7347
  download: "Download",
7341
7348
  folderUnsupported: "Folders not supported",
7342
7349
  copyFailed: "Copy failed — select the path and copy it manually",
7350
+ err400: "The download request is incomplete: missing session or file path.",
7343
7351
  err401: "Your session has expired. Refresh the page and sign in again.",
7344
- err413: "This file is larger than the server's download limit.",
7345
7352
  err404: "The file no longer exists or was deleted.",
7346
- err403: "You do not have permission to read this file.",
7353
+ err403: "You can only download files inside the current session's workspace.",
7354
+ err410: "This download link has expired or was already used — click download again.",
7347
7355
  errOther: "Download failed (HTTP {status}).",
7348
7356
  errNetwork: "Download failed — check your connection.",
7357
+ errSession: "Cannot determine the current session — download cancelled.",
7349
7358
  givingUp: "The file-row ⋯ menu could not be injected repeatedly and stopped; the file tree itself is unaffected.",
7350
7359
  dismiss: "Do not show again"
7351
7360
  };
@@ -8133,40 +8142,97 @@ window.__ModuleLoader__.load({
8133
8142
 
8134
8143
  function downloadErrorText(status) {
8135
8144
  switch (status) {
8145
+ case 400: return translate("err400");
8136
8146
  case 401: return translate("err401");
8137
- case 413: return translate("err413");
8138
- case 404: return translate("err404");
8139
8147
  case 403: return translate("err403");
8148
+ case 404: return translate("err404");
8149
+ case 410: return translate("err410");
8140
8150
  default: return translate("errOther", { status: status > 0 ? status : "?" });
8141
8151
  }
8142
8152
  }
8143
8153
 
8144
- async function downloadRowFile(path) {
8154
+ /** The current session id: sidebar marker first, persisted pointer second, "" if neither. */
8155
+ function currentSessionId() {
8156
+ try {
8157
+ const marker = document.querySelector("[" + ROW_MENU_SESSION_ATTR + "]");
8158
+ const attr = marker !== null && marker !== undefined ? marker.getAttribute(ROW_MENU_SESSION_ATTR) : null;
8159
+ if (typeof attr === "string" && attr !== "") return attr;
8160
+ } catch (error) { /* fall through to storage */ }
8161
+ try {
8162
+ const stored = window.localStorage.getItem(ROW_MENU_SESSION_KEY);
8163
+ if (typeof stored === "string" && stored !== "") return stored;
8164
+ } catch (error) { /* no storage: no session */ }
8165
+ return "";
8166
+ }
8167
+
8168
+ /**
8169
+ * Ask the host for a one-shot download URL.
8170
+ *
8171
+ * The ticket route is the only place that knows the session workspace, so the
8172
+ * browser never puts a file path into a URL it then hands to a download
8173
+ * manager: the returned link already carries a single-use token.
8174
+ * @param sessionId - current session id.
8175
+ * @param path - absolute path of the row.
8176
+ * @returns {Promise<{url: string, filename: string}|null>} the ticket, or null when refused.
8177
+ */
8178
+ async function requestDownloadTicket(sessionId, path) {
8145
8179
  let response = null;
8146
8180
  try {
8147
- response = await window.fetch("api/file?path=" + encodeURIComponent(path), { credentials: "same-origin" });
8181
+ response = await window.fetch(ROW_MENU_TICKET_ROUTE, {
8182
+ method: "POST",
8183
+ credentials: "same-origin",
8184
+ headers: { "content-type": "application/json" },
8185
+ body: JSON.stringify({ sessionId, path }),
8186
+ });
8148
8187
  } catch (error) {
8149
8188
  if (!disposed) showToast(translate("errNetwork"), false);
8150
- return;
8189
+ return null;
8151
8190
  }
8152
8191
  if (response === null || response.ok !== true) {
8153
8192
  const status = response !== null && typeof response.status === "number" ? response.status : 0;
8154
8193
  if (!disposed) showToast(downloadErrorText(status), false);
8194
+ return null;
8195
+ }
8196
+ let payload = null;
8197
+ try {
8198
+ payload = await response.json();
8199
+ } catch (error) {
8200
+ if (!disposed) showToast(translate("errOther", { status: "?" }), false);
8201
+ return null;
8202
+ }
8203
+ const url = payload !== null && typeof payload === "object" ? payload.url : null;
8204
+ if (typeof url !== "string" || url === "") {
8205
+ if (!disposed) showToast(translate("errOther", { status: "?" }), false);
8206
+ return null;
8207
+ }
8208
+ const name = payload !== null && typeof payload === "object" && typeof payload.filename === "string" && payload.filename !== ""
8209
+ ? payload.filename
8210
+ : fileNameFor(path);
8211
+ return { url, filename: name };
8212
+ }
8213
+
8214
+ /**
8215
+ * Download without ever holding the file: mint a one-shot ticket, then let a
8216
+ * plain <a download> stream the host's own GET to disk. A refused ticket
8217
+ * never starts a download, it only explains why.
8218
+ */
8219
+ async function downloadRowFile(path) {
8220
+ const sessionId = currentSessionId();
8221
+ if (sessionId === "") {
8222
+ if (!disposed) showToast(translate("errSession"), false);
8155
8223
  return;
8156
8224
  }
8225
+ const ticket = await requestDownloadTicket(sessionId, path);
8226
+ if (ticket === null || disposed) return;
8157
8227
  try {
8158
- const blob = await response.blob();
8159
- if (disposed) return;
8160
- const objectUrl = window.URL.createObjectURL(blob);
8161
8228
  const link = document.createElement("a");
8162
- link.href = objectUrl;
8163
- link.download = fileNameFor(path);
8229
+ link.href = ticket.url;
8230
+ link.download = ticket.filename;
8164
8231
  link.rel = "noopener";
8165
8232
  link.style.display = "none";
8166
8233
  document.body.appendChild(link);
8167
8234
  link.click();
8168
8235
  link.remove();
8169
- window.setTimeout(() => { try { window.URL.revokeObjectURL(objectUrl); } catch { /* gone */ } }, 1000);
8170
8236
  } catch (error) {
8171
8237
  if (!disposed) showToast(translate("errNetwork"), false);
8172
8238
  }
@@ -0,0 +1,843 @@
1
+ /**
2
+ * dsh-lookout · 宿主侧票据下载路由
3
+ *
4
+ * 两条精确的 Fetch 路由:
5
+ * POST /api/lookout.ticket 签发一次性票据(JSON in → JSON out)
6
+ * GET|HEAD /api/lookout.download?ticket=... 用票据换一个限时下载会话
7
+ *
8
+ * 为什么要有票据这一层:老路由把 sessionId/path 直接放在 query 里,任何能发请求的
9
+ * 页面都能拼一条 URL 去读别人工作区里的文件。现在 URL 里只有一个不可猜的随机串,
10
+ * 服务端只存它的 SHA-256,并且第一次 GET 之后就把票据换成"限时 + 限次"的会话。
11
+ *
12
+ * 五条硬约束(改代码时别动):
13
+ * 1. 路径逐段必须匹配 [A-Za-z0-9_$.-]+,且宿主强制 /api/ 前缀——所以是
14
+ * '/api/lookout.download' 这个字面量,不能把参数拼进路径。
15
+ * 2. **不要把 connection/fs 塞进 lib/index.js 顶层的 inject 数组**:那会让没有 web 载体的
16
+ * 组合(CLI)一直等不到服务、卡死插件加载。这里用 ctx.inject 局部等待。
17
+ * 3. 放行判定是"词法归一化 + realpath"两道:只有词法那道挡不住符号链接逃逸。
18
+ * 4. 票据只在服务端以 SHA-256 十六进制形式存在,日志里一个字都不许出现。
19
+ * 5. 清理定时器必须 unref,disposer 必须把它清掉,否则插件卸载后进程退不出去。
20
+ *
21
+ * 错误码:
22
+ * 签发:缺参/非绝对路径/JSON 坏掉 400;越界或非普通文件 403;不存在 404;其它 500。
23
+ * 下载:缺 ticket 400;查不到(过期/不存在/已用尽,一律不区分)410;其它同上。
24
+ */
25
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
26
+ import { createReadStream, fstat } from 'node:fs'
27
+ import { realpath } from 'node:fs/promises'
28
+ import { Readable } from 'node:stream'
29
+ import { basename, isAbsolute, normalize, sep } from 'node:path'
30
+
31
+ /** 下载路由路径:宿主强制 /api/ 前缀,且每段要过 [A-Za-z0-9_$.-]+ 校验。 */
32
+ export const DOWNLOAD_ROUTE_PATH = '/api/lookout.download'
33
+
34
+ /** 同一路由路径的短名(老调用方只认这个名字)。 */
35
+ export const DOWNLOAD_ROUTE = DOWNLOAD_ROUTE_PATH
36
+
37
+ /** 签发路由路径。 */
38
+ export const TICKET_ROUTE_PATH = '/api/lookout.ticket'
39
+
40
+ /** 未兑换票据的存活时间:5 分钟。 */
41
+ export const TICKET_TTL_MS = 5 * 60 * 1000
42
+
43
+ /** 兑换后下载会话的存活时间:10 分钟。 */
44
+ export const SESSION_TTL_MS = 10 * 60 * 1000
45
+
46
+ /** 一个下载会话里允许的 GET 次数(首次兑换那次不计数)。 */
47
+ export const SESSION_QUOTA = 32
48
+
49
+ /** 同一个会话里最多存多少张未兑换票据,超出踢最旧的。 */
50
+ export const MAX_TICKETS_PER_SESSION = 32
51
+
52
+ /** 全局最多存多少条 entry(票据 + 会话共用)。 */
53
+ export const MAX_ENTRIES = 512
54
+
55
+ /** 空闲清理周期。 */
56
+ export const SWEEP_INTERVAL_MS = 60 * 1000
57
+
58
+ /** 认不出扩展名时一律给这个,避免浏览器瞎猜。 */
59
+ const DEFAULT_CONTENT_TYPE = 'application/octet-stream'
60
+
61
+ /**
62
+ * 最小扩展名映射。只有一张表,不做内容嗅探:下载场景里"猜错"比"给 stream"更糟。
63
+ * @type {Record<string, string>}
64
+ */
65
+ const CONTENT_TYPES = {
66
+ '.txt': 'text/plain',
67
+ '.md': 'text/markdown',
68
+ '.json': 'application/json',
69
+ '.csv': 'text/csv',
70
+ '.html': 'text/html',
71
+ '.css': 'text/css',
72
+ '.js': 'text/javascript',
73
+ '.mjs': 'text/javascript',
74
+ '.xml': 'application/xml',
75
+ '.yaml': 'text/yaml',
76
+ '.yml': 'text/yaml',
77
+ '.png': 'image/png',
78
+ '.jpg': 'image/jpeg',
79
+ '.jpeg': 'image/jpeg',
80
+ '.gif': 'image/gif',
81
+ '.webp': 'image/webp',
82
+ '.svg': 'image/svg+xml',
83
+ '.pdf': 'application/pdf',
84
+ '.zip': 'application/zip',
85
+ '.gz': 'application/gzip',
86
+ '.mp3': 'audio/mpeg',
87
+ '.wav': 'audio/wav',
88
+ '.mp4': 'video/mp4',
89
+ }
90
+
91
+ /**
92
+ * 按扩展名给出 content-type。
93
+ * @param {string} fileName 文件名(大小写不敏感)
94
+ * @returns {string} MIME 类型;认不出给 application/octet-stream
95
+ */
96
+ export function contentTypeOf(fileName) {
97
+ const dot = fileName.lastIndexOf('.')
98
+ if (dot <= 0) return DEFAULT_CONTENT_TYPE
99
+ return CONTENT_TYPES[fileName.slice(dot).toLowerCase()] ?? DEFAULT_CONTENT_TYPE
100
+ }
101
+
102
+ /**
103
+ * content-disposition 里 filename="..." 那份的 ASCII 兜底:
104
+ * 非可见 ASCII 与引号/反斜杠一律换成下划线,空名字退回 download。
105
+ * @param {string} fileName 原始文件名
106
+ * @returns {string} 纯 ASCII 文件名
107
+ */
108
+ export function asciiFileName(fileName) {
109
+ const cleaned = String(fileName).replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_').trim()
110
+ return cleaned.length === 0 ? 'download' : cleaned
111
+ }
112
+
113
+ /**
114
+ * 拼完整的 content-disposition:ASCII 兜底 + RFC 5987 的 filename*(真名,中文不丢)。
115
+ * @param {string} fileName 原始文件名
116
+ * @returns {string} 头值
117
+ */
118
+ export function contentDisposition(fileName) {
119
+ const name = String(fileName ?? '')
120
+ return 'attachment; filename="' + asciiFileName(name) + '"; filename*=UTF-8\'\'' + encodeURIComponent(name || 'download')
121
+ }
122
+
123
+ /**
124
+ * 判定 candidate 是否落在 root 之内(root 自身算在内)。两边都应是已归一化的绝对路径。
125
+ * @param {string} root 边界目录
126
+ * @param {string} candidate 待判定路径
127
+ * @returns {boolean} 是否在范围内
128
+ */
129
+ export function withinRoot(root, candidate) {
130
+ if (candidate === root) return true
131
+ const base = root.endsWith(sep) ? root : root + sep
132
+ return candidate.startsWith(base)
133
+ }
134
+
135
+ /**
136
+ * 读一个宿主服务:先用 Reflect.get(普通对象与 ctx 都吃),再退回 ctx.get。
137
+ * @param {object} ctx 上下文(真实 ctx、inject 回调里的 scoped,或 {fs, sessions} 这类直给对象)
138
+ * @param {string} name 服务名
139
+ * @returns {any} 服务实例或 undefined
140
+ */
141
+ function serviceOf(ctx, name) {
142
+ const direct = Reflect.get(ctx, name)
143
+ if (direct !== undefined && direct !== null) return direct
144
+ return typeof ctx?.get === 'function' ? ctx.get(name) : undefined
145
+ }
146
+
147
+ /**
148
+ * 取会话 cwd。字段名现场确认过:dsh-session 的 Session 上是 session.header.cwd
149
+ * (SessionHeader.cwd,创建时校验必须是绝对路径字符串);meta.cwd / cwd 只作兜底。
150
+ * @param {object|undefined} session ctx.sessions.get() 的返回值
151
+ * @returns {string|undefined} 绝对路径 cwd
152
+ */
153
+ export function sessionCwd(session) {
154
+ if (session === null || typeof session !== 'object') return undefined
155
+ const candidates = [session.header?.cwd, session.meta?.cwd, session.cwd]
156
+ for (const value of candidates) if (typeof value === 'string' && value.length > 0) return value
157
+ return undefined
158
+ }
159
+
160
+ /**
161
+ * 取某个会话的工作区目录(下载的边界)。先 get,拿不到再在 list 里按 id 匹配一次。
162
+ * @param {object|undefined} sessions ctx.sessions
163
+ * @param {string} sessionId 会话 id
164
+ * @returns {string|null} 绝对路径,取不到给 null
165
+ */
166
+ export function sessionWorkspace(sessions, sessionId) {
167
+ if (sessions === null || typeof sessions !== 'object') return null
168
+ if (typeof sessions.get === 'function') {
169
+ const cwd = sessionCwd(sessions.get(sessionId))
170
+ if (cwd !== undefined) return cwd
171
+ }
172
+ if (typeof sessions.list === 'function') {
173
+ for (const session of sessions.list() ?? []) {
174
+ if (session?.header?.id === sessionId || session?.id === sessionId) {
175
+ const cwd = sessionCwd(session)
176
+ if (cwd !== undefined) return cwd
177
+ }
178
+ }
179
+ }
180
+ return null
181
+ }
182
+
183
+ /**
184
+ * realpath 的"宽容版":路径不存在/无权限时原样返回,让后面的 stat 去定 404。
185
+ * @param {string} path 绝对路径
186
+ * @returns {Promise<string>} realpath,或原路径
187
+ */
188
+ async function realPathOrSelf(path) {
189
+ try {
190
+ return await realpath(path)
191
+ } catch {
192
+ return path
193
+ }
194
+ }
195
+
196
+ /**
197
+ * 统一的安全响应头。所有出口(含错误页)都要带上这一份。
198
+ * @param {Record<string, string>} [extra] 追加的头
199
+ * @returns {Record<string, string>} 头对象
200
+ */
201
+ function securityHeaders(extra = {}) {
202
+ return {
203
+ 'cache-control': 'no-store',
204
+ 'referrer-policy': 'no-referrer',
205
+ 'x-content-type-options': 'nosniff',
206
+ ...extra,
207
+ }
208
+ }
209
+
210
+ /**
211
+ * 统一的错误响应:纯文本 + 安全头(不吐内部细节)。
212
+ * @param {number} status HTTP 状态码
213
+ * @param {string} message 一行说明
214
+ * @returns {Response} 响应
215
+ */
216
+ function errorResponse(status, message) {
217
+ return new Response(message, {
218
+ status,
219
+ headers: securityHeaders({ 'content-type': 'text/plain; charset=utf-8' }),
220
+ })
221
+ }
222
+
223
+ /**
224
+ * JSON 响应:签发接口与它的错误都用这一份,保证 content-type 与禁缓存一致。
225
+ * @param {number} status HTTP 状态码
226
+ * @param {unknown} payload 任意可序列化对象
227
+ * @returns {Response} 响应
228
+ */
229
+ function jsonResponse(status, payload) {
230
+ return new Response(JSON.stringify(payload), {
231
+ status,
232
+ headers: securityHeaders({ 'content-type': 'application/json' }),
233
+ })
234
+ }
235
+
236
+ /**
237
+ * 把 fs 层的失败翻译成状态码:FS_NOT_FOUND / ENOENT 当"不存在",其它交给外层 500。
238
+ * @param {unknown} error 抛出的错误
239
+ * @returns {boolean} 是否属于"不存在"
240
+ */
241
+ function isNotFound(error) {
242
+ const code = error !== null && typeof error === 'object' ? error.code : undefined
243
+ return code === 'FS_NOT_FOUND' || code === 'ENOENT'
244
+ }
245
+
246
+ /**
247
+ * 事件日志:只在调用方显式给了 logger 时才写,且只允许"事件 + 文件名"。
248
+ * token / 哈希在任何情况下都不进日志——这一点由调用点的参数结构保证。
249
+ * @param {object} deps 依赖包
250
+ * @returns {(event: string, detail?: string) => void} 记录函数
251
+ */
252
+ function createEventLogger(deps) {
253
+ const sink = typeof deps?.logger === 'function' ? deps.logger : undefined
254
+ return (event, detail) => {
255
+ if (sink === undefined) return
256
+ try {
257
+ sink(event, detail)
258
+ } catch {
259
+ /* 日志不许影响主流程 */
260
+ }
261
+ }
262
+ }
263
+
264
+ // ───────────────────────── 票据 / 会话仓库 ─────────────────────────
265
+
266
+ /**
267
+ * SHA-256(token) 的十六进制。服务端只存这个,原文一出生就丢。
268
+ * @param {string} token 票据原文
269
+ * @returns {string} 64 位小写十六进制
270
+ */
271
+ export function hashToken(token) {
272
+ return createHash('sha256').update(String(token)).digest('hex')
273
+ }
274
+
275
+ /**
276
+ * 常量时间的十六进制比对:长度不等/非十六进制先返回假,再交给 timingSafeEqual。
277
+ * @param {string} actual 候选值
278
+ * @param {string} expected 期望值
279
+ * @returns {boolean} 是否相等
280
+ */
281
+ export function timingSafeEqualHex(actual, expected) {
282
+ if (typeof actual !== 'string' || typeof expected !== 'string') return false
283
+ if (actual.length !== expected.length || actual.length === 0) return false
284
+ const left = Buffer.from(actual, 'hex')
285
+ const right = Buffer.from(expected, 'hex')
286
+ if (left.length === 0 || left.length !== right.length) return false
287
+ return timingSafeEqual(left, right)
288
+ }
289
+
290
+ /**
291
+ * 造一张票据仓库。注入时钟是为了让"过期"能在单测里被推进,而不是靠 sleep。
292
+ *
293
+ * entry 形态(票据期与会话期复用同一个对象,只换 kind):
294
+ * { tokenHash, kind: 'ticket' | 'session', sessionId, path, filename, size,
295
+ * expiresAt, remaining, createdAt }
296
+ *
297
+ * @param {{
298
+ * now?: () => number,
299
+ * createToken?: () => string,
300
+ * ticketTtl?: number,
301
+ * sessionTtl?: number,
302
+ * sessionQuota?: number,
303
+ * maxPerSession?: number,
304
+ * maxEntries?: number,
305
+ * sweepInterval?: number,
306
+ * setInterval?: typeof setInterval,
307
+ * clearInterval?: typeof clearInterval,
308
+ * }} [options] 覆盖项
309
+ * @returns {object} 仓库
310
+ */
311
+ export function createTicketStore(options = {}) {
312
+ const now = typeof options.now === 'function' ? options.now : Date.now
313
+ const ticketTtl = Number.isFinite(options.ticketTtl) ? options.ticketTtl : TICKET_TTL_MS
314
+ const sessionTtl = Number.isFinite(options.sessionTtl) ? options.sessionTtl : SESSION_TTL_MS
315
+ const sessionQuota = Number.isSafeInteger(options.sessionQuota) ? options.sessionQuota : SESSION_QUOTA
316
+ const maxPerSession = Number.isSafeInteger(options.maxPerSession) ? options.maxPerSession : MAX_TICKETS_PER_SESSION
317
+ const maxEntries = Number.isSafeInteger(options.maxEntries) ? options.maxEntries : MAX_ENTRIES
318
+ const sweepInterval = Number.isFinite(options.sweepInterval) ? options.sweepInterval : SWEEP_INTERVAL_MS
319
+ const issueToken = typeof options.createToken === 'function' ? options.createToken : () => randomBytes(32).toString('base64url')
320
+
321
+ /** @type {Map<string, object>} key = SHA-256(token) 十六进制 */
322
+ const entries = new Map()
323
+ let timer
324
+
325
+ /** 清掉所有已过期的 entry。 */
326
+ const sweep = () => {
327
+ const at = now()
328
+ for (const [key, entry] of entries) if (entry.expiresAt <= at) entries.delete(key)
329
+ }
330
+
331
+ /** 第一次签发时才挂周期清理,并立刻 unref:绝不阻止进程退出。 */
332
+ const ensureTimer = () => {
333
+ if (timer !== undefined) return
334
+ const schedule = typeof options.setInterval === 'function' ? options.setInterval : setInterval
335
+ timer = schedule(() => {
336
+ try {
337
+ sweep()
338
+ } catch {
339
+ /* 清理失败不影响请求 */
340
+ }
341
+ }, sweepInterval)
342
+ /** 定时器绝不能拖住进程退出。 */
343
+ timer?.unref?.()
344
+ }
345
+
346
+ /**
347
+ * 按 hash 查一条 entry。过期即删;命中的那一条再用常量时间比一次哈希。
348
+ * @param {string} token 票据原文
349
+ * @returns {object|null} entry 或 null
350
+ */
351
+ const lookup = (token) => {
352
+ if (typeof token !== 'string' || token.length === 0) return null
353
+ const tokenHash = hashToken(token)
354
+ const entry = entries.get(tokenHash)
355
+ if (entry === undefined) return null
356
+ // Map 已经保证键相等,这里再比一次:把"比 token"这件事锁在常量时间实现里,
357
+ // 而不是依赖 Map 的内部实现。
358
+ if (!timingSafeEqualHex(tokenHash, entry.tokenHash)) return null
359
+ if (entry.expiresAt <= now()) {
360
+ entries.delete(entry.tokenHash)
361
+ return null
362
+ }
363
+ return entry
364
+ }
365
+
366
+ /**
367
+ * 踢掉指定会话里最旧的未兑换票据,直到低于配额。
368
+ * @param {string} sessionId 会话 id
369
+ */
370
+ const trimSessionTickets = (sessionId) => {
371
+ const keys = []
372
+ for (const [key, entry] of entries) {
373
+ if (entry.kind === 'ticket' && entry.sessionId === sessionId) keys.push(key)
374
+ }
375
+ while (keys.length >= maxPerSession) entries.delete(keys.shift())
376
+ }
377
+
378
+ /** 全局超限时按插入顺序踢最旧的一条。 */
379
+ const trimGlobal = () => {
380
+ while (entries.size >= maxEntries) {
381
+ const oldest = entries.keys().next()
382
+ if (oldest.done === true) return
383
+ entries.delete(oldest.value)
384
+ }
385
+ }
386
+
387
+ return {
388
+ /**
389
+ * 签一张票据。返回原文(只在这里出现一次)与过期时间。
390
+ * @param {{sessionId: string, path: string, filename: string, size?: number}} input 绑定信息
391
+ * @returns {{token: string, expiresAt: number}} 签发结果
392
+ */
393
+ issue(input) {
394
+ ensureTimer()
395
+ sweep()
396
+ const token = issueToken()
397
+ const tokenHash = hashToken(token)
398
+ trimSessionTickets(input.sessionId)
399
+ trimGlobal()
400
+ const createdAt = now()
401
+ entries.set(tokenHash, {
402
+ tokenHash,
403
+ kind: 'ticket',
404
+ sessionId: input.sessionId,
405
+ path: input.path,
406
+ filename: input.filename,
407
+ size: input.size,
408
+ expiresAt: createdAt + ticketTtl,
409
+ remaining: 0,
410
+ createdAt,
411
+ })
412
+ return { token, expiresAt: createdAt + ticketTtl }
413
+ },
414
+
415
+ /**
416
+ * 只看不动(HEAD 用):过期/不存在都给 null,且不消费、不建会话。
417
+ * @param {string} token 票据原文
418
+ * @returns {object|null} entry 或 null
419
+ */
420
+ peek: lookup,
421
+
422
+ /**
423
+ * 原子地"消费票据 → 建下载会话",或在既有会话里扣一次配额。
424
+ *
425
+ * - 票据期:kind 改成 'session'、续 10 分钟、remaining = 配额(本次不扣)。
426
+ * - 会话期:remaining > 0 则 -1;扣到 0 立刻删掉(之后 410)。
427
+ * - 过期 / 不存在 / 已用尽:null。
428
+ *
429
+ * 整个函数是同步的,Node 单线程下天然原子:不会出现"两个请求各拿到一半配额"。
430
+ * @param {string} token 票据原文
431
+ * @returns {object|null} 可用的 entry 或 null
432
+ */
433
+ openForRead(token) {
434
+ const entry = lookup(token)
435
+ if (entry === null) return null
436
+ if (entry.kind === 'ticket') {
437
+ entry.kind = 'session'
438
+ entry.expiresAt = now() + sessionTtl
439
+ entry.remaining = sessionQuota
440
+ // 配额为 0 表示"只允许这一次":本次请求照常放行,但立刻让这条链接失效,
441
+ // 免得它还能被 HEAD 探到(严格一次性场景)。
442
+ if (sessionQuota === 0) entries.delete(entry.tokenHash)
443
+ return entry
444
+ }
445
+ if (entry.remaining > 0) {
446
+ entry.remaining -= 1
447
+ if (entry.remaining === 0) entries.delete(entry.tokenHash)
448
+ return entry
449
+ }
450
+ entries.delete(entry.tokenHash)
451
+ return null
452
+ },
453
+
454
+ /**
455
+ * 当前 entry 数量(诊断用)。
456
+ * @returns {number} 条数
457
+ */
458
+ size() {
459
+ return entries.size
460
+ },
461
+
462
+ /** 清定时器 + 清数据;重复调用无害。 */
463
+ dispose() {
464
+ if (timer !== undefined) {
465
+ const clear = typeof options.clearInterval === 'function' ? options.clearInterval : clearInterval
466
+ try {
467
+ clear(timer)
468
+ } catch {
469
+ /* 已经清过了 */
470
+ }
471
+ timer = undefined
472
+ }
473
+ entries.clear()
474
+ },
475
+ }
476
+ }
477
+
478
+ // ───────────────────────── Range ─────────────────────────
479
+
480
+ /**
481
+ * 解析 Range 头。
482
+ *
483
+ * 只认 bytes=;支持 start-end / start- / -suffix。
484
+ * 多段(含逗号)、越界、格式非法 → unsatisfiable(调用方回 416 + bytes * /size)。
485
+ * 其它单位(如 items=)按 RFC 7233 忽略,当没带 Range 处理。
486
+ *
487
+ * @param {string|null|undefined} header Range 头原文
488
+ * @param {number|undefined} size 文件总字节数;拿不到按 0 算(任何区间都不满足)
489
+ * @returns {{kind: 'none'}|{kind: 'range', start: number, end: number}|{kind: 'unsatisfiable'}} 解析结果
490
+ */
491
+ export function parseRange(header, size) {
492
+ if (header === null || header === undefined) return { kind: 'none' }
493
+ const raw = String(header).trim()
494
+ if (raw.length === 0) return { kind: 'none' }
495
+ const eq = raw.indexOf('=')
496
+ if (eq < 0) return { kind: 'unsatisfiable' }
497
+ if (raw.slice(0, eq).trim().toLowerCase() !== 'bytes') return { kind: 'none' }
498
+ const spec = raw.slice(eq + 1).trim()
499
+ if (spec.length === 0) return { kind: 'unsatisfiable' }
500
+ // 多段:契约要求 416(客户端本来就是分段多线程下载,用不到多段响应)。
501
+ if (spec.includes(',')) return { kind: 'unsatisfiable' }
502
+ const match = /^(\d*)-(\d*)$/.exec(spec)
503
+ if (match === null) return { kind: 'unsatisfiable' }
504
+ const startText = match[1]
505
+ const endText = match[2]
506
+ if (startText.length === 0 && endText.length === 0) return { kind: 'unsatisfiable' }
507
+ const total = typeof size === 'number' && Number.isFinite(size) && size >= 0 ? size : 0
508
+ if (startText.length === 0) {
509
+ const suffix = Number(endText)
510
+ if (!Number.isSafeInteger(suffix) || suffix <= 0) return { kind: 'unsatisfiable' }
511
+ if (total === 0) return { kind: 'unsatisfiable' }
512
+ return { kind: 'range', start: Math.max(0, total - suffix), end: total - 1 }
513
+ }
514
+ const start = Number(startText)
515
+ if (!Number.isSafeInteger(start) || start < 0 || start >= total) return { kind: 'unsatisfiable' }
516
+ let end = total - 1
517
+ if (endText.length > 0) {
518
+ const parsed = Number(endText)
519
+ if (!Number.isSafeInteger(parsed) || parsed < start) return { kind: 'unsatisfiable' }
520
+ end = parsed > total - 1 ? total - 1 : parsed
521
+ }
522
+ return { kind: 'range', start, end }
523
+ }
524
+
525
+ // ───────────────────────── 路径放行 ─────────────────────────
526
+
527
+ /**
528
+ * 把"会话 cwd + 一个绝对路径"变成可流式打开的宿主机真实路径。
529
+ * 两道闸:词法归一化(挡 ..),realpath(挡符号链接逃逸)。
530
+ *
531
+ * @param {object} ctx 上下文
532
+ * @param {{sessionId: string, requestedPath: string, signal?: AbortSignal}} options 请求要素
533
+ * @returns {Promise<{ok: true, hostPath: string, fileName: string, size?: number}
534
+ * | {ok: false, response: Response}>} 结果或现成的错误响应
535
+ */
536
+ async function resolveTarget(ctx, options) {
537
+ const sessions = serviceOf(ctx, 'sessions')
538
+ if (sessions === undefined) return { ok: false, response: errorResponse(500, 'download unavailable: missing sessions service') }
539
+
540
+ // —— 403:会话或它的 cwd 拿不到,就没有边界可谈 ——
541
+ const cwd = sessionWorkspace(sessions, options.sessionId)
542
+ if (cwd === null) return { ok: false, response: errorResponse(403, 'session cwd unavailable') }
543
+
544
+ // —— 第一道:词法归一化(挡 .. 这类纯文本穿越) ——
545
+ const root = normalize(cwd)
546
+ const candidate = normalize(options.requestedPath)
547
+ if (!withinRoot(root, candidate)) return { ok: false, response: errorResponse(403, 'path outside session cwd') }
548
+
549
+ const fs = serviceOf(ctx, 'fs')
550
+ if (typeof fs?.resolve !== 'function' || typeof fs?.stat !== 'function' || typeof fs?.processPath !== 'function') {
551
+ return { ok: false, response: errorResponse(500, 'download unavailable: missing fs service') }
552
+ }
553
+
554
+ let target
555
+ let rootTarget
556
+ try {
557
+ target = await fs.resolve(candidate, { signal: options.signal })
558
+ rootTarget = await fs.resolve(root, { signal: options.signal })
559
+ } catch (error) {
560
+ if (isNotFound(error)) return { ok: false, response: errorResponse(404, 'file not found') }
561
+ throw error
562
+ }
563
+
564
+ // —— 第二道:realpath 之后仍在范围内(挡符号链接逃逸) ——
565
+ // 双保险:既信后端的 processPath(本地后端本身就是 realpath 结果),
566
+ // 也自己 realpath 一次,后端换成非 realpath 的实现时这道闸照样有效。
567
+ const realRoot = String(fs.processPath(rootTarget))
568
+ const hostPath = String(fs.processPath(target))
569
+ const [realRootPath, realTargetPath] = await Promise.all([realPathOrSelf(realRoot), realPathOrSelf(hostPath)])
570
+ if (!withinRoot(realRootPath, realTargetPath)) {
571
+ return { ok: false, response: errorResponse(403, 'path outside session cwd after realpath') }
572
+ }
573
+
574
+ let info
575
+ try {
576
+ info = await fs.stat(target, options.signal)
577
+ } catch (error) {
578
+ if (isNotFound(error) || error?.code === 'ENOTDIR' || error?.code === 'EISDIR') {
579
+ return { ok: false, response: errorResponse(404, 'file not found') }
580
+ }
581
+ throw error
582
+ }
583
+ if (info === undefined) return { ok: false, response: errorResponse(404, 'file not found') }
584
+ if (info.type !== 'file') return { ok: false, response: errorResponse(403, 'not a regular file') }
585
+
586
+ // 后端不保证给 size;缺了就留 undefined,让调用方决定"不写长度"还是 500。
587
+ const size = typeof info.size === 'number' && Number.isFinite(info.size) && info.size >= 0 ? info.size : undefined
588
+ return { ok: true, hostPath: realTargetPath, fileName: basename(candidate), size }
589
+ }
590
+
591
+ /**
592
+ * 给流挂两个保护:打开后复核大小、错误日志过滤掉客户端取消。
593
+ * @param {import('node:stream').Readable} stream 文件流
594
+ * @param {number|undefined} declaredSize 之前 stat 出来的总大小
595
+ * @returns {void}
596
+ */
597
+ function guardStream(stream, declaredSize) {
598
+ stream.once('open', (fd) => {
599
+ if (declaredSize === undefined) return
600
+ try {
601
+ fstat(fd, (error, stats) => {
602
+ // stat 与 open 之间文件被换掉:宁可断流,也不发一份长度对不上的响应。
603
+ if (error === null && stats !== undefined && stats.size !== declaredSize) {
604
+ stream.destroy(new Error('size changed while downloading'))
605
+ }
606
+ })
607
+ } catch {
608
+ /* best effort */
609
+ }
610
+ })
611
+ stream.on('error', (error) => {
612
+ const code = error?.code ?? ''
613
+ // 客户端主动取消/连接被掐断是常态,不该刷日志。
614
+ if (code === 'ERR_STREAM_PREMATURE_CLOSE' || code === 'ECONNRESET' || code === 'EPIPE' || error?.name === 'AbortError') return
615
+ console.warn('[dsh-lookout] download stream failed:', code !== '' ? code : (error?.message ?? error))
616
+ })
617
+ }
618
+
619
+ // ───────────────────────── handlers ─────────────────────────
620
+
621
+ /**
622
+ * 造签发 handler:校验 → 放行 → stat → 签票。
623
+ *
624
+ * @param {object} ctx 上下文
625
+ * @param {{store?: object, now?: () => number, logger?: Function}} [deps] 依赖(store 供测试注入)
626
+ * @returns {(request: Request) => Promise<Response>} Fetch handler
627
+ */
628
+ export function createTicketHandler(ctx, deps = {}) {
629
+ const store = deps.store ?? createTicketStore({ now: deps.now })
630
+ const log = createEventLogger(deps)
631
+
632
+ return async function handleTicket(request) {
633
+ try {
634
+ let payload
635
+ try {
636
+ payload = await request.json()
637
+ } catch {
638
+ return jsonResponse(400, { error: 'invalid json' })
639
+ }
640
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
641
+ return jsonResponse(400, { error: 'invalid body' })
642
+ }
643
+
644
+ const sessionId = payload.sessionId
645
+ const requestedPath = payload.path
646
+ // —— 400:参数这一关先过,不合法就不碰文件系统 ——
647
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return jsonResponse(400, { error: 'missing sessionId' })
648
+ if (typeof requestedPath !== 'string' || requestedPath.length === 0) return jsonResponse(400, { error: 'missing path' })
649
+ if (!isAbsolute(requestedPath)) return jsonResponse(400, { error: 'path must be absolute' })
650
+ // 尾随分隔符是"目录写法":按"没有这个文件"处理,别让它走到 stat 再变成 500。
651
+ if (/[\\/]$/.test(requestedPath)) return jsonResponse(404, { error: 'file not found' })
652
+
653
+ const resolved = await resolveTarget(ctx, { sessionId, requestedPath, signal: request.signal })
654
+ if (resolved.ok !== true) return resolved.response
655
+
656
+ // Range 需要确切大小,签票阶段就定死;拿不到就别签,免得后面算错区间。
657
+ if (typeof resolved.size !== 'number') return jsonResponse(500, { error: 'file size unavailable' })
658
+
659
+ const issued = store.issue({
660
+ sessionId,
661
+ path: resolved.hostPath,
662
+ filename: resolved.fileName,
663
+ size: resolved.size,
664
+ })
665
+ // 日志只写事件 + 文件名;token / 哈希绝不进日志。
666
+ log('ticket.issued', resolved.fileName)
667
+ return jsonResponse(200, {
668
+ url: DOWNLOAD_ROUTE_PATH + '?ticket=' + issued.token,
669
+ expiresAt: issued.expiresAt,
670
+ filename: resolved.fileName,
671
+ size: resolved.size,
672
+ })
673
+ } catch (error) {
674
+ console.warn('[dsh-lookout] ticket route failed:', error?.message ?? error)
675
+ return jsonResponse(500, { error: 'ticket failed' })
676
+ }
677
+ }
678
+ }
679
+
680
+ /**
681
+ * 造下载 handler:票据 → 会话 → Range → 流。
682
+ *
683
+ * deps 只为一件事存在:单测要数"到底有没有真去读文件"、要注入时钟与仓库。
684
+ * 生产路径不传,用 node:fs 的真身。
685
+ *
686
+ * @param {object} ctx 上下文
687
+ * @param {{createReadStream?: typeof createReadStream, store?: object, now?: () => number, logger?: Function}} [deps] 依赖
688
+ * @returns {(request: Request) => Promise<Response>} Fetch handler
689
+ */
690
+ export function createDownloadHandler(ctx, deps = {}) {
691
+ const openStream = typeof deps.createReadStream === 'function' ? deps.createReadStream : createReadStream
692
+ const store = deps.store ?? createTicketStore({ now: deps.now })
693
+ const log = createEventLogger(deps)
694
+
695
+ return async function handleDownload(request) {
696
+ try {
697
+ const params = new URL(request.url).searchParams
698
+ const token = params.get('ticket')
699
+ // 只认 ticket:sessionId / path 一概不看,杜绝"用票据绕过边界"这条路。
700
+ if (token === null || token.length === 0) return errorResponse(400, 'missing ticket')
701
+
702
+ // 先只看不动:票据不存在/过期/用尽一律 410,且不区分原因(防枚举)。
703
+ const peeked = store.peek(token)
704
+ if (peeked === null) return errorResponse(410, 'ticket gone')
705
+
706
+ const resolved = await resolveTarget(ctx, {
707
+ sessionId: peeked.sessionId,
708
+ requestedPath: peeked.path,
709
+ signal: request.signal,
710
+ })
711
+ if (resolved.ok !== true) return resolved.response
712
+
713
+ // GET 才消费;HEAD 只读头,不动配额、不建会话。
714
+ let entry = peeked
715
+ if (request.method === 'HEAD') {
716
+ if (peeked.kind !== 'ticket') log('download.head', peeked.filename)
717
+ } else {
718
+ const opened = store.openForRead(token)
719
+ if (opened === null) return errorResponse(410, 'ticket gone')
720
+ entry = opened
721
+ log('download.started', entry.filename)
722
+ }
723
+
724
+ const fileName = resolved.fileName.length > 0 ? resolved.fileName : (entry.filename ?? 'download')
725
+ const size = resolved.size
726
+ const headers = securityHeaders({
727
+ 'content-type': contentTypeOf(fileName),
728
+ 'content-disposition': contentDisposition(fileName),
729
+ 'accept-ranges': 'bytes',
730
+ })
731
+
732
+ const range = parseRange(request.headers.get('range'), size)
733
+ if (range.kind === 'unsatisfiable') {
734
+ return new Response(request.method === 'HEAD' ? null : 'range not satisfiable', {
735
+ status: 416,
736
+ headers: securityHeaders({
737
+ 'content-type': 'text/plain; charset=utf-8',
738
+ 'accept-ranges': 'bytes',
739
+ 'content-range': 'bytes */' + (typeof size === 'number' ? size : 0),
740
+ }),
741
+ })
742
+ }
743
+ if (range.kind === 'range') {
744
+ headers['content-range'] = 'bytes ' + range.start + '-' + range.end + '/' + size
745
+ headers['content-length'] = String(range.end - range.start + 1)
746
+ // HEAD 带 Range:同样的 206 + 区间头,body 为空,且**不打开文件**。
747
+ if (request.method === 'HEAD') return new Response(null, { status: 206, headers })
748
+ const stream = openStream(resolved.hostPath, { start: range.start, end: range.end })
749
+ guardStream(stream, size)
750
+ return new Response(Readable.toWeb(stream), { status: 206, headers })
751
+ }
752
+
753
+ // 无 Range:全文件 200(保持现状);size 未知就不写 content-length。
754
+ if (typeof size === 'number') headers['content-length'] = String(size)
755
+ if (request.method === 'HEAD') return new Response(null, { status: 200, headers })
756
+ const stream = openStream(resolved.hostPath)
757
+ guardStream(stream, size)
758
+ return new Response(Readable.toWeb(stream), { status: 200, headers })
759
+ } catch (error) {
760
+ // 500 只说一句,不带堆栈:日志里也不要整段内部路径。
761
+ console.warn('[dsh-lookout] download route failed:', error?.message ?? error)
762
+ return errorResponse(500, 'download failed')
763
+ }
764
+ }
765
+ }
766
+
767
+ // ───────────────────────── 注册 ─────────────────────────
768
+
769
+ /** 同一个 connection 只挂一次:宿主对重复路径会直接抛错。 */
770
+ const REGISTERED_CONNECTIONS = new WeakSet()
771
+
772
+ /**
773
+ * 把签发 + 下载两条路由挂到 connection 上(同一个 effect 里,卸载时一并撤销)。
774
+ *
775
+ * 用 ctx.inject 局部等 connection/fs/sessions:缺任何一个就不注册,而不是让整个插件
776
+ * 卡在加载阶段(那正是不能把它们塞进顶层 inject 的原因)。
777
+ *
778
+ * @param {object} ctx 插件上下文
779
+ * @param {{createReadStream?: typeof createReadStream, store?: object, now?: () => number}} [deps] 仅单测使用
780
+ * @returns {void}
781
+ */
782
+ export function registerDownloadRoute(ctx, deps = {}) {
783
+ const attach = (scoped) => {
784
+ const connection = serviceOf(scoped, 'connection')
785
+ if (typeof connection?.fetch?.register !== 'function') return
786
+ // 同一个 connection 只挂一次:宿主对重复路径会直接抛错,抛在 apply 里会带崩整个插件。
787
+ if (REGISTERED_CONNECTIONS.has(connection)) return
788
+
789
+ const store = deps.store ?? createTicketStore({ now: deps.now })
790
+ const ticketRoute = {
791
+ path: TICKET_ROUTE_PATH,
792
+ methods: ['POST'],
793
+ requestBody: 'buffered',
794
+ fetch: createTicketHandler(scoped, { ...deps, store }),
795
+ }
796
+ const downloadRoute = {
797
+ path: DOWNLOAD_ROUTE_PATH,
798
+ methods: ['GET', 'HEAD'],
799
+ requestBody: 'buffered',
800
+ fetch: createDownloadHandler(scoped, { ...deps, store }),
801
+ }
802
+
803
+ const register = () => {
804
+ if (REGISTERED_CONNECTIONS.has(connection)) return undefined
805
+ const disposers = []
806
+ try {
807
+ disposers.push(connection.fetch.register(ticketRoute))
808
+ disposers.push(connection.fetch.register(downloadRoute))
809
+ REGISTERED_CONNECTIONS.add(connection)
810
+ } catch (error) {
811
+ // 已经被别人占了同名路径(或宿主换了校验):记一条,不拖累插件加载。
812
+ console.warn('[dsh-lookout] download route not registered:', error?.code ?? error?.message ?? error)
813
+ for (const dispose of disposers) {
814
+ try {
815
+ dispose?.()
816
+ } catch {
817
+ /* 回滚尽力而为 */
818
+ }
819
+ }
820
+ store.dispose()
821
+ return undefined
822
+ }
823
+ return () => {
824
+ for (const dispose of disposers) {
825
+ try {
826
+ dispose?.()
827
+ } catch {
828
+ /* 撤销尽力而为 */
829
+ }
830
+ }
831
+ // 清定时器 + 清票据:插件卸载后不留任何活物。
832
+ store.dispose()
833
+ REGISTERED_CONNECTIONS.delete(connection)
834
+ }
835
+ }
836
+
837
+ // 包一层 effect:插件卸载时路由与定时器跟着撤掉。
838
+ if (typeof scoped?.effect === 'function') scoped.effect(register, 'dsh-lookout: ' + DOWNLOAD_ROUTE_PATH)
839
+ else register()
840
+ }
841
+ if (typeof ctx?.inject === 'function') ctx.inject(['connection', 'fs', 'sessions'], attach)
842
+ else attach(ctx)
843
+ }
package/lib/index.js CHANGED
@@ -22,6 +22,7 @@ import { FaviconCustomService } from './icon/index.js'
22
22
  import { apply as applyNotify } from './notify/index.js'
23
23
  import { apply as applySessionInfo } from './session/index.js'
24
24
  import { MailSchema } from './notify/config.js'
25
+ import { registerDownloadRoute } from './download.js'
25
26
 
26
27
  export const name = 'dsh-lookout'
27
28
 
@@ -68,4 +69,6 @@ export function apply(ctx, config) {
68
69
  applyNotify(ctx, config)
69
70
  // ③ C:宿主侧本来就是空实现。
70
71
  applySessionInfo(ctx)
72
+ // ④ 宿主流式下载路由:自己 inject(connection/fs/sessions),缺服务就不挂,不动顶层 inject。
73
+ registerDownloadRoute(ctx)
71
74
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cowwo/dsh-lookout",
3
3
  "description": "瞭望哨:盯着你的每个会话——谁跑完了、谁出错了、谁卡住了、谁在等你审批,用浏览器图标角标、红点/黄点和邮件通知你;顺带显示当前会话 ID。· Lookout: watches every session — who finished, who errored, who is stuck, and who is waiting for your approval — and notifies you through the browser favicon badge, red/yellow dots, and email, while also showing the current session ID.",
4
- "version": "0.5.2",
4
+ "version": "0.5.4",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",