dsh-mobile-plugin 0.5.12 → 0.5.13
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/lib/mobile/uploads.js +217 -3
- package/lib/shared/e2ee-request.js +39 -0
- package/lib/shared/http-util.js +56 -1
- package/package.json +1 -1
package/lib/mobile/uploads.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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());
|
|
@@ -65,6 +65,17 @@ function signedPath(req) {
|
|
|
65
65
|
return hit >= 0 ? pathname.slice(hit) : pathname;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* v3 二进制分片的 AAD:`v2|up|METHOD|PATH|<uploadId>|<offset>`。
|
|
70
|
+
*
|
|
71
|
+
* 在 v2 的四段后面绑上这两格,是为了让「把一片搬到别的上传 / 搬到同一上传的别的偏移」
|
|
72
|
+
* 在 AEAD 这一层就不成立 —— 签名只覆盖路径与字节,绑不到上传身份上。
|
|
73
|
+
*
|
|
74
|
+
* 字符串口径写在 `docs/native-upload.md` 的 v3 章,与 App 侧下一阶段的实现对齐;这里只
|
|
75
|
+
* 负责把它拼出来,不额外规范化 offset(`"042"` 与 `"42"` 拼出来不同,客户端必须原样发)。
|
|
76
|
+
*/
|
|
77
|
+
const binChunkAad = (method, path, uploadId, offset) => `v2|up|${method}|${path}|${uploadId}|${offset}`;
|
|
78
|
+
|
|
68
79
|
/** 查询串按线上原样(含 `?`),不排序:它必须与 App 真正发出去的那串一致。 */
|
|
69
80
|
function signedQuery(req) {
|
|
70
81
|
const raw = String(req?.url || '');
|
|
@@ -81,6 +92,8 @@ function signedQuery(req) {
|
|
|
81
92
|
* @param {Buffer} [deps.rawBody] 已读到的请求体原文(没读过就给 undefined,这里会自己读)
|
|
82
93
|
* @returns {{ ok: true, body: object|undefined, keys: object, device: object }
|
|
83
94
|
* | { ok: false, status: number, code: string, extra?: object }}
|
|
95
|
+
*
|
|
96
|
+
* 带 `X-Dsh-Wire: bin` 的请求没有 JSON body,解出来的明文放在 `req.rawBody`(Buffer)上。
|
|
84
97
|
*/
|
|
85
98
|
function openRequest(req, deps, rawBody) {
|
|
86
99
|
const keyId = headerOf(req, 'x-dsh-key-id').trim();
|
|
@@ -133,6 +146,32 @@ function openRequest(req, deps, rawBody) {
|
|
|
133
146
|
nonces.set(keyId, table);
|
|
134
147
|
}
|
|
135
148
|
|
|
149
|
+
// ── 二进制 wire(v3 的上行分片体,见 docs/native-upload.md 的 v3 章)──
|
|
150
|
+
//
|
|
151
|
+
// 只对**明确带头**的请求生效:老 App 不会发 `X-Dsh-Wire`,所以它走的仍是下面那条
|
|
152
|
+
// JSON 信封分支,一个字节都不变。反过来,带了 `bin` 就说明客户端知道自己在发裸密文,
|
|
153
|
+
// 这时候把它当 JSON 解只会得到一个「请求体不是合法 JSON」的 400。
|
|
154
|
+
if (headerOf(req, 'x-dsh-wire').trim().toLowerCase() === 'bin') {
|
|
155
|
+
const uploadId = headerOf(req, 'x-dsh-upload-id').trim();
|
|
156
|
+
const offsetText = headerOf(req, 'x-dsh-upload-offset').trim();
|
|
157
|
+
// 这两格既进 AAD 也进业务判据,缺一格就没法把「这一片」绑到「这个上传的这个偏移」上。
|
|
158
|
+
if (!uploadId || !/^\d+$/.test(offsetText)) {
|
|
159
|
+
return { ok: false, status: 400, code: 'e2ee-bad-envelope', extra: { reason: 'missing wire headers' } };
|
|
160
|
+
}
|
|
161
|
+
let plain;
|
|
162
|
+
try {
|
|
163
|
+
// AAD 比 v2 多两段:先把一片搬到别的上传、或搬到同一上传的别的偏移,认证就不过。
|
|
164
|
+
// 签名口径不变(签的仍是线上这串密文字节),所以上面那步验签一行没动。
|
|
165
|
+
plain = e2ee.open(keys.up, rawBody || Buffer.alloc(0), binChunkAad(method, signedPath(req), uploadId, offsetText));
|
|
166
|
+
} catch {
|
|
167
|
+
return { ok: false, status: 400, code: 'e2ee-decrypt-failed' };
|
|
168
|
+
}
|
|
169
|
+
// 业务 handler 拿不到 JSON(这一片本来就不是 JSON):明文交给 `req.rawBody`,
|
|
170
|
+
// 上传 handler 用 `readRawBody` 取。响应仍由上面的守卫封成普通的 v2 JSON 信封。
|
|
171
|
+
req.rawBody = plain;
|
|
172
|
+
return { ok: true, body: undefined, keys, device: record, req: { ts, nonce } };
|
|
173
|
+
}
|
|
174
|
+
|
|
136
175
|
// ── 解封请求体 ──
|
|
137
176
|
let body;
|
|
138
177
|
if (rawBody && rawBody.length > 0) {
|
package/lib/shared/http-util.js
CHANGED
|
@@ -137,4 +137,59 @@ function readJsonBody(req, maxBytes = JSON_MAX_BODY) {
|
|
|
137
137
|
});
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
|
|
140
|
+
/**
|
|
141
|
+
* 读原始请求体,回 Buffer;超限 / 连接出错 → null。
|
|
142
|
+
*
|
|
143
|
+
* 与 `readJsonBody` 的分工:那条路交出去的是**解析好的 JSON**,这条路交出去的是**字节**。
|
|
144
|
+
* v3 的二进制分片(`X-Dsh-Wire: bin`)加密后就是裸字节,中间没有 JSON 这一层;handler 拿
|
|
145
|
+
* 到的明文片(守卫解封之后)同样是字节,所以两边都用这一条。
|
|
146
|
+
*
|
|
147
|
+
* `req.rawBody` 优先:信封守卫(`shared/e2ee-request.js`)已经把密文读走、解封完放在那里,
|
|
148
|
+
* 请求流早就见底了,再 `on('data')` 只会等来一个空 body —— 那会表现为「每片都解出 0 字节」,
|
|
149
|
+
* 而错误信息只会是「分片是空的」。
|
|
150
|
+
*
|
|
151
|
+
* @param {object} req node:http IncomingMessage
|
|
152
|
+
* @param {number} [maxBytes] 请求体上限(字节),缺省按普通 JSON 那一档
|
|
153
|
+
* @returns {Promise<Buffer|null>}
|
|
154
|
+
*/
|
|
155
|
+
function readRawBody(req, maxBytes = JSON_MAX_BODY) {
|
|
156
|
+
const handed = req && req.rawBody;
|
|
157
|
+
if (Buffer.isBuffer(handed)) return Promise.resolve(handed.length > maxBytes ? null : handed);
|
|
158
|
+
return new Promise((resolve) => {
|
|
159
|
+
const chunks = [];
|
|
160
|
+
let total = 0;
|
|
161
|
+
let settled = false;
|
|
162
|
+
req.on('data', (chunk) => {
|
|
163
|
+
if (settled) return;
|
|
164
|
+
total += chunk.length;
|
|
165
|
+
if (total > maxBytes) {
|
|
166
|
+
settled = true;
|
|
167
|
+
req.destroy();
|
|
168
|
+
resolve(null);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
chunks.push(Buffer.from(chunk));
|
|
172
|
+
});
|
|
173
|
+
req.on('end', () => {
|
|
174
|
+
if (settled) return;
|
|
175
|
+
settled = true;
|
|
176
|
+
resolve(Buffer.concat(chunks));
|
|
177
|
+
});
|
|
178
|
+
req.on('error', () => {
|
|
179
|
+
if (settled) return;
|
|
180
|
+
settled = true;
|
|
181
|
+
resolve(null);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = {
|
|
187
|
+
sendJson,
|
|
188
|
+
sendHtml,
|
|
189
|
+
readJsonBody,
|
|
190
|
+
readRawBody,
|
|
191
|
+
encodeJsonBody,
|
|
192
|
+
wantsGzip,
|
|
193
|
+
JSON_GZIP_MIN_BYTES,
|
|
194
|
+
JSON_MAX_BODY,
|
|
195
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-plugin",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.13",
|
|
4
4
|
"description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (dsh 0.2.x, peers resolved by the host)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"icon": "./icon.png",
|