@cowwo/dsh-lookout 0.5.1 → 0.5.3

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 CHANGED
@@ -48,7 +48,7 @@
48
48
  文件面板里每个文件 / 文件夹行的右侧多出一个「⋯」按钮,点开可以:
49
49
 
50
50
  - **复制路径**:把这一行的**绝对路径**原样放进剪贴板(不是屏幕上显示的短名)。
51
- - **下载**:下载单个文件,上限 20 MB;超过会提示「文件超过 20MB,暂不支持下载」。
51
+ - **下载**:下载单个文件;上限由服务端决定(默认 50 MiB,可配置),超过会提示「文件太大,超出服务器允许的下载上限」。
52
52
  - **文件夹**:菜单仍可打开,但「下载」是灰的,并标注「文件夹暂不支持」。
53
53
 
54
54
  设置页里有「文件行 ⋯ 菜单」开关,可以整体关掉。这个菜单是贴着宿主文件树画上去的:DSH 升级换了文件面板结构时,它可能不再出现,但**文件树本身不受影响**;连续 3 次注入失败会自动停止,并且只提示一次(可点「不再提示」)。
package/client/client.js CHANGED
@@ -7264,13 +7264,17 @@ window.__ModuleLoader__.load({
7264
7264
  * pair, no React for the menu itself, and nothing but DOM writes into the
7265
7265
  * shipped file tree.
7266
7266
  *
7267
- * Host contract (read-only reference: @deepseek-ai/dsh-client-ui-sidebar-files):
7268
- * [data-files-body] li[data-files-entry="file"|"directory"][data-files-path]
7269
- * > button.row
7267
+ * Host contract (read-only reference: @deepseek-ai/dsh-client-ui-sidebar-files).
7268
+ * Two shapes ship today and both are supported:
7269
+ * legacy (0.2.1): li > button
7270
+ * current (0.2.2): li > div.row > (button.action, div.accessory), and an
7271
+ * expanded directory keeps its <ul class="level"> beside div.row
7270
7272
  * The absolute path is ALWAYS read from data-files-path; it is never rebuilt
7271
- * from the row text. Rows of type "other" are left alone. The menu button is
7272
- * inserted as a SIBLING after that row button, never inside it: a nested
7273
- * button is invalid HTML and its click would bubble into "open this file".
7273
+ * from the row text. Rows of type "other" are left alone. In the legacy shape
7274
+ * the menu button is inserted as a SIBLING after that row button; in the
7275
+ * current shape it becomes one more item inside the host's own accessory slot.
7276
+ * Either way it is never nested inside the host's button: a nested button is
7277
+ * invalid HTML and its click would bubble into "open this file".
7274
7278
  *
7275
7279
  * Every entry point is guarded. A selector that stops matching after a host
7276
7280
  * upgrade can only cost these buttons, never the host's own rendering; three
@@ -7279,12 +7283,21 @@ window.__ModuleLoader__.load({
7279
7283
  const ROW_MENU_NS = "dsh-lookout";
7280
7284
  /** Sibling button injected after the row button; its presence is the idempotency key. */
7281
7285
  const ROW_MENU_ATTR = "data-lookout-rowmenu";
7286
+ /** Modifier for the current host shape: inline inside the host's accessory slot. */
7287
+ const ROW_MENU_INLINE_CLASS = "dsh-lookout-rowmenu--inline";
7288
+ /** The host's right-hand slot inside li > div.row (sidebar-files 0.2.2). */
7289
+ const ROW_MENU_ACCESSORY_CLASS = "accessory";
7282
7290
  const ROW_MENU_POP_ATTR = "data-lookout-rowmenu-pop";
7283
7291
  const ROW_MENU_TOAST_ATTR = "data-lookout-toast";
7284
7292
  const ROW_MENU_STYLE_KEY = "lookout-row-menu";
7285
7293
  const ROW_MENU_BODY_SELECTOR = "[data-files-body]";
7286
7294
  const ROW_MENU_ROWS_SELECTOR = 'li[data-files-entry="file"],li[data-files-entry="directory"]';
7287
7295
  const ROW_MENU_PATH_ATTR = "data-files-path";
7296
+ /** Same-origin download route: HEAD is our pre-flight, the browser streams the GET. */
7297
+ const ROW_MENU_DOWNLOAD_ROUTE = "/api/lookout.download";
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";
7288
7301
  /** A run that places nothing at all, this many times in a row, gives up. */
7289
7302
  const ROW_MENU_MAX_FAILURES = 3;
7290
7303
  const ROW_MENU_SCAN_DEBOUNCE = 200;
@@ -7314,12 +7327,13 @@ window.__ModuleLoader__.load({
7314
7327
  download: "下载",
7315
7328
  folderUnsupported: "文件夹暂不支持",
7316
7329
  copyFailed: "复制失败,请手动选择路径",
7330
+ err400: "下载参数不完整:缺少会话或文件路径",
7317
7331
  err401: "登录已过期,请刷新页面重新登录",
7318
- err413: "文件超过 20MB,暂不支持下载",
7319
7332
  err404: "文件不存在或已被删除",
7320
- err403: "没有权限读取这个文件",
7333
+ err403: "只能下载当前会话工作区内的文件",
7321
7334
  errOther: "下载失败(HTTP {status})",
7322
7335
  errNetwork: "下载失败,请检查连接",
7336
+ errSession: "无法确定当前会话,已取消下载",
7323
7337
  givingUp: "文件行「⋯」菜单连续注入失败,已自动停止;文件树本身不受影响。",
7324
7338
  dismiss: "不再提示"
7325
7339
  };
@@ -7332,12 +7346,13 @@ window.__ModuleLoader__.load({
7332
7346
  download: "Download",
7333
7347
  folderUnsupported: "Folders not supported",
7334
7348
  copyFailed: "Copy failed — select the path and copy it manually",
7349
+ err400: "The download request is incomplete: missing session or file path.",
7335
7350
  err401: "Your session has expired. Refresh the page and sign in again.",
7336
- err413: "This file is larger than 20 MB and cannot be downloaded yet.",
7337
7351
  err404: "The file no longer exists or was deleted.",
7338
- err403: "You do not have permission to read this file.",
7352
+ err403: "You can only download files inside the current session's workspace.",
7339
7353
  errOther: "Download failed (HTTP {status}).",
7340
7354
  errNetwork: "Download failed — check your connection.",
7355
+ errSession: "Cannot determine the current session — download cancelled.",
7341
7356
  givingUp: "The file-row ⋯ menu could not be injected repeatedly and stopped; the file tree itself is unaffected.",
7342
7357
  dismiss: "Do not show again"
7343
7358
  };
@@ -7348,6 +7363,7 @@ window.__ModuleLoader__.load({
7348
7363
  */
7349
7364
  const ROW_MENU_CSS = [
7350
7365
  ".dsh-lookout-rowmenu{position:absolute;top:4px;right:4px;z-index:2;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;line-height:1;touch-action:manipulation;-webkit-tap-highlight-color:transparent;}",
7366
+ ".dsh-lookout-rowmenu--inline{position:static;top:auto;right:auto;flex:none;}",
7351
7367
  ".dsh-lookout-rowmenu:hover,.dsh-lookout-rowmenu:focus-visible{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover);outline:none;}",
7352
7368
  ".dsh-lookout-rowmenu svg{display:block;width:16px;height:16px;pointer-events:none;}",
7353
7369
  ".dsh-lookout-rowmenu[data-lookout-copied]{color:var(--dsw-alias-state-success-primary);}",
@@ -7527,27 +7543,113 @@ window.__ModuleLoader__.load({
7527
7543
  }
7528
7544
 
7529
7545
  // ---- injected row button -------------------------------------------
7530
- function directRowButton(row) {
7546
+ /** True for one of our own ⋯ buttons, whichever shape it currently sits in. */
7547
+ function isMenuButton(node) {
7548
+ return node !== null && node !== undefined && typeof node.getAttribute === "function" && node.getAttribute(ROW_MENU_ATTR) !== null;
7549
+ }
7550
+
7551
+ /** classList when the DOM has one, className text otherwise (svg / minimal hosts). */
7552
+ function hasClass(node, name) {
7553
+ if (node === null || node === undefined) return false;
7554
+ const list = node.classList;
7555
+ if (list !== undefined && list !== null && typeof list.contains === "function") {
7556
+ try { return list.contains(name) === true; } catch { /* fall back to className */ }
7557
+ }
7558
+ const raw = node.className;
7559
+ const text = typeof raw === "string" ? raw : raw !== null && raw !== undefined && typeof raw.baseVal === "string" ? raw.baseVal : "";
7560
+ return text.split(/\s+/).indexOf(name) >= 0;
7561
+ }
7562
+
7563
+ /** Toggle the inline modifier without touching any other class on the button. */
7564
+ function setInlineClass(button, inline) {
7565
+ if (hasClass(button, ROW_MENU_INLINE_CLASS) === inline) return;
7566
+ const list = button.classList;
7567
+ if (list !== undefined && list !== null && typeof list.add === "function" && typeof list.remove === "function") {
7568
+ try {
7569
+ if (inline) list.add(ROW_MENU_INLINE_CLASS);
7570
+ else list.remove(ROW_MENU_INLINE_CLASS);
7571
+ return;
7572
+ } catch { /* fall back to className */ }
7573
+ }
7574
+ const kept = String(button.className === null || button.className === undefined ? "" : button.className)
7575
+ .split(/\s+/)
7576
+ .filter((item) => item !== "" && item !== ROW_MENU_INLINE_CLASS);
7577
+ if (inline) kept.push(ROW_MENU_INLINE_CLASS);
7578
+ button.className = kept.join(" ");
7579
+ }
7580
+
7581
+ /** The host's own row button: first direct child BUTTON that is not ours. */
7582
+ function directRowButton(container) {
7583
+ for (const child of container.children) {
7584
+ if (child.tagName === "BUTTON" && !isMenuButton(child)) return child;
7585
+ }
7586
+ return null;
7587
+ }
7588
+
7589
+ /**
7590
+ * Split one row into the host's button and the slot our ⋯ may occupy.
7591
+ *
7592
+ * Both shipped shapes are accepted, so an upgrade (or a rollback) never costs
7593
+ * the menu:
7594
+ * legacy 0.2.1: li > button
7595
+ * current 0.2.2: li > div.row > (button.action, div.accessory)
7596
+ * The accessory is found by class alone and may sit before or after the
7597
+ * button; when a row has none, the ⋯ is appended to div.row itself.
7598
+ * @param row - one li[data-files-entry] row.
7599
+ * @returns {{rowButton: Element, mount: Element|null, legacy: boolean}|null} null when the row matches neither shape.
7600
+ */
7601
+ function rowParts(row) {
7602
+ const legacyButton = directRowButton(row);
7603
+ if (legacyButton !== null) return { rowButton: legacyButton, mount: null, legacy: true };
7531
7604
  for (const child of row.children) {
7532
- if (child.tagName === "BUTTON" && child.getAttribute(ROW_MENU_ATTR) === null) return child;
7605
+ if (child.tagName !== "DIV") continue;
7606
+ const rowButton = directRowButton(child);
7607
+ if (rowButton === null) continue;
7608
+ let mount = null;
7609
+ for (const inner of child.children) {
7610
+ if (inner.tagName === "DIV" && hasClass(inner, ROW_MENU_ACCESSORY_CLASS)) { mount = inner; break; }
7611
+ }
7612
+ return { rowButton, mount: mount !== null ? mount : child, legacy: false };
7533
7613
  }
7534
7614
  return null;
7535
7615
  }
7536
7616
 
7617
+ /**
7618
+ * The one ⋯ button we own in this row, in either shape. Looking one level
7619
+ * deeper (div.row, then div.accessory) is what keeps a row at exactly one ⋯
7620
+ * while the host re-renders or the shape changes under us.
7621
+ */
7537
7622
  function directMenuButton(row) {
7538
7623
  for (const child of row.children) {
7539
- if (child.getAttribute(ROW_MENU_ATTR) !== null) return child;
7624
+ if (isMenuButton(child)) return child;
7625
+ if (child.tagName !== "DIV") continue;
7626
+ for (const inner of child.children) {
7627
+ if (isMenuButton(inner)) return inner;
7628
+ if (!hasClass(inner, ROW_MENU_ACCESSORY_CLASS)) continue;
7629
+ for (const deep of inner.children) if (isMenuButton(deep)) return deep;
7630
+ }
7540
7631
  }
7541
7632
  return null;
7542
7633
  }
7543
7634
 
7635
+ /**
7636
+ * Legacy shape only: the ⋯ floats, so the row must become its positioning
7637
+ * parent and the host button must give it room. A style is recorded only at
7638
+ * the moment it really changes, so restoreStyles() never writes back a value
7639
+ * it did not set. The current shape calls neither — its accessory slot is
7640
+ * already the host's own layout space.
7641
+ */
7544
7642
  function ensureStyles(row, rowButton) {
7545
- if (!savedRowPositions.has(row)) savedRowPositions.set(row, row.style.position);
7546
- try {
7547
- if (window.getComputedStyle(row).position === "static") row.style.position = "relative";
7548
- } catch { row.style.position = "relative"; }
7549
- if (!savedButtonPadding.has(rowButton)) savedButtonPadding.set(rowButton, rowButton.style.paddingRight);
7550
- if (rowButton.style.paddingRight !== ROW_MENU_BUTTON_PADDING) rowButton.style.paddingRight = ROW_MENU_BUTTON_PADDING;
7643
+ let position = "static";
7644
+ try { position = window.getComputedStyle(row).position; } catch { position = "static"; }
7645
+ if (position === "static") {
7646
+ if (!savedRowPositions.has(row)) savedRowPositions.set(row, row.style.position);
7647
+ if (row.style.position !== "relative") row.style.position = "relative";
7648
+ }
7649
+ if (rowButton.style.paddingRight !== ROW_MENU_BUTTON_PADDING) {
7650
+ if (!savedButtonPadding.has(rowButton)) savedButtonPadding.set(rowButton, rowButton.style.paddingRight);
7651
+ rowButton.style.paddingRight = ROW_MENU_BUTTON_PADDING;
7652
+ }
7551
7653
  }
7552
7654
 
7553
7655
  function pruneDetachedStyles() {
@@ -7666,15 +7768,22 @@ window.__ModuleLoader__.load({
7666
7768
  for (const row of rows) {
7667
7769
  const path = row.getAttribute(ROW_MENU_PATH_ATTR);
7668
7770
  if (typeof path !== "string" || path === "") { missed++; continue; }
7669
- const rowButton = directRowButton(row);
7670
- if (rowButton === null) { missed++; continue; }
7771
+ const parts = rowParts(row);
7772
+ if (parts === null) { missed++; continue; }
7671
7773
  const isDirectory = row.getAttribute("data-files-entry") === "directory";
7672
- ensureStyles(row, rowButton);
7673
7774
  let button = directMenuButton(row);
7674
- if (button === null) {
7675
- button = createRowButton();
7676
- rowButton.insertAdjacentElement("afterend", button);
7775
+ if (parts.legacy) {
7776
+ // Legacy shape: float the ⋯ over the row, and restyle only then.
7777
+ ensureStyles(row, parts.rowButton);
7778
+ if (button === null) button = createRowButton();
7779
+ if (button.parentNode !== parts.rowButton.parentNode) parts.rowButton.insertAdjacentElement("afterend", button);
7780
+ } else {
7781
+ // Current shape: the host already owns the right-hand slot, so nothing
7782
+ // is restyled — the ⋯ is one more inline item inside that slot.
7783
+ if (button === null) button = createRowButton();
7784
+ if (button.parentNode !== parts.mount) parts.mount.appendChild(button);
7677
7785
  }
7786
+ setInlineClass(button, !parts.legacy);
7678
7787
  const previousPath = button.getAttribute("data-lookout-path");
7679
7788
  if (previousPath !== path) {
7680
7789
  if (menuAnchor === button) closeMenu();
@@ -8031,18 +8140,49 @@ window.__ModuleLoader__.load({
8031
8140
 
8032
8141
  function downloadErrorText(status) {
8033
8142
  switch (status) {
8143
+ case 400: return translate("err400");
8034
8144
  case 401: return translate("err401");
8035
- case 413: return translate("err413");
8036
- case 404: return translate("err404");
8037
8145
  case 403: return translate("err403");
8146
+ case 404: return translate("err404");
8038
8147
  default: return translate("errOther", { status: status > 0 ? status : "?" });
8039
8148
  }
8040
8149
  }
8041
8150
 
8151
+ /** The current session id: sidebar marker first, persisted pointer second, "" if neither. */
8152
+ function currentSessionId() {
8153
+ try {
8154
+ const marker = document.querySelector("[" + ROW_MENU_SESSION_ATTR + "]");
8155
+ const attr = marker !== null && marker !== undefined ? marker.getAttribute(ROW_MENU_SESSION_ATTR) : null;
8156
+ if (typeof attr === "string" && attr !== "") return attr;
8157
+ } catch (error) { /* fall through to storage */ }
8158
+ try {
8159
+ const stored = window.localStorage.getItem(ROW_MENU_SESSION_KEY);
8160
+ if (typeof stored === "string" && stored !== "") return stored;
8161
+ } catch (error) { /* no storage: no session */ }
8162
+ return "";
8163
+ }
8164
+
8165
+ function downloadUrl(sessionId, path) {
8166
+ return ROW_MENU_DOWNLOAD_ROUTE
8167
+ + "?sessionId=" + encodeURIComponent(sessionId)
8168
+ + "&path=" + encodeURIComponent(path);
8169
+ }
8170
+
8171
+ /**
8172
+ * Download without ever holding the file: HEAD pre-flights the route, then a
8173
+ * plain <a download> lets the browser stream its own GET to disk. A failed
8174
+ * pre-flight never starts a download, it only explains why.
8175
+ */
8042
8176
  async function downloadRowFile(path) {
8177
+ const sessionId = currentSessionId();
8178
+ if (sessionId === "") {
8179
+ if (!disposed) showToast(translate("errSession"), false);
8180
+ return;
8181
+ }
8182
+ const url = downloadUrl(sessionId, path);
8043
8183
  let response = null;
8044
8184
  try {
8045
- response = await window.fetch("api/file?path=" + encodeURIComponent(path), { credentials: "same-origin" });
8185
+ response = await window.fetch(url, { method: "HEAD", credentials: "same-origin" });
8046
8186
  } catch (error) {
8047
8187
  if (!disposed) showToast(translate("errNetwork"), false);
8048
8188
  return;
@@ -8052,19 +8192,16 @@ window.__ModuleLoader__.load({
8052
8192
  if (!disposed) showToast(downloadErrorText(status), false);
8053
8193
  return;
8054
8194
  }
8195
+ if (disposed) return;
8055
8196
  try {
8056
- const blob = await response.blob();
8057
- if (disposed) return;
8058
- const objectUrl = window.URL.createObjectURL(blob);
8059
8197
  const link = document.createElement("a");
8060
- link.href = objectUrl;
8198
+ link.href = url;
8061
8199
  link.download = fileNameFor(path);
8062
8200
  link.rel = "noopener";
8063
8201
  link.style.display = "none";
8064
8202
  document.body.appendChild(link);
8065
8203
  link.click();
8066
8204
  link.remove();
8067
- window.setTimeout(() => { try { window.URL.revokeObjectURL(objectUrl); } catch { /* gone */ } }, 1000);
8068
8205
  } catch (error) {
8069
8206
  if (!disposed) showToast(translate("errNetwork"), false);
8070
8207
  }
@@ -0,0 +1,344 @@
1
+ /**
2
+ * dsh-lookout · 宿主侧流式下载路由
3
+ *
4
+ * 一条精确的 Fetch 路由 `GET|HEAD /api/lookout.download`,把**某个会话 cwd 之内**的
5
+ * 一个普通文件流式发给浏览器(内存恒定:createReadStream → Readable.toWeb → Response)。
6
+ *
7
+ * 三条硬约束(改代码时别动):
8
+ * 1. 路径逐段必须匹配 [A-Za-z0-9_$.-]+,且宿主强制 `/api/` 前缀——所以是
9
+ * `/api/lookout.download` 这个字面量,不能把参数拼进路径。
10
+ * 2. **不要把 connection/fs 塞进 lib/index.js 顶层的 inject 数组**:那会让没有 web 载体的
11
+ * 组合(CLI)一直等不到服务、卡死插件加载。这里用 ctx.inject 局部等待。
12
+ * 3. 放行判定是"词法归一化 + realpath"两道:只有词法那道挡不住符号链接逃逸。
13
+ *
14
+ * 错误码:缺参/非绝对路径 400;越界或非普通文件 403;不存在 404;其它 500(只 warn 一次,不吐堆栈)。
15
+ */
16
+ import { createReadStream, fstat } from 'node:fs'
17
+ import { realpath } from 'node:fs/promises'
18
+ import { Readable } from 'node:stream'
19
+ import { basename, isAbsolute, normalize, sep } from 'node:path'
20
+
21
+ /** 路由路径:宿主强制 /api/ 前缀,且每段要过 [A-Za-z0-9_$.-]+ 校验。 */
22
+ export const DOWNLOAD_ROUTE_PATH = '/api/lookout.download'
23
+
24
+ /** 同一路由路径的短名(老调用方只认这个名字)。 */
25
+ export const DOWNLOAD_ROUTE = DOWNLOAD_ROUTE_PATH
26
+
27
+ /** 认不出扩展名时一律给这个,避免浏览器瞎猜。 */
28
+ const DEFAULT_CONTENT_TYPE = 'application/octet-stream'
29
+
30
+ /**
31
+ * 最小扩展名映射。只有一张表,不做内容嗅探:下载场景里"猜错"比"给 stream"更糟。
32
+ * @type {Record<string, string>}
33
+ */
34
+ const CONTENT_TYPES = {
35
+ '.txt': 'text/plain',
36
+ '.md': 'text/markdown',
37
+ '.json': 'application/json',
38
+ '.csv': 'text/csv',
39
+ '.html': 'text/html',
40
+ '.css': 'text/css',
41
+ '.js': 'text/javascript',
42
+ '.mjs': 'text/javascript',
43
+ '.xml': 'application/xml',
44
+ '.yaml': 'text/yaml',
45
+ '.yml': 'text/yaml',
46
+ '.png': 'image/png',
47
+ '.jpg': 'image/jpeg',
48
+ '.jpeg': 'image/jpeg',
49
+ '.gif': 'image/gif',
50
+ '.webp': 'image/webp',
51
+ '.svg': 'image/svg+xml',
52
+ '.pdf': 'application/pdf',
53
+ '.zip': 'application/zip',
54
+ '.gz': 'application/gzip',
55
+ '.mp3': 'audio/mpeg',
56
+ '.wav': 'audio/wav',
57
+ '.mp4': 'video/mp4',
58
+ }
59
+
60
+ /**
61
+ * 按扩展名给出 content-type。
62
+ * @param {string} fileName 文件名(大小写不敏感)
63
+ * @returns {string} MIME 类型;认不出给 application/octet-stream
64
+ */
65
+ export function contentTypeOf(fileName) {
66
+ const dot = fileName.lastIndexOf('.')
67
+ if (dot <= 0) return DEFAULT_CONTENT_TYPE
68
+ return CONTENT_TYPES[fileName.slice(dot).toLowerCase()] ?? DEFAULT_CONTENT_TYPE
69
+ }
70
+
71
+ /**
72
+ * content-disposition 里 filename="..." 那份的 ASCII 兜底:
73
+ * 非可见 ASCII 与引号/反斜杠一律换成下划线,空名字退回 download。
74
+ * @param {string} fileName 原始文件名
75
+ * @returns {string} 纯 ASCII 文件名
76
+ */
77
+ export function asciiFileName(fileName) {
78
+ const cleaned = String(fileName).replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_').trim()
79
+ return cleaned.length === 0 ? 'download' : cleaned
80
+ }
81
+
82
+ /**
83
+ * 拼完整的 content-disposition:ASCII 兜底 + RFC 5987 的 filename*(真名,中文不丢)。
84
+ * @param {string} fileName 原始文件名
85
+ * @returns {string} 头值
86
+ */
87
+ export function contentDisposition(fileName) {
88
+ const name = String(fileName ?? '')
89
+ return `attachment; filename="${asciiFileName(name)}"; filename*=UTF-8''${encodeURIComponent(name || 'download')}`
90
+ }
91
+
92
+ /**
93
+ * 判定 candidate 是否落在 root 之内(root 自身算在内)。两边都应是已归一化的绝对路径。
94
+ * @param {string} root 边界目录
95
+ * @param {string} candidate 待判定路径
96
+ * @returns {boolean} 是否在范围内
97
+ */
98
+ export function withinRoot(root, candidate) {
99
+ if (candidate === root) return true
100
+ const base = root.endsWith(sep) ? root : root + sep
101
+ return candidate.startsWith(base)
102
+ }
103
+
104
+ /**
105
+ * 读一个宿主服务:先用 Reflect.get(普通对象与 ctx 都吃),再退回 ctx.get。
106
+ * @param {object} ctx 上下文(真实 ctx、inject 回调里的 scoped,或 {fs, sessions} 这类直给对象)
107
+ * @param {string} name 服务名
108
+ * @returns {any} 服务实例或 undefined
109
+ */
110
+ function serviceOf(ctx, name) {
111
+ const direct = Reflect.get(ctx, name)
112
+ if (direct !== undefined && direct !== null) return direct
113
+ return typeof ctx?.get === 'function' ? ctx.get(name) : undefined
114
+ }
115
+
116
+ /**
117
+ * 取会话 cwd。字段名现场确认过:dsh-session 的 Session 上是 `session.header.cwd`
118
+ * (SessionHeader.cwd,创建时校验必须是绝对路径字符串);`meta.cwd` / `cwd` 只作兜底。
119
+ * @param {object|undefined} session ctx.sessions.get() 的返回值
120
+ * @returns {string|undefined} 绝对路径 cwd
121
+ */
122
+ export function sessionCwd(session) {
123
+ if (session === null || typeof session !== 'object') return undefined
124
+ const candidates = [session.header?.cwd, session.meta?.cwd, session.cwd]
125
+ for (const value of candidates) if (typeof value === 'string' && value.length > 0) return value
126
+ return undefined
127
+ }
128
+
129
+ /**
130
+ * 取某个会话的工作区目录(下载的边界)。先 get,拿不到再在 list 里按 id 匹配一次。
131
+ * @param {object|undefined} sessions ctx.sessions
132
+ * @param {string} sessionId 会话 id
133
+ * @returns {string|null} 绝对路径,取不到给 null
134
+ */
135
+ export function sessionWorkspace(sessions, sessionId) {
136
+ if (sessions === null || typeof sessions !== 'object') return null
137
+ if (typeof sessions.get === 'function') {
138
+ const cwd = sessionCwd(sessions.get(sessionId))
139
+ if (cwd !== undefined) return cwd
140
+ }
141
+ if (typeof sessions.list === 'function') {
142
+ for (const session of sessions.list() ?? []) {
143
+ if (session?.header?.id === sessionId || session?.id === sessionId) {
144
+ const cwd = sessionCwd(session)
145
+ if (cwd !== undefined) return cwd
146
+ }
147
+ }
148
+ }
149
+ return null
150
+ }
151
+
152
+ /**
153
+ * realpath 的"宽容版":路径不存在/无权限时原样返回,让后面的 stat 去定 404。
154
+ * @param {string} path 绝对路径
155
+ * @returns {Promise<string>} realpath,或原路径
156
+ */
157
+ async function realPathOrSelf(path) {
158
+ try {
159
+ return await realpath(path)
160
+ } catch {
161
+ return path
162
+ }
163
+ }
164
+
165
+ /**
166
+ * 统一的错误响应:纯文本 + 禁缓存。
167
+ * @param {number} status HTTP 状态码
168
+ * @param {string} message 一行说明(不吐内部细节)
169
+ * @returns {Response} 响应
170
+ */
171
+ function errorResponse(status, message) {
172
+ return new Response(message, {
173
+ status,
174
+ headers: {
175
+ 'content-type': 'text/plain; charset=utf-8',
176
+ 'cache-control': 'private, no-store',
177
+ },
178
+ })
179
+ }
180
+
181
+ /**
182
+ * 把 fs 层的失败翻译成状态码:FS_NOT_FOUND / ENOENT 当"不存在",其它交给外层 500。
183
+ * @param {unknown} error 抛出的错误
184
+ * @returns {boolean} 是否属于"不存在"
185
+ */
186
+ function isNotFound(error) {
187
+ const code = error !== null && typeof error === 'object' ? error.code : undefined
188
+ return code === 'FS_NOT_FOUND' || code === 'ENOENT'
189
+ }
190
+
191
+ /**
192
+ * 造一个下载 handler。
193
+ *
194
+ * deps 只为一件事存在:单测要数"到底有没有真去读文件"。生产路径不传,用 node:fs 的真身。
195
+ * @param {object} ctx 上下文(真实 ctx / inject 回调里的 scoped / {fs, sessions} 直给对象)
196
+ * @param {{createReadStream?: typeof createReadStream}} [deps] 可替换的底层流工厂
197
+ * @returns {(request: Request) => Promise<Response>} Fetch handler
198
+ */
199
+ export function createDownloadHandler(ctx, deps = {}) {
200
+ const openStream = typeof deps.createReadStream === 'function' ? deps.createReadStream : createReadStream
201
+
202
+ return async function handleDownload(request) {
203
+ try {
204
+ const signal = request.signal
205
+ const params = new URL(request.url).searchParams
206
+ const sessionId = params.get('sessionId')
207
+ const requestedPath = params.get('path')
208
+
209
+ // —— 400:参数这一关先过,不合法就不碰文件系统 ——
210
+ if (sessionId === null || sessionId.length === 0) return errorResponse(400, 'missing sessionId')
211
+ if (requestedPath === null || requestedPath.length === 0) return errorResponse(400, 'missing path')
212
+ if (!isAbsolute(requestedPath)) return errorResponse(400, 'path must be absolute')
213
+ // 尾随分隔符是"目录写法":按"没有这个文件"处理,别让它走到 stat 再变成 500。
214
+ if (/[\\/]$/.test(requestedPath)) return errorResponse(404, 'file not found')
215
+
216
+ const sessions = serviceOf(ctx, 'sessions')
217
+ if (sessions === undefined) return errorResponse(500, 'download unavailable: missing sessions service')
218
+
219
+ // —— 403:会话或它的 cwd 拿不到,就没有边界可谈 ——
220
+ const cwd = sessionWorkspace(sessions, sessionId)
221
+ if (cwd === null) return errorResponse(403, 'session cwd unavailable')
222
+
223
+ // —— 第一道:词法归一化(挡 .. 这类纯文本穿越) ——
224
+ const root = normalize(cwd)
225
+ const candidate = normalize(requestedPath)
226
+ if (!withinRoot(root, candidate)) return errorResponse(403, 'path outside session cwd')
227
+
228
+ const fs = serviceOf(ctx, 'fs')
229
+ if (typeof fs?.resolve !== 'function' || typeof fs?.stat !== 'function' || typeof fs?.processPath !== 'function') {
230
+ return errorResponse(500, 'download unavailable: missing fs service')
231
+ }
232
+
233
+ let target
234
+ let rootTarget
235
+ try {
236
+ target = await fs.resolve(candidate, { signal })
237
+ rootTarget = await fs.resolve(root, { signal })
238
+ } catch (error) {
239
+ if (isNotFound(error)) return errorResponse(404, 'file not found')
240
+ throw error
241
+ }
242
+
243
+ // —— 第二道:realpath 之后仍在范围内(挡符号链接逃逸) ——
244
+ // 双保险:既信后端的 processPath(本地后端本身就是 realpath 结果),
245
+ // 也自己 realpath 一次,后端换成非 realpath 的实现时这道闸照样有效。
246
+ const realRoot = String(fs.processPath(rootTarget))
247
+ const hostPath = String(fs.processPath(target))
248
+ const [realRootPath, realTargetPath] = await Promise.all([realPathOrSelf(realRoot), realPathOrSelf(hostPath)])
249
+ if (!withinRoot(realRootPath, realTargetPath)) return errorResponse(403, 'path outside session cwd after realpath')
250
+
251
+ let info
252
+ try {
253
+ info = await fs.stat(target, signal)
254
+ } catch (error) {
255
+ if (isNotFound(error) || error?.code === 'ENOTDIR' || error?.code === 'EISDIR') return errorResponse(404, 'file not found')
256
+ throw error
257
+ }
258
+ if (info === undefined) return errorResponse(404, 'file not found')
259
+ if (info.type !== 'file') return errorResponse(403, 'not a regular file')
260
+
261
+ const fileName = basename(candidate)
262
+ const headers = {
263
+ 'content-type': contentTypeOf(fileName),
264
+ 'content-disposition': contentDisposition(fileName),
265
+ 'cache-control': 'private, no-store',
266
+ }
267
+ // 后端不保证给 size;缺了就不写 content-length(宁可不写,也不写一个假的 0)。
268
+ let declaredSize = 0
269
+ if (typeof info.size === 'number' && Number.isFinite(info.size) && info.size >= 0) {
270
+ declaredSize = info.size
271
+ headers['content-length'] = String(info.size)
272
+ }
273
+
274
+ // HEAD 只要头:同样的 status + headers,body 必须为空,且**不打开文件**。
275
+ if (request.method === 'HEAD') return new Response(null, { status: 200, headers })
276
+
277
+ const stream = openStream(realTargetPath)
278
+ // 打开后用同一个 fd 复核一次大小:stat 与 open 之间被换掉的极端情况,
279
+ // 宁可断流也不要发出一份长度对不上的响应。
280
+ stream.once('open', (fd) => {
281
+ if (declaredSize === 0) return
282
+ try {
283
+ fstat(fd, (error, stats) => {
284
+ if (error === null && stats !== undefined && stats.size !== declaredSize) stream.destroy(new Error('size changed while downloading'))
285
+ })
286
+ } catch { /* best effort */ }
287
+ })
288
+ stream.on('error', (error) => {
289
+ const code = error?.code ?? ''
290
+ // 客户端主动取消/连接被掐断是常态,不该刷日志。
291
+ if (code === 'ERR_STREAM_PREMATURE_CLOSE' || code === 'ECONNRESET' || code === 'EPIPE' || error?.name === 'AbortError') return
292
+ console.warn('[dsh-lookout] download stream failed:', code !== '' ? code : (error?.message ?? error))
293
+ })
294
+ return new Response(Readable.toWeb(stream), { status: 200, headers })
295
+ } catch (error) {
296
+ // 500 只说一句,不带堆栈:日志里也不要整段内部路径。
297
+ console.warn('[dsh-lookout] download route failed:', error?.message ?? error)
298
+ return errorResponse(500, 'download failed')
299
+ }
300
+ }
301
+ }
302
+
303
+ /**
304
+ * 把下载路由挂到 connection 上。
305
+ *
306
+ * 用 ctx.inject 局部等 connection/fs/sessions:缺任何一个就不注册,而不是让整个插件
307
+ * 卡在加载阶段(那正是不能把它们塞进顶层 inject 的原因)。
308
+ * @param {object} ctx 插件上下文
309
+ * @param {{createReadStream?: typeof createReadStream}} [deps] 仅单测使用
310
+ * @returns {void}
311
+ */
312
+ const REGISTERED_CONNECTIONS = new WeakSet()
313
+
314
+ export function registerDownloadRoute(ctx, deps = {}) {
315
+ const attach = (scoped) => {
316
+ const connection = serviceOf(scoped, 'connection')
317
+ if (typeof connection?.fetch?.register !== 'function') return
318
+ // 同一个 connection 只挂一次:宿主对重复路径会直接抛错,抛在 apply 里会带崩整个插件。
319
+ if (REGISTERED_CONNECTIONS.has(connection)) return
320
+ const route = {
321
+ path: DOWNLOAD_ROUTE_PATH,
322
+ methods: ['GET', 'HEAD'],
323
+ requestBody: 'buffered',
324
+ fetch: createDownloadHandler(scoped, deps),
325
+ }
326
+ const register = () => {
327
+ if (REGISTERED_CONNECTIONS.has(connection)) return
328
+ try {
329
+ const dispose = connection.fetch.register(route)
330
+ REGISTERED_CONNECTIONS.add(connection)
331
+ return dispose
332
+ } catch (error) {
333
+ // 已经被别人占了同名路径(或宿主换了校验):记一条,不拖累插件加载。
334
+ console.warn('[dsh-lookout] download route not registered:', error?.code ?? error?.message ?? error)
335
+ return undefined
336
+ }
337
+ }
338
+ // 包一层 effect:插件卸载时路由跟着撤掉。
339
+ if (typeof scoped?.effect === 'function') scoped.effect(register, `dsh-lookout: ${DOWNLOAD_ROUTE_PATH}`)
340
+ else register()
341
+ }
342
+ if (typeof ctx?.inject === 'function') ctx.inject(['connection', 'fs', 'sessions'], attach)
343
+ else attach(ctx)
344
+ }
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.1",
4
+ "version": "0.5.3",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",