dsh-mobile-plugin 0.5.12 → 0.5.14

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.
@@ -17,6 +17,11 @@
17
17
  *
18
18
  * 顺序与幂等:chunk 只接受下一片,重复送达同一片按重传处理,跳号直接 409。
19
19
  *
20
+ * v3(`caps.wire === 'bin'`,见 docs/native-upload.md 的 v3 章):请求体换成裸密文,一片
21
+ * 按**字节偏移**收、允许乱序,靠一个按偏移排序的待拼窗口把连续的部分落盘。协商进去的 job
22
+ * 走这条路;老客户端没有 `caps`,仍走上面那套按 index 的口径 —— 两条路靠 job 上记的 wire
23
+ * 分流,不做「猜」。
24
+ *
20
25
  * 图片走两段:先按上面那套把字节传完拿 uploadId,`session/send` 再把 uploadId 换回内联
21
26
  * base64 交给宿主 prompt。换的时候整份字节要读进内存 —— 宿主只认内联块,这条通道没有流式
22
27
  * 入口。也正因为如此,单张图片的大小上限由桌面端的附件限制定,这里不设体积门槛。
@@ -30,7 +35,9 @@ const os = require('node:os');
30
35
  const path = require('node:path');
31
36
  const crypto = require('node:crypto');
32
37
 
33
- const { sendJson, readJsonBody } = require('../shared/http-util');
38
+ const { sendJson, readJsonBody, readRawBody } = require('../shared/http-util');
39
+ // 头名的大小写不保证(中转会压成小写,Node 版本之间也有差异),复用信封那一份读法。
40
+ const { headerOf } = require('../shared/e2ee-request');
34
41
  const { allowedRoots, inAllowedZone } = require('./allowed-zone.js');
35
42
 
36
43
  // 片大小的**起步值** 256KB(base64 后约 341KB):客户端一次请求的硬预算是 8s
@@ -46,6 +53,24 @@ const CHUNK_MAX_BYTES = 2 * 1024 * 1024;
46
53
  * 放大到 11MB —— 按线上长度直接卡 8MB 等于悄悄砍掉四分之一的可用额度。
47
54
  */
48
55
  const CHUNK_MAX_BODY = Math.ceil(8 * 1024 * 1024 * 4 / 3);
56
+ /**
57
+ * v3(二进制分片体)的片长:起步 4MB、天花板 6MB。
58
+ *
59
+ * 天花板的来历是**中转隧道单帧 8MB**:v3 一片的线上字节只是明文加 AEAD 的 12 字节 nonce
60
+ * 与 16 字节 tag(不 base64、不套 JSON),所以 6MB 明文装得进一帧还留出信封余量。v2 只能到
61
+ * 2MB 是因为同一份字节被 base64 了两遍(片一次、整条信封再一次),1.78 倍膨胀先把额度吃了。
62
+ */
63
+ const BIN_CHUNK_BYTES = 4 * 1024 * 1024;
64
+ const BIN_CHUNK_MAX_BYTES = 6 * 1024 * 1024;
65
+ /** 同时在飞的片数缺省值:再小就藏不掉高延迟链路上的一片空等。 */
66
+ const BIN_PIPELINE = 4;
67
+ /**
68
+ * 客户端给的 pipeline 最多认到 8:它是内存与窗口的乘数,没有理由跟着请求往上翻。
69
+ *
70
+ * 窗口的字节上限由它乘出来(片数 × 片长):窗口只需要装下「在飞的那几片」,再大等于给一个
71
+ * 发疯的客户端留一块几十 MB 的无主内存。乘出来的那个数就是 `begin` 回给客户端的那一格。
72
+ */
73
+ const BIN_PIPELINE_MAX = 8;
49
74
  /** 文件名上限(按字符算,避开各文件系统的字节上限差异)。 */
50
75
  const NAME_MAX_CHARS = 120;
