@cowwo/dsh-lookout 0.5.3 → 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 +54 -23
- package/lib/download.js +606 -107
- package/package.json +1 -1
package/client/client.js
CHANGED
|
@@ -7293,8 +7293,8 @@ 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
|
|
7297
|
-
const
|
|
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
7298
|
/** Where the current session id is read from, sidebar marker first, storage second. */
|
|
7299
7299
|
const ROW_MENU_SESSION_ATTR = "data-sidebar-right-session";
|
|
7300
7300
|
const ROW_MENU_SESSION_KEY = "dsh.sessions.current";
|
|
@@ -7331,6 +7331,7 @@ window.__ModuleLoader__.load({
|
|
|
7331
7331
|
err401: "登录已过期,请刷新页面重新登录",
|
|
7332
7332
|
err404: "文件不存在或已被删除",
|
|
7333
7333
|
err403: "只能下载当前会话工作区内的文件",
|
|
7334
|
+
err410: "下载链接已失效或被用尽,请重新点击下载",
|
|
7334
7335
|
errOther: "下载失败(HTTP {status})",
|
|
7335
7336
|
errNetwork: "下载失败,请检查连接",
|
|
7336
7337
|
errSession: "无法确定当前会话,已取消下载",
|
|
@@ -7350,6 +7351,7 @@ window.__ModuleLoader__.load({
|
|
|
7350
7351
|
err401: "Your session has expired. Refresh the page and sign in again.",
|
|
7351
7352
|
err404: "The file no longer exists or was deleted.",
|
|
7352
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.",
|
|
7353
7355
|
errOther: "Download failed (HTTP {status}).",
|
|
7354
7356
|
errNetwork: "Download failed — check your connection.",
|
|
7355
7357
|
errSession: "Cannot determine the current session — download cancelled.",
|
|
@@ -8144,6 +8146,7 @@ window.__ModuleLoader__.load({
|
|
|
8144
8146
|
case 401: return translate("err401");
|
|
8145
8147
|
case 403: return translate("err403");
|
|
8146
8148
|
case 404: return translate("err404");
|
|
8149
|
+
case 410: return translate("err410");
|
|
8147
8150
|
default: return translate("errOther", { status: status > 0 ? status : "?" });
|
|
8148
8151
|
}
|
|
8149
8152
|
}
|
|
@@ -8162,41 +8165,69 @@ window.__ModuleLoader__.load({
|
|
|
8162
8165
|
return "";
|
|
8163
8166
|
}
|
|
8164
8167
|
|
|
8165
|
-
function downloadUrl(sessionId, path) {
|
|
8166
|
-
return ROW_MENU_DOWNLOAD_ROUTE
|
|
8167
|
-
+ "?sessionId=" + encodeURIComponent(sessionId)
|
|
8168
|
-
+ "&path=" + encodeURIComponent(path);
|
|
8169
|
-
}
|
|
8170
|
-
|
|
8171
8168
|
/**
|
|
8172
|
-
*
|
|
8173
|
-
*
|
|
8174
|
-
*
|
|
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.
|
|
8175
8177
|
*/
|
|
8176
|
-
async function
|
|
8177
|
-
const sessionId = currentSessionId();
|
|
8178
|
-
if (sessionId === "") {
|
|
8179
|
-
if (!disposed) showToast(translate("errSession"), false);
|
|
8180
|
-
return;
|
|
8181
|
-
}
|
|
8182
|
-
const url = downloadUrl(sessionId, path);
|
|
8178
|
+
async function requestDownloadTicket(sessionId, path) {
|
|
8183
8179
|
let response = null;
|
|
8184
8180
|
try {
|
|
8185
|
-
response = await window.fetch(
|
|
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
|
+
});
|
|
8186
8187
|
} catch (error) {
|
|
8187
8188
|
if (!disposed) showToast(translate("errNetwork"), false);
|
|
8188
|
-
return;
|
|
8189
|
+
return null;
|
|
8189
8190
|
}
|
|
8190
8191
|
if (response === null || response.ok !== true) {
|
|
8191
8192
|
const status = response !== null && typeof response.status === "number" ? response.status : 0;
|
|
8192
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);
|
|
8193
8223
|
return;
|
|
8194
8224
|
}
|
|
8195
|
-
|
|
8225
|
+
const ticket = await requestDownloadTicket(sessionId, path);
|
|
8226
|
+
if (ticket === null || disposed) return;
|
|
8196
8227
|
try {
|
|
8197
8228
|
const link = document.createElement("a");
|
|
8198
|
-
link.href = url;
|
|
8199
|
-
link.download =
|
|
8229
|
+
link.href = ticket.url;
|
|
8230
|
+
link.download = ticket.filename;
|
|
8200
8231
|
link.rel = "noopener";
|
|
8201
8232
|
link.style.display = "none";
|
|
8202
8233
|
document.body.appendChild(link);
|
package/lib/download.js
CHANGED
|
@@ -1,29 +1,60 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-lookout ·
|
|
2
|
+
* dsh-lookout · 宿主侧票据下载路由
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* 两条精确的 Fetch 路由:
|
|
5
|
+
* POST /api/lookout.ticket 签发一次性票据(JSON in → JSON out)
|
|
6
|
+
* GET|HEAD /api/lookout.download?ticket=... 用票据换一个限时下载会话
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* 为什么要有票据这一层:老路由把 sessionId/path 直接放在 query 里,任何能发请求的
|
|
9
|
+
* 页面都能拼一条 URL 去读别人工作区里的文件。现在 URL 里只有一个不可猜的随机串,
|
|
10
|
+
* 服务端只存它的 SHA-256,并且第一次 GET 之后就把票据换成"限时 + 限次"的会话。
|
|
11
|
+
*
|
|
12
|
+
* 五条硬约束(改代码时别动):
|
|
13
|
+
* 1. 路径逐段必须匹配 [A-Za-z0-9_$.-]+,且宿主强制 /api/ 前缀——所以是
|
|
14
|
+
* '/api/lookout.download' 这个字面量,不能把参数拼进路径。
|
|
10
15
|
* 2. **不要把 connection/fs 塞进 lib/index.js 顶层的 inject 数组**:那会让没有 web 载体的
|
|
11
16
|
* 组合(CLI)一直等不到服务、卡死插件加载。这里用 ctx.inject 局部等待。
|
|
12
17
|
* 3. 放行判定是"词法归一化 + realpath"两道:只有词法那道挡不住符号链接逃逸。
|
|
18
|
+
* 4. 票据只在服务端以 SHA-256 十六进制形式存在,日志里一个字都不许出现。
|
|
19
|
+
* 5. 清理定时器必须 unref,disposer 必须把它清掉,否则插件卸载后进程退不出去。
|
|
13
20
|
*
|
|
14
|
-
*
|
|
21
|
+
* 错误码:
|
|
22
|
+
* 签发:缺参/非绝对路径/JSON 坏掉 400;越界或非普通文件 403;不存在 404;其它 500。
|
|
23
|
+
* 下载:缺 ticket 400;查不到(过期/不存在/已用尽,一律不区分)410;其它同上。
|
|
15
24
|
*/
|
|
25
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
16
26
|
import { createReadStream, fstat } from 'node:fs'
|
|
17
27
|
import { realpath } from 'node:fs/promises'
|
|
18
28
|
import { Readable } from 'node:stream'
|
|
19
29
|
import { basename, isAbsolute, normalize, sep } from 'node:path'
|
|
20
30
|
|
|
21
|
-
/**
|
|
31
|
+
/** 下载路由路径:宿主强制 /api/ 前缀,且每段要过 [A-Za-z0-9_$.-]+ 校验。 */
|
|
22
32
|
export const DOWNLOAD_ROUTE_PATH = '/api/lookout.download'
|
|
23
33
|
|
|
24
34
|
/** 同一路由路径的短名(老调用方只认这个名字)。 */
|
|
25
35
|
export const DOWNLOAD_ROUTE = DOWNLOAD_ROUTE_PATH
|
|
26
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
|
+
|
|
27
58
|
/** 认不出扩展名时一律给这个,避免浏览器瞎猜。 */
|
|
28
59
|
const DEFAULT_CONTENT_TYPE = 'application/octet-stream'
|
|
29
60
|
|
|
@@ -86,7 +117,7 @@ export function asciiFileName(fileName) {
|
|
|
86
117
|
*/
|
|
87
118
|
export function contentDisposition(fileName) {
|
|
88
119
|
const name = String(fileName ?? '')
|
|
89
|
-
return
|
|
120
|
+
return 'attachment; filename="' + asciiFileName(name) + '"; filename*=UTF-8\'\'' + encodeURIComponent(name || 'download')
|
|
90
121
|
}
|
|
91
122
|
|
|
92
123
|
/**
|
|
@@ -114,8 +145,8 @@ function serviceOf(ctx, name) {
|
|
|
114
145
|
}
|
|
115
146
|
|
|
116
147
|
/**
|
|
117
|
-
* 取会话 cwd。字段名现场确认过:dsh-session 的 Session 上是
|
|
118
|
-
* (SessionHeader.cwd
|
|
148
|
+
* 取会话 cwd。字段名现场确认过:dsh-session 的 Session 上是 session.header.cwd
|
|
149
|
+
* (SessionHeader.cwd,创建时校验必须是绝对路径字符串);meta.cwd / cwd 只作兜底。
|
|
119
150
|
* @param {object|undefined} session ctx.sessions.get() 的返回值
|
|
120
151
|
* @returns {string|undefined} 绝对路径 cwd
|
|
121
152
|
*/
|
|
@@ -163,18 +194,42 @@ async function realPathOrSelf(path) {
|
|
|
163
194
|
}
|
|
164
195
|
|
|
165
196
|
/**
|
|
166
|
-
*
|
|
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
|
+
* 统一的错误响应:纯文本 + 安全头(不吐内部细节)。
|
|
167
212
|
* @param {number} status HTTP 状态码
|
|
168
|
-
* @param {string} message
|
|
213
|
+
* @param {string} message 一行说明
|
|
169
214
|
* @returns {Response} 响应
|
|
170
215
|
*/
|
|
171
216
|
function errorResponse(status, message) {
|
|
172
217
|
return new Response(message, {
|
|
173
218
|
status,
|
|
174
|
-
headers: {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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' }),
|
|
178
233
|
})
|
|
179
234
|
}
|
|
180
235
|
|
|
@@ -189,108 +244,517 @@ function isNotFound(error) {
|
|
|
189
244
|
}
|
|
190
245
|
|
|
191
246
|
/**
|
|
192
|
-
*
|
|
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(挡符号链接逃逸)。
|
|
193
530
|
*
|
|
194
|
-
*
|
|
195
|
-
* @param {
|
|
196
|
-
* @
|
|
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 供测试注入)
|
|
197
626
|
* @returns {(request: Request) => Promise<Response>} Fetch handler
|
|
198
627
|
*/
|
|
199
|
-
export function
|
|
200
|
-
const
|
|
628
|
+
export function createTicketHandler(ctx, deps = {}) {
|
|
629
|
+
const store = deps.store ?? createTicketStore({ now: deps.now })
|
|
630
|
+
const log = createEventLogger(deps)
|
|
201
631
|
|
|
202
|
-
return async function
|
|
632
|
+
return async function handleTicket(request) {
|
|
203
633
|
try {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
+
}
|
|
208
643
|
|
|
644
|
+
const sessionId = payload.sessionId
|
|
645
|
+
const requestedPath = payload.path
|
|
209
646
|
// —— 400:参数这一关先过,不合法就不碰文件系统 ——
|
|
210
|
-
if (sessionId
|
|
211
|
-
if (requestedPath
|
|
212
|
-
if (!isAbsolute(requestedPath)) return
|
|
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' })
|
|
213
650
|
// 尾随分隔符是"目录写法":按"没有这个文件"处理,别让它走到 stat 再变成 500。
|
|
214
|
-
if (/[\\/]$/.test(requestedPath)) return
|
|
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
|
|
215
655
|
|
|
216
|
-
|
|
217
|
-
if (
|
|
656
|
+
// Range 需要确切大小,签票阶段就定死;拿不到就别签,免得后面算错区间。
|
|
657
|
+
if (typeof resolved.size !== 'number') return jsonResponse(500, { error: 'file size unavailable' })
|
|
218
658
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
+
}
|
|
222
679
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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)
|
|
227
694
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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')
|
|
232
701
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
}
|
|
702
|
+
// 先只看不动:票据不存在/过期/用尽一律 410,且不区分原因(防枚举)。
|
|
703
|
+
const peeked = store.peek(token)
|
|
704
|
+
if (peeked === null) return errorResponse(410, 'ticket gone')
|
|
242
705
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
if (!withinRoot(realRootPath, realTargetPath)) return errorResponse(403, 'path outside session cwd after realpath')
|
|
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
|
|
250
712
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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)
|
|
257
722
|
}
|
|
258
|
-
if (info === undefined) return errorResponse(404, 'file not found')
|
|
259
|
-
if (info.type !== 'file') return errorResponse(403, 'not a regular file')
|
|
260
723
|
|
|
261
|
-
const fileName =
|
|
262
|
-
const
|
|
724
|
+
const fileName = resolved.fileName.length > 0 ? resolved.fileName : (entry.filename ?? 'download')
|
|
725
|
+
const size = resolved.size
|
|
726
|
+
const headers = securityHeaders({
|
|
263
727
|
'content-type': contentTypeOf(fileName),
|
|
264
728
|
'content-disposition': contentDisposition(fileName),
|
|
265
|
-
'
|
|
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
|
+
})
|
|
266
742
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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 })
|
|
272
751
|
}
|
|
273
752
|
|
|
274
|
-
//
|
|
753
|
+
// 无 Range:全文件 200(保持现状);size 未知就不写 content-length。
|
|
754
|
+
if (typeof size === 'number') headers['content-length'] = String(size)
|
|
275
755
|
if (request.method === 'HEAD') return new Response(null, { status: 200, headers })
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
})
|
|
756
|
+
const stream = openStream(resolved.hostPath)
|
|
757
|
+
guardStream(stream, size)
|
|
294
758
|
return new Response(Readable.toWeb(stream), { status: 200, headers })
|
|
295
759
|
} catch (error) {
|
|
296
760
|
// 500 只说一句,不带堆栈:日志里也不要整段内部路径。
|
|
@@ -300,43 +764,78 @@ export function createDownloadHandler(ctx, deps = {}) {
|
|
|
300
764
|
}
|
|
301
765
|
}
|
|
302
766
|
|
|
767
|
+
// ───────────────────────── 注册 ─────────────────────────
|
|
768
|
+
|
|
769
|
+
/** 同一个 connection 只挂一次:宿主对重复路径会直接抛错。 */
|
|
770
|
+
const REGISTERED_CONNECTIONS = new WeakSet()
|
|
771
|
+
|
|
303
772
|
/**
|
|
304
|
-
*
|
|
773
|
+
* 把签发 + 下载两条路由挂到 connection 上(同一个 effect 里,卸载时一并撤销)。
|
|
305
774
|
*
|
|
306
775
|
* 用 ctx.inject 局部等 connection/fs/sessions:缺任何一个就不注册,而不是让整个插件
|
|
307
776
|
* 卡在加载阶段(那正是不能把它们塞进顶层 inject 的原因)。
|
|
777
|
+
*
|
|
308
778
|
* @param {object} ctx 插件上下文
|
|
309
|
-
* @param {{createReadStream?: typeof createReadStream}} [deps] 仅单测使用
|
|
779
|
+
* @param {{createReadStream?: typeof createReadStream, store?: object, now?: () => number}} [deps] 仅单测使用
|
|
310
780
|
* @returns {void}
|
|
311
781
|
*/
|
|
312
|
-
const REGISTERED_CONNECTIONS = new WeakSet()
|
|
313
|
-
|
|
314
782
|
export function registerDownloadRoute(ctx, deps = {}) {
|
|
315
783
|
const attach = (scoped) => {
|
|
316
784
|
const connection = serviceOf(scoped, 'connection')
|
|
317
785
|
if (typeof connection?.fetch?.register !== 'function') return
|
|
318
786
|
// 同一个 connection 只挂一次:宿主对重复路径会直接抛错,抛在 apply 里会带崩整个插件。
|
|
319
787
|
if (REGISTERED_CONNECTIONS.has(connection)) return
|
|
320
|
-
|
|
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 = {
|
|
321
797
|
path: DOWNLOAD_ROUTE_PATH,
|
|
322
798
|
methods: ['GET', 'HEAD'],
|
|
323
799
|
requestBody: 'buffered',
|
|
324
|
-
fetch: createDownloadHandler(scoped, deps),
|
|
800
|
+
fetch: createDownloadHandler(scoped, { ...deps, store }),
|
|
325
801
|
}
|
|
802
|
+
|
|
326
803
|
const register = () => {
|
|
327
|
-
if (REGISTERED_CONNECTIONS.has(connection)) return
|
|
804
|
+
if (REGISTERED_CONNECTIONS.has(connection)) return undefined
|
|
805
|
+
const disposers = []
|
|
328
806
|
try {
|
|
329
|
-
|
|
807
|
+
disposers.push(connection.fetch.register(ticketRoute))
|
|
808
|
+
disposers.push(connection.fetch.register(downloadRoute))
|
|
330
809
|
REGISTERED_CONNECTIONS.add(connection)
|
|
331
|
-
return dispose
|
|
332
810
|
} catch (error) {
|
|
333
811
|
// 已经被别人占了同名路径(或宿主换了校验):记一条,不拖累插件加载。
|
|
334
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()
|
|
335
821
|
return undefined
|
|
336
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
|
+
}
|
|
337
835
|
}
|
|
338
|
-
|
|
339
|
-
|
|
836
|
+
|
|
837
|
+
// 包一层 effect:插件卸载时路由与定时器跟着撤掉。
|
|
838
|
+
if (typeof scoped?.effect === 'function') scoped.effect(register, 'dsh-lookout: ' + DOWNLOAD_ROUTE_PATH)
|
|
340
839
|
else register()
|
|
341
840
|
}
|
|
342
841
|
if (typeof ctx?.inject === 'function') ctx.inject(['connection', 'fs', 'sessions'], attach)
|
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.
|
|
4
|
+
"version": "0.5.4",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|