51
76
  /**
@@ -96,7 +121,13 @@ function resolveFinishedUpload(uploadId) {
96
121
  return { path: record.path, name: record.name, bytes: record.bytes };
97
122
  }
98
123
 
99
- /** 进行中的上传:uploadId → { name, stagingPath, received, bytes, expiresAt, expiryTimer }。 */
124
+ /**
125
+ * 进行中的上传:uploadId → job。
126
+ *
127
+ * 两种 wire 共用这张表,靠 job 上的 `wire` 分流(`json` 是老行为,`bin` 是 v3):
128
+ * - json:`received` 是**片数**,chunk 只吃下一片。
129
+ * - bin:`receivedBytes` 是**已连续落盘的字节数**,片按偏移收,乱序的先进 `window`。
130
+ */
100
131
  const uploads = new Map();
101
132
 
102
133
  function removeUpload(uploadId, job) {
@@ -104,6 +135,12 @@ function removeUpload(uploadId, job) {
104
135
  uploads.delete(uploadId);
105
136
  if (job.expiryTimer) clearTimeout(job.expiryTimer);
106
137
  job.expiryTimer = null;
138
+ // 待拼窗口是纯内存的片缓冲:job 从 Map 摘掉之后还可能被定时器闭包抓着,不清的话一次
139
+ // 中断的大文件能让几十 MB 多活到进程退出(`unlink` 只管暂存文件,管不到这些 Buffer)。
140
+ if (job.window) {
141
+ job.window.length = 0;
142
+ job.pendingWindowBytes = 0;
143
+ }
107
144
  return true;
108
145
  }
109
146
 
@@ -268,12 +305,46 @@ async function resolveDestDir(ctx, body) {
268
305
  return { dir };
269
306
  }
270
307
 
271
- /** POST /api/mobile/v1/upload/begin { name, destPath?, sessionId? } → { ok, uploadId, chunkSize }。 */
308
+ /**
309
+ * begin 里的能力协商(v3)。
310
+ *
311
+ * **只有明确说了 `caps.wire === 'bin'` 才进 v3**:老 App 一个字段都不带,`caps` 为空,
312
+ * 于是它拿到的是和从前一模一样的响应,走的也是从前那条代码路径。
313
+ *
314
+ * v3 必须同时给出 `size`(总字节数):收口时要靠它证明「字节齐了」,没有它就没法区分
315
+ * 「一次传完」和「少了一片但客户端以为传完了」。
316
+ *
317
+ * @returns {{ wire: 'json' } | { wire: 'bin', pipeline: number, windowBytes: number, windowPieces: number, total: number }
318
+ * | { status: number, code: string, message: string }}
319
+ */
320
+ function resolveCaps(body) {
321
+ const caps = body.caps && typeof body.caps === 'object' ? body.caps : null;
322
+ if (!caps || caps.wire !== 'bin') return { wire: 'json' };
323
+ // 只认真的数字:`null` 会被 Number() 悄悄变成 0,那等于把「没给」当成「传了个空文件」。
324
+ const size = body.size;
325
+ if (typeof size !== 'number' || !Number.isSafeInteger(size) || size < 0) {
326
+ return { status: 400, code: 'bad-request', message: '二进制分片需要 { size }(总字节数)' };
327
+ }
328
+ const asked = Number(caps.pipeline);
329
+ const pipeline = Number.isInteger(asked) && asked > 0 ? Math.min(asked, BIN_PIPELINE_MAX) : BIN_PIPELINE;
330
+ return {
331
+ wire: 'bin',
332
+ pipeline,
333
+ windowBytes: pipeline * BIN_CHUNK_MAX_BYTES,
334
+ // 与字节上限并行的第二道封顶:一片只有几字节时,光靠字节数能塞进几千片。
335
+ windowPieces: Math.max(4, pipeline * 2),
336
+ total: size,
337
+ };
338
+ }
339
+
340
+ /** POST /api/mobile/v1/upload/begin { name, size?, destPath?, sessionId?, caps? } → { ok, uploadId, chunkSize, maxChunkSize, … }。 */
272
341
  async function handleUploadBegin(ctx, req, res) {
273
342
  const body = await readJsonBody(req);
274
343
  if (!body || typeof body.name !== 'string' || !body.name.trim()) {
275
344
  return fail(res, 400, 'bad-request', '需要 { name }');
276
345
  }
346
+ const caps = resolveCaps(body);
347
+ if (caps.status) return fail(res, caps.status, caps.code, caps.message);
277
348
  // 落点先判:越界 / 不是目录在这一步就回,不先建暂存再报错 —— 残片要躺满 7 天才清。
278
349
  const destination = await resolveDestDir(ctx, body);
279
350
  if (destination.status) return fail(res, destination.status, destination.code, destination.message);
@@ -298,24 +369,158 @@ async function handleUploadBegin(ctx, req, res) {
298
369
  // 指定落点就记在这里,finish 才用得上(中途不再判一次范围:begin 已经认过)。
299
370
  destDir: destination.dir,
300
371
  stagingPath,
372
+ // 老路径(json)的两个计数器;v3 另起 `receivedBytes`,两者语义不同,不混用同一格。
301
373
  received: 0,
302
374
  bytes: 0,
375
+ wire: caps.wire,
376
+ // v3 才有意义:偏移收片的账、待拼窗口与它的两块封顶。
377
+ receivedBytes: 0,
378
+ total: caps.wire === 'bin' ? caps.total : 0,
379
+ pipeline: caps.wire === 'bin' ? caps.pipeline : 0,
380
+ windowBytes: caps.wire === 'bin' ? caps.windowBytes : 0,
381
+ windowPieces: caps.wire === 'bin' ? caps.windowPieces : 0,
382
+ window: [],
383
+ pendingWindowBytes: 0,
303
384
  expiresAt: Date.now() + STAGING_TTL_MS,
304
385
  expiryTimer: null,
305
386
  };
306
387
  uploads.set(uploadId, job);
307
388
  scheduleUploadExpiry(uploadId, job);
308
389
  // chunkSize 是起步值、maxChunkSize 是天花板:客户端从前者开始按实测周期往上爬。
390
+ //
391
+ // 老客户端(没协商 caps)拿到的是**和从前逐字节相同**的那五个字段:多一个 `wire` 都会
392
+ // 让「不带 caps 就走 v2」这条承诺落空,所以 v3 的字段只在真协商成功时追加。
393
+ if (caps.wire === 'bin') {
394
+ return sendJson(res, 200, {
395
+ ok: true,
396
+ uploadId,
397
+ chunkSize: BIN_CHUNK_BYTES,
398
+ maxChunkSize: BIN_CHUNK_MAX_BYTES,
399
+ wire: 'bin',
400
+ pipeline: job.pipeline,
401
+ windowBytes: job.windowBytes,
402
+ });
403
+ }
309
404
  sendJson(res, 200, { ok: true, uploadId, chunkSize: CHUNK_BYTES, maxChunkSize: CHUNK_MAX_BYTES });
310
405
  }
311
406
 
407
+ /**
408
+ * 把一片按偏移接到暂存文件末尾,**只追加 `receivedBytes` 之后的那部分**。
409
+ *
410
+ * 「只追加尾巴」是为了容忍客户端切片的边界与上一片重叠:重叠字节已经在盘上了,再追加一遍
411
+ * 就把文件写坏(大文件里表现为某一段重复,且字节数悄悄变多)。返回写入字节数,0 表示这片
412
+ * 整段落在已收区间内。
413
+ */
414
+ async function appendPiece(job, offset, data) {
415
+ const skip = job.receivedBytes - offset;
416
+ if (skip >= data.length) return 0;
417
+ const tail = skip > 0 ? data.subarray(skip) : data;
418
+ await fsp.appendFile(job.stagingPath, tail);
419
+ job.receivedBytes += tail.length;
420
+ // finish 落盘报的 `bytes` 与 json 分支同一个字段:两条 wire 的收口代码因此只差那一句判据。
421
+ job.bytes = job.receivedBytes;
422
+ return tail.length;
423
+ }
424
+
425
+ /** 顺着窗口把接得上的后续片一并落盘(它们的偏移正好从 `receivedBytes` 开始)。 */
426
+ async function flushWindow(job) {
427
+ let wrote = false;
428
+ while (job.window.length) {
429
+ const next = job.window[0];
430
+ if (next.offset > job.receivedBytes) break;
431
+ job.window.shift();
432
+ job.pendingWindowBytes -= next.data.length;
433
+ if (await appendPiece(job, next.offset, next.data)) wrote = true;
434
+ }
435
+ return wrote;
436
+ }
437
+
438
+ /**
439
+ * v3 的一条二进制分片:`X-Dsh-Wire: bin` + `X-Dsh-Upload-Id` + `X-Dsh-Upload-Offset`,
440
+ * 请求体是这一片的裸密文(守卫已解成 `req.rawBody` 里的明文)。
441
+ *
442
+ * 偏移三分法(见 docs/native-upload.md 的「偏移与窗口」):
443
+ * - `offset < receivedBytes`:整片已收过 → 200 幂等;跨过边界 → 400 `bad-offset`。
444
+ * - `offset === receivedBytes`:追加,并顺着窗口把接得上的后续片一起落盘。
445
+ * - `offset > receivedBytes`:进窗口等前面补齐;窗口按字节数或积压片数封顶 → 409。
446
+ */
447
+ async function handleBinChunk(req, res) {
448
+ const uploadId = headerOf(req, 'x-dsh-upload-id').trim();
449
+ const job = uploads.get(uploadId);
450
+ if (!job) return fail(res, 404, 'unknown-upload', '上传会话不存在或已结束');
451
+ // 同一 job 中途换 wire 一律拒:两种 wire 的 `received` 一个数片、一个数字节,混着收
452
+ // 会拼出一个对不上账的暂存文件,而客户端还以为自己在续传。
453
+ if (job.wire !== 'bin') {
454
+ return fail(res, 400, 'wire-mismatch', '这个上传是按 JSON 分片协商的,不能混发二进制分片');
455
+ }
456
+
457
+ const offsetText = headerOf(req, 'x-dsh-upload-offset').trim();
458
+ const offset = Number(offsetText);
459
+ if (!/^\d+$/.test(offsetText) || !Number.isSafeInteger(offset)) {
460
+ return fail(res, 400, 'bad-offset', 'X-Dsh-Upload-Offset 必须是非负整数');
461
+ }
462
+ const piece = await readRawBody(req, BIN_CHUNK_MAX_BYTES);
463
+ if (!piece) return fail(res, 413, 'too-large', '分片过大');
464
+ if (!piece.length) return fail(res, 400, 'bad-offset', '分片不能是空的');
465
+
466
+ const end = offset + piece.length;
467
+ if (offset < job.receivedBytes) {
468
+ if (end <= job.receivedBytes) return sendJson(res, 200, { ok: true, received: job.receivedBytes });
469
+ return fail(res, 400, 'bad-offset', `这一片跨过了已收边界(已收 ${job.receivedBytes} 字节)`);
470
+ }
471
+
472
+ if (offset === job.receivedBytes) {
473
+ try {
474
+ await appendPiece(job, offset, piece);
475
+ await flushWindow(job);
476
+ } catch (error) {
477
+ removeUpload(uploadId, job);
478
+ return fail(res, 500, 'inbox-unavailable', `写入暂存文件失败:${error.message}`);
479
+ }
480
+ job.expiresAt = Date.now() + STAGING_TTL_MS;
481
+ scheduleUploadExpiry(uploadId, job);
482
+ return sendJson(res, 200, { ok: true, received: job.receivedBytes });
483
+ }
484
+
485
+ // 到这里就是乱序:这一片比已连续落盘的字节跑得靠后,先攒着。
486
+ const existing = job.window.find((entry) => entry.offset === offset);
487
+ const added = existing ? piece.length - existing.data.length : piece.length;
488
+ if (
489
+ job.pendingWindowBytes + added > job.windowBytes
490
+ || (!existing && job.window.length >= job.windowPieces)
491
+ ) {
492
+ return fail(res, 409, 'window-exceeded', `乱序窗口已满(${job.windowPieces} 片 / ${job.windowBytes} 字节),请退避后重发这一片`);
493
+ }
494
+ if (existing) {
495
+ // 同一偏移重发:以新的字节为准(客户端重传的是同一片,内容应当一样),不重复计数。
496
+ existing.data = piece;
497
+ } else {
498
+ // 保持按偏移升序:flushWindow 只认队首,插错位置会让窗口永远不前进。
499
+ const at = job.window.findIndex((entry) => entry.offset > offset);
500
+ const entry = { offset, data: piece };
501
+ if (at < 0) job.window.push(entry);
502
+ else job.window.splice(at, 0, entry);
503
+ }
504
+ job.pendingWindowBytes += added;
505
+ job.expiresAt = Date.now() + STAGING_TTL_MS;
506
+ scheduleUploadExpiry(uploadId, job);
507
+ return sendJson(res, 200, { ok: true, received: job.receivedBytes });
508
+ }
509
+
312
510
  /** POST /api/mobile/v1/upload/chunk { uploadId, index, data } → { ok, received }。 */
313
511
  async function handleUploadChunk(_ctx, req, res) {
512
+ // wire 在最前面分流:bin 的请求体是裸密文(守卫已解成 `req.rawBody`),拿它当 JSON 读
513
+ // 只会得到一个「畸形 JSON」的 413 —— 那是假象,不是真的太大。
514
+ if (headerOf(req, 'x-dsh-wire').trim().toLowerCase() === 'bin') return handleBinChunk(req, res);
515
+
314
516
  const body = await readJsonBody(req, CHUNK_MAX_BODY);
315
517
  if (!body) return fail(res, 413, 'too-large', '分片过大或不是合法 JSON');
316
518
 
317
519
  const job = uploads.get(String(body.uploadId == null ? '' : body.uploadId));
318
520
  if (!job) return fail(res, 404, 'unknown-upload', '上传会话不存在或已结束');
521
+ if (job.wire === 'bin') {
522
+ return fail(res, 400, 'wire-mismatch', '这个上传是按二进制分片协商的,不能混发 JSON 分片');
523
+ }
319
524
 
320
525
  const index = Number(body.index);
321
526
  if (!Number.isInteger(index) || index < 0) return fail(res, 400, 'bad-request', 'index 必须是非负整数');
@@ -358,6 +563,15 @@ async function handleUploadFinish(_ctx, req, res) {
358
563
  if (replay) return sendJson(res, 200, { ok: true, ...replay, uploadId });
359
564
  return fail(res, 404, 'unknown-upload', '上传会话不存在或已结束');
360
565
  }
566
+
567
+ // v3 的收口必须先证明字节齐了:分片可以乱序、可以重发,少一片时落盘出来的文件是残缺的,
568
+ // 而文件名照样对、客户端照样以为传完了。**job 不删** —— 客户端补上缺的那几片后还能再收一次口。
569
+ if (job.wire === 'bin' && job.receivedBytes !== job.total) {
570
+ return sendJson(res, 400, {
571
+ ok: false,
572
+ error: { code: 'incomplete', message: `上传还没收齐(已收 ${job.receivedBytes} / ${job.total} 字节)`, received: job.receivedBytes },
573
+ });
574
+ }
361
575
  removeUpload(uploadId, job);
362
576
 
363
577
  const dir = job.destDir || path.join(inboxRoot(), today());