@dropgate/core 2.2.1 → 3.0.1
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 +259 -128
- package/dist/index.browser.js +1 -1
- package/dist/index.browser.js.map +1 -1
- package/dist/index.cjs +2900 -1431
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +424 -283
- package/dist/index.d.ts +424 -283
- package/dist/index.js +2902 -1384
- package/dist/index.js.map +1 -1
- package/dist/p2p/index.cjs +587 -191
- package/dist/p2p/index.cjs.map +1 -1
- package/dist/p2p/index.d.cts +366 -5
- package/dist/p2p/index.d.ts +366 -5
- package/dist/p2p/index.js +589 -179
- package/dist/p2p/index.js.map +1 -1
- package/package.json +15 -12
package/dist/index.cjs
CHANGED
|
@@ -3,6 +3,7 @@ var __defProp = Object.defineProperty;
|
|
|
3
3
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
4
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
5
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
|
6
7
|
var __export = (target, all) => {
|
|
7
8
|
for (var name in all)
|
|
8
9
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
@@ -16,6 +17,7 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
16
17
|
return to;
|
|
17
18
|
};
|
|
18
19
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
20
|
+
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
19
21
|
|
|
20
22
|
// src/index.ts
|
|
21
23
|
var index_exports = {};
|
|
@@ -31,12 +33,11 @@ __export(index_exports, {
|
|
|
31
33
|
DropgateTimeoutError: () => DropgateTimeoutError,
|
|
32
34
|
DropgateValidationError: () => DropgateValidationError,
|
|
33
35
|
ENCRYPTION_OVERHEAD_PER_CHUNK: () => ENCRYPTION_OVERHEAD_PER_CHUNK,
|
|
36
|
+
StreamingZipWriter: () => StreamingZipWriter,
|
|
34
37
|
arrayBufferToBase64: () => arrayBufferToBase64,
|
|
35
38
|
base64ToBytes: () => base64ToBytes,
|
|
36
39
|
buildBaseUrl: () => buildBaseUrl,
|
|
37
|
-
buildPeerOptions: () => buildPeerOptions,
|
|
38
40
|
bytesToBase64: () => bytesToBase64,
|
|
39
|
-
createPeerWithRetries: () => createPeerWithRetries,
|
|
40
41
|
decryptChunk: () => decryptChunk,
|
|
41
42
|
decryptFilenameFromBase64: () => decryptFilenameFromBase64,
|
|
42
43
|
encryptFilenameToBase64: () => encryptFilenameToBase64,
|
|
@@ -58,11 +59,8 @@ __export(index_exports, {
|
|
|
58
59
|
makeAbortSignal: () => makeAbortSignal,
|
|
59
60
|
parseSemverMajorMinor: () => parseSemverMajorMinor,
|
|
60
61
|
parseServerUrl: () => parseServerUrl,
|
|
61
|
-
resolvePeerConfig: () => resolvePeerConfig,
|
|
62
62
|
sha256Hex: () => sha256Hex,
|
|
63
63
|
sleep: () => sleep,
|
|
64
|
-
startP2PReceive: () => startP2PReceive,
|
|
65
|
-
startP2PSend: () => startP2PSend,
|
|
66
64
|
validatePlainFilename: () => validatePlainFilename
|
|
67
65
|
});
|
|
68
66
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -77,18 +75,12 @@ var MAX_IN_MEMORY_DOWNLOAD_BYTES = 100 * 1024 * 1024;
|
|
|
77
75
|
// src/errors.ts
|
|
78
76
|
var DropgateError = class extends Error {
|
|
79
77
|
constructor(message, opts = {}) {
|
|
80
|
-
super(message);
|
|
78
|
+
super(message, opts.cause !== void 0 ? { cause: opts.cause } : void 0);
|
|
79
|
+
__publicField(this, "code");
|
|
80
|
+
__publicField(this, "details");
|
|
81
81
|
this.name = this.constructor.name;
|
|
82
82
|
this.code = opts.code || "DROPGATE_ERROR";
|
|
83
83
|
this.details = opts.details;
|
|
84
|
-
if (opts.cause !== void 0) {
|
|
85
|
-
Object.defineProperty(this, "cause", {
|
|
86
|
-
value: opts.cause,
|
|
87
|
-
writable: false,
|
|
88
|
-
enumerable: false,
|
|
89
|
-
configurable: true
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
84
|
}
|
|
93
85
|
};
|
|
94
86
|
var DropgateValidationError = class extends DropgateError {
|
|
@@ -304,6 +296,144 @@ async function fetchJson(fetchFn, url, opts = {}) {
|
|
|
304
296
|
}
|
|
305
297
|
}
|
|
306
298
|
|
|
299
|
+
// src/crypto/sha256-fallback.ts
|
|
300
|
+
var K = new Uint32Array([
|
|
301
|
+
1116352408,
|
|
302
|
+
1899447441,
|
|
303
|
+
3049323471,
|
|
304
|
+
3921009573,
|
|
305
|
+
961987163,
|
|
306
|
+
1508970993,
|
|
307
|
+
2453635748,
|
|
308
|
+
2870763221,
|
|
309
|
+
3624381080,
|
|
310
|
+
310598401,
|
|
311
|
+
607225278,
|
|
312
|
+
1426881987,
|
|
313
|
+
1925078388,
|
|
314
|
+
2162078206,
|
|
315
|
+
2614888103,
|
|
316
|
+
3248222580,
|
|
317
|
+
3835390401,
|
|
318
|
+
4022224774,
|
|
319
|
+
264347078,
|
|
320
|
+
604807628,
|
|
321
|
+
770255983,
|
|
322
|
+
1249150122,
|
|
323
|
+
1555081692,
|
|
324
|
+
1996064986,
|
|
325
|
+
2554220882,
|
|
326
|
+
2821834349,
|
|
327
|
+
2952996808,
|
|
328
|
+
3210313671,
|
|
329
|
+
3336571891,
|
|
330
|
+
3584528711,
|
|
331
|
+
113926993,
|
|
332
|
+
338241895,
|
|
333
|
+
666307205,
|
|
334
|
+
773529912,
|
|
335
|
+
1294757372,
|
|
336
|
+
1396182291,
|
|
337
|
+
1695183700,
|
|
338
|
+
1986661051,
|
|
339
|
+
2177026350,
|
|
340
|
+
2456956037,
|
|
341
|
+
2730485921,
|
|
342
|
+
2820302411,
|
|
343
|
+
3259730800,
|
|
344
|
+
3345764771,
|
|
345
|
+
3516065817,
|
|
346
|
+
3600352804,
|
|
347
|
+
4094571909,
|
|
348
|
+
275423344,
|
|
349
|
+
430227734,
|
|
350
|
+
506948616,
|
|
351
|
+
659060556,
|
|
352
|
+
883997877,
|
|
353
|
+
958139571,
|
|
354
|
+
1322822218,
|
|
355
|
+
1537002063,
|
|
356
|
+
1747873779,
|
|
357
|
+
1955562222,
|
|
358
|
+
2024104815,
|
|
359
|
+
2227730452,
|
|
360
|
+
2361852424,
|
|
361
|
+
2428436474,
|
|
362
|
+
2756734187,
|
|
363
|
+
3204031479,
|
|
364
|
+
3329325298
|
|
365
|
+
]);
|
|
366
|
+
function rotr(x, n) {
|
|
367
|
+
return x >>> n | x << 32 - n;
|
|
368
|
+
}
|
|
369
|
+
function sha256Fallback(data) {
|
|
370
|
+
const bytes = new Uint8Array(data);
|
|
371
|
+
const bitLen = bytes.length * 8;
|
|
372
|
+
const padded = new Uint8Array(
|
|
373
|
+
Math.ceil((bytes.length + 9) / 64) * 64
|
|
374
|
+
);
|
|
375
|
+
padded.set(bytes);
|
|
376
|
+
padded[bytes.length] = 128;
|
|
377
|
+
const view = new DataView(padded.buffer);
|
|
378
|
+
view.setUint32(padded.length - 8, bitLen / 4294967296 >>> 0, false);
|
|
379
|
+
view.setUint32(padded.length - 4, bitLen >>> 0, false);
|
|
380
|
+
let h0 = 1779033703;
|
|
381
|
+
let h1 = 3144134277;
|
|
382
|
+
let h2 = 1013904242;
|
|
383
|
+
let h3 = 2773480762;
|
|
384
|
+
let h4 = 1359893119;
|
|
385
|
+
let h5 = 2600822924;
|
|
386
|
+
let h6 = 528734635;
|
|
387
|
+
let h7 = 1541459225;
|
|
388
|
+
const W = new Uint32Array(64);
|
|
389
|
+
for (let offset = 0; offset < padded.length; offset += 64) {
|
|
390
|
+
for (let i = 0; i < 16; i++) {
|
|
391
|
+
W[i] = view.getUint32(offset + i * 4, false);
|
|
392
|
+
}
|
|
393
|
+
for (let i = 16; i < 64; i++) {
|
|
394
|
+
const s0 = rotr(W[i - 15], 7) ^ rotr(W[i - 15], 18) ^ W[i - 15] >>> 3;
|
|
395
|
+
const s1 = rotr(W[i - 2], 17) ^ rotr(W[i - 2], 19) ^ W[i - 2] >>> 10;
|
|
396
|
+
W[i] = W[i - 16] + s0 + W[i - 7] + s1 | 0;
|
|
397
|
+
}
|
|
398
|
+
let a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, h = h7;
|
|
399
|
+
for (let i = 0; i < 64; i++) {
|
|
400
|
+
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
|
|
401
|
+
const ch = e & f ^ ~e & g;
|
|
402
|
+
const temp1 = h + S1 + ch + K[i] + W[i] | 0;
|
|
403
|
+
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
|
|
404
|
+
const maj = a & b ^ a & c ^ b & c;
|
|
405
|
+
const temp2 = S0 + maj | 0;
|
|
406
|
+
h = g;
|
|
407
|
+
g = f;
|
|
408
|
+
f = e;
|
|
409
|
+
e = d + temp1 | 0;
|
|
410
|
+
d = c;
|
|
411
|
+
c = b;
|
|
412
|
+
b = a;
|
|
413
|
+
a = temp1 + temp2 | 0;
|
|
414
|
+
}
|
|
415
|
+
h0 = h0 + a | 0;
|
|
416
|
+
h1 = h1 + b | 0;
|
|
417
|
+
h2 = h2 + c | 0;
|
|
418
|
+
h3 = h3 + d | 0;
|
|
419
|
+
h4 = h4 + e | 0;
|
|
420
|
+
h5 = h5 + f | 0;
|
|
421
|
+
h6 = h6 + g | 0;
|
|
422
|
+
h7 = h7 + h | 0;
|
|
423
|
+
}
|
|
424
|
+
const result = new ArrayBuffer(32);
|
|
425
|
+
const out = new DataView(result);
|
|
426
|
+
out.setUint32(0, h0, false);
|
|
427
|
+
out.setUint32(4, h1, false);
|
|
428
|
+
out.setUint32(8, h2, false);
|
|
429
|
+
out.setUint32(12, h3, false);
|
|
430
|
+
out.setUint32(16, h4, false);
|
|
431
|
+
out.setUint32(20, h5, false);
|
|
432
|
+
out.setUint32(24, h6, false);
|
|
433
|
+
out.setUint32(28, h7, false);
|
|
434
|
+
return result;
|
|
435
|
+
}
|
|
436
|
+
|
|
307
437
|
// src/crypto/decrypt.ts
|
|
308
438
|
async function importKeyFromBase64(cryptoObj, keyB64, base64) {
|
|
309
439
|
const adapter = base64 || getDefaultBase64();
|
|
@@ -334,8 +464,7 @@ async function decryptFilenameFromBase64(cryptoObj, encryptedFilenameB64, key, b
|
|
|
334
464
|
}
|
|
335
465
|
|
|
336
466
|
// src/crypto/index.ts
|
|
337
|
-
|
|
338
|
-
const hashBuffer = await cryptoObj.subtle.digest("SHA-256", data);
|
|
467
|
+
function digestToHex(hashBuffer) {
|
|
339
468
|
const arr = new Uint8Array(hashBuffer);
|
|
340
469
|
let hex = "";
|
|
341
470
|
for (let i = 0; i < arr.length; i++) {
|
|
@@ -343,6 +472,13 @@ async function sha256Hex(cryptoObj, data) {
|
|
|
343
472
|
}
|
|
344
473
|
return hex;
|
|
345
474
|
}
|
|
475
|
+
async function sha256Hex(cryptoObj, data) {
|
|
476
|
+
if (cryptoObj?.subtle) {
|
|
477
|
+
const hashBuffer = await cryptoObj.subtle.digest("SHA-256", data);
|
|
478
|
+
return digestToHex(hashBuffer);
|
|
479
|
+
}
|
|
480
|
+
return digestToHex(sha256Fallback(data));
|
|
481
|
+
}
|
|
346
482
|
async function generateAesGcmKey(cryptoObj) {
|
|
347
483
|
return cryptoObj.subtle.generateKey(
|
|
348
484
|
{ name: "AES-GCM", length: 256 },
|
|
@@ -372,1546 +508,2879 @@ async function encryptFilenameToBase64(cryptoObj, filename, key) {
|
|
|
372
508
|
return arrayBufferToBase64(buf);
|
|
373
509
|
}
|
|
374
510
|
|
|
375
|
-
//
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
511
|
+
// node_modules/fflate/esm/browser.js
|
|
512
|
+
var u8 = Uint8Array;
|
|
513
|
+
var u16 = Uint16Array;
|
|
514
|
+
var i32 = Int32Array;
|
|
515
|
+
var fleb = new u8([
|
|
516
|
+
0,
|
|
517
|
+
0,
|
|
518
|
+
0,
|
|
519
|
+
0,
|
|
520
|
+
0,
|
|
521
|
+
0,
|
|
522
|
+
0,
|
|
523
|
+
0,
|
|
524
|
+
1,
|
|
525
|
+
1,
|
|
526
|
+
1,
|
|
527
|
+
1,
|
|
528
|
+
2,
|
|
529
|
+
2,
|
|
530
|
+
2,
|
|
531
|
+
2,
|
|
532
|
+
3,
|
|
533
|
+
3,
|
|
534
|
+
3,
|
|
535
|
+
3,
|
|
536
|
+
4,
|
|
537
|
+
4,
|
|
538
|
+
4,
|
|
539
|
+
4,
|
|
540
|
+
5,
|
|
541
|
+
5,
|
|
542
|
+
5,
|
|
543
|
+
5,
|
|
544
|
+
0,
|
|
545
|
+
/* unused */
|
|
546
|
+
0,
|
|
547
|
+
0,
|
|
548
|
+
/* impossible */
|
|
549
|
+
0
|
|
550
|
+
]);
|
|
551
|
+
var fdeb = new u8([
|
|
552
|
+
0,
|
|
553
|
+
0,
|
|
554
|
+
0,
|
|
555
|
+
0,
|
|
556
|
+
1,
|
|
557
|
+
1,
|
|
558
|
+
2,
|
|
559
|
+
2,
|
|
560
|
+
3,
|
|
561
|
+
3,
|
|
562
|
+
4,
|
|
563
|
+
4,
|
|
564
|
+
5,
|
|
565
|
+
5,
|
|
566
|
+
6,
|
|
567
|
+
6,
|
|
568
|
+
7,
|
|
569
|
+
7,
|
|
570
|
+
8,
|
|
571
|
+
8,
|
|
572
|
+
9,
|
|
573
|
+
9,
|
|
574
|
+
10,
|
|
575
|
+
10,
|
|
576
|
+
11,
|
|
577
|
+
11,
|
|
578
|
+
12,
|
|
579
|
+
12,
|
|
580
|
+
13,
|
|
581
|
+
13,
|
|
582
|
+
/* unused */
|
|
583
|
+
0,
|
|
584
|
+
0
|
|
585
|
+
]);
|
|
586
|
+
var clim = new u8([16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]);
|
|
587
|
+
var freb = function(eb, start) {
|
|
588
|
+
var b = new u16(31);
|
|
589
|
+
for (var i = 0; i < 31; ++i) {
|
|
590
|
+
b[i] = start += 1 << eb[i - 1];
|
|
386
591
|
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
`${baseUrl}/api/info`,
|
|
392
|
-
{
|
|
393
|
-
method: "GET",
|
|
394
|
-
timeoutMs,
|
|
395
|
-
signal,
|
|
396
|
-
headers: { Accept: "application/json" }
|
|
397
|
-
}
|
|
398
|
-
);
|
|
399
|
-
if (res.ok && json && typeof json === "object" && "version" in json) {
|
|
400
|
-
return { baseUrl, serverInfo: json };
|
|
592
|
+
var r = new i32(b[30]);
|
|
593
|
+
for (var i = 1; i < 30; ++i) {
|
|
594
|
+
for (var j = b[i]; j < b[i + 1]; ++j) {
|
|
595
|
+
r[j] = j - b[i] << 5 | i;
|
|
401
596
|
}
|
|
402
|
-
throw new DropgateProtocolError(
|
|
403
|
-
`Server info request failed (status ${res.status}).`
|
|
404
|
-
);
|
|
405
|
-
} catch (err) {
|
|
406
|
-
if (err instanceof DropgateError) throw err;
|
|
407
|
-
throw new DropgateNetworkError("Could not reach server /api/info.", {
|
|
408
|
-
cause: err
|
|
409
|
-
});
|
|
410
597
|
}
|
|
598
|
+
return { b, r };
|
|
599
|
+
};
|
|
600
|
+
var _a = freb(fleb, 2);
|
|
601
|
+
var fl = _a.b;
|
|
602
|
+
var revfl = _a.r;
|
|
603
|
+
fl[28] = 258, revfl[258] = 28;
|
|
604
|
+
var _b = freb(fdeb, 0);
|
|
605
|
+
var fd = _b.b;
|
|
606
|
+
var revfd = _b.r;
|
|
607
|
+
var rev = new u16(32768);
|
|
608
|
+
for (i = 0; i < 32768; ++i) {
|
|
609
|
+
x = (i & 43690) >> 1 | (i & 21845) << 1;
|
|
610
|
+
x = (x & 52428) >> 2 | (x & 13107) << 2;
|
|
611
|
+
x = (x & 61680) >> 4 | (x & 3855) << 4;
|
|
612
|
+
rev[i] = ((x & 65280) >> 8 | (x & 255) << 8) >> 1;
|
|
411
613
|
}
|
|
412
|
-
var
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
614
|
+
var x;
|
|
615
|
+
var i;
|
|
616
|
+
var flt = new u8(288);
|
|
617
|
+
for (i = 0; i < 144; ++i)
|
|
618
|
+
flt[i] = 8;
|
|
619
|
+
var i;
|
|
620
|
+
for (i = 144; i < 256; ++i)
|
|
621
|
+
flt[i] = 9;
|
|
622
|
+
var i;
|
|
623
|
+
for (i = 256; i < 280; ++i)
|
|
624
|
+
flt[i] = 7;
|
|
625
|
+
var i;
|
|
626
|
+
for (i = 280; i < 288; ++i)
|
|
627
|
+
flt[i] = 8;
|
|
628
|
+
var i;
|
|
629
|
+
var fdt = new u8(32);
|
|
630
|
+
for (i = 0; i < 32; ++i)
|
|
631
|
+
fdt[i] = 5;
|
|
632
|
+
var i;
|
|
633
|
+
var slc = function(v, s, e) {
|
|
634
|
+
if (s == null || s < 0)
|
|
635
|
+
s = 0;
|
|
636
|
+
if (e == null || e > v.length)
|
|
637
|
+
e = v.length;
|
|
638
|
+
return new u8(v.subarray(s, e));
|
|
639
|
+
};
|
|
640
|
+
var ec = [
|
|
641
|
+
"unexpected EOF",
|
|
642
|
+
"invalid block type",
|
|
643
|
+
"invalid length/literal",
|
|
644
|
+
"invalid distance",
|
|
645
|
+
"stream finished",
|
|
646
|
+
"no stream handler",
|
|
647
|
+
,
|
|
648
|
+
"no callback",
|
|
649
|
+
"invalid UTF-8 data",
|
|
650
|
+
"extra field too long",
|
|
651
|
+
"date not in range 1980-2099",
|
|
652
|
+
"filename too long",
|
|
653
|
+
"stream finishing",
|
|
654
|
+
"invalid zip data"
|
|
655
|
+
// determined by unknown compression method
|
|
656
|
+
];
|
|
657
|
+
var err = function(ind, msg, nt) {
|
|
658
|
+
var e = new Error(msg || ec[ind]);
|
|
659
|
+
e.code = ind;
|
|
660
|
+
if (Error.captureStackTrace)
|
|
661
|
+
Error.captureStackTrace(e, err);
|
|
662
|
+
if (!nt)
|
|
663
|
+
throw e;
|
|
664
|
+
return e;
|
|
665
|
+
};
|
|
666
|
+
var et = /* @__PURE__ */ new u8(0);
|
|
667
|
+
var crct = /* @__PURE__ */ (function() {
|
|
668
|
+
var t = new Int32Array(256);
|
|
669
|
+
for (var i = 0; i < 256; ++i) {
|
|
670
|
+
var c = i, k = 9;
|
|
671
|
+
while (--k)
|
|
672
|
+
c = (c & 1 && -306674912) ^ c >>> 1;
|
|
673
|
+
t[i] = c;
|
|
674
|
+
}
|
|
675
|
+
return t;
|
|
676
|
+
})();
|
|
677
|
+
var crc = function() {
|
|
678
|
+
var c = -1;
|
|
679
|
+
return {
|
|
680
|
+
p: function(d) {
|
|
681
|
+
var cr = c;
|
|
682
|
+
for (var i = 0; i < d.length; ++i)
|
|
683
|
+
cr = crct[cr & 255 ^ d[i]] ^ cr >>> 8;
|
|
684
|
+
c = cr;
|
|
685
|
+
},
|
|
686
|
+
d: function() {
|
|
687
|
+
return ~c;
|
|
423
688
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
689
|
+
};
|
|
690
|
+
};
|
|
691
|
+
var mrg = function(a, b) {
|
|
692
|
+
var o = {};
|
|
693
|
+
for (var k in a)
|
|
694
|
+
o[k] = a[k];
|
|
695
|
+
for (var k in b)
|
|
696
|
+
o[k] = b[k];
|
|
697
|
+
return o;
|
|
698
|
+
};
|
|
699
|
+
var wbytes = function(d, b, v) {
|
|
700
|
+
for (; v; ++b)
|
|
701
|
+
d[b] = v, v >>>= 8;
|
|
702
|
+
};
|
|
703
|
+
var te = typeof TextEncoder != "undefined" && /* @__PURE__ */ new TextEncoder();
|
|
704
|
+
var td = typeof TextDecoder != "undefined" && /* @__PURE__ */ new TextDecoder();
|
|
705
|
+
var tds = 0;
|
|
706
|
+
try {
|
|
707
|
+
td.decode(et, { stream: true });
|
|
708
|
+
tds = 1;
|
|
709
|
+
} catch (e) {
|
|
710
|
+
}
|
|
711
|
+
function strToU8(str, latin1) {
|
|
712
|
+
if (latin1) {
|
|
713
|
+
var ar_1 = new u8(str.length);
|
|
714
|
+
for (var i = 0; i < str.length; ++i)
|
|
715
|
+
ar_1[i] = str.charCodeAt(i);
|
|
716
|
+
return ar_1;
|
|
717
|
+
}
|
|
718
|
+
if (te)
|
|
719
|
+
return te.encode(str);
|
|
720
|
+
var l = str.length;
|
|
721
|
+
var ar = new u8(str.length + (str.length >> 1));
|
|
722
|
+
var ai = 0;
|
|
723
|
+
var w = function(v) {
|
|
724
|
+
ar[ai++] = v;
|
|
725
|
+
};
|
|
726
|
+
for (var i = 0; i < l; ++i) {
|
|
727
|
+
if (ai + 5 > ar.length) {
|
|
728
|
+
var n = new u8(ai + 8 + (l - i << 1));
|
|
729
|
+
n.set(ar);
|
|
730
|
+
ar = n;
|
|
429
731
|
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
732
|
+
var c = str.charCodeAt(i);
|
|
733
|
+
if (c < 128 || latin1)
|
|
734
|
+
w(c);
|
|
735
|
+
else if (c < 2048)
|
|
736
|
+
w(192 | c >> 6), w(128 | c & 63);
|
|
737
|
+
else if (c > 55295 && c < 57344)
|
|
738
|
+
c = 65536 + (c & 1023 << 10) | str.charCodeAt(++i) & 1023, w(240 | c >> 18), w(128 | c >> 12 & 63), w(128 | c >> 6 & 63), w(128 | c & 63);
|
|
739
|
+
else
|
|
740
|
+
w(224 | c >> 12), w(128 | c >> 6 & 63), w(128 | c & 63);
|
|
741
|
+
}
|
|
742
|
+
return slc(ar, 0, ai);
|
|
743
|
+
}
|
|
744
|
+
var exfl = function(ex) {
|
|
745
|
+
var le = 0;
|
|
746
|
+
if (ex) {
|
|
747
|
+
for (var k in ex) {
|
|
748
|
+
var l = ex[k].length;
|
|
749
|
+
if (l > 65535)
|
|
750
|
+
err(9);
|
|
751
|
+
le += l + 4;
|
|
434
752
|
}
|
|
435
|
-
this.cryptoObj = cryptoObj;
|
|
436
|
-
this.base64 = opts.base64 || getDefaultBase64();
|
|
437
|
-
this.logger = opts.logger || null;
|
|
438
753
|
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
754
|
+
return le;
|
|
755
|
+
};
|
|
756
|
+
var wzh = function(d, b, f, fn, u, c, ce, co) {
|
|
757
|
+
var fl2 = fn.length, ex = f.extra, col = co && co.length;
|
|
758
|
+
var exl = exfl(ex);
|
|
759
|
+
wbytes(d, b, ce != null ? 33639248 : 67324752), b += 4;
|
|
760
|
+
if (ce != null)
|
|
761
|
+
d[b++] = 20, d[b++] = f.os;
|
|
762
|
+
d[b] = 20, b += 2;
|
|
763
|
+
d[b++] = f.flag << 1 | (c < 0 && 8), d[b++] = u && 8;
|
|
764
|
+
d[b++] = f.compression & 255, d[b++] = f.compression >> 8;
|
|
765
|
+
var dt = new Date(f.mtime == null ? Date.now() : f.mtime), y = dt.getFullYear() - 1980;
|
|
766
|
+
if (y < 0 || y > 119)
|
|
767
|
+
err(10);
|
|
768
|
+
wbytes(d, b, y << 25 | dt.getMonth() + 1 << 21 | dt.getDate() << 16 | dt.getHours() << 11 | dt.getMinutes() << 5 | dt.getSeconds() >> 1), b += 4;
|
|
769
|
+
if (c != -1) {
|
|
770
|
+
wbytes(d, b, f.crc);
|
|
771
|
+
wbytes(d, b + 4, c < 0 ? -c - 2 : c);
|
|
772
|
+
wbytes(d, b + 8, f.size);
|
|
773
|
+
}
|
|
774
|
+
wbytes(d, b + 12, fl2);
|
|
775
|
+
wbytes(d, b + 14, exl), b += 16;
|
|
776
|
+
if (ce != null) {
|
|
777
|
+
wbytes(d, b, col);
|
|
778
|
+
wbytes(d, b + 6, f.attrs);
|
|
779
|
+
wbytes(d, b + 10, ce), b += 14;
|
|
780
|
+
}
|
|
781
|
+
d.set(fn, b);
|
|
782
|
+
b += fl2;
|
|
783
|
+
if (exl) {
|
|
784
|
+
for (var k in ex) {
|
|
785
|
+
var exf = ex[k], l = exf.length;
|
|
786
|
+
wbytes(d, b, +k);
|
|
787
|
+
wbytes(d, b + 2, l);
|
|
788
|
+
d.set(exf, b + 4), b += 4 + l;
|
|
451
789
|
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
790
|
+
}
|
|
791
|
+
if (col)
|
|
792
|
+
d.set(co, b), b += col;
|
|
793
|
+
return b;
|
|
794
|
+
};
|
|
795
|
+
var wzf = function(o, b, c, d, e) {
|
|
796
|
+
wbytes(o, b, 101010256);
|
|
797
|
+
wbytes(o, b + 8, c);
|
|
798
|
+
wbytes(o, b + 10, c);
|
|
799
|
+
wbytes(o, b + 12, d);
|
|
800
|
+
wbytes(o, b + 16, e);
|
|
801
|
+
};
|
|
802
|
+
var ZipPassThrough = /* @__PURE__ */ (function() {
|
|
803
|
+
function ZipPassThrough2(filename) {
|
|
804
|
+
this.filename = filename;
|
|
805
|
+
this.c = crc();
|
|
806
|
+
this.size = 0;
|
|
807
|
+
this.compression = 0;
|
|
808
|
+
}
|
|
809
|
+
ZipPassThrough2.prototype.process = function(chunk, final) {
|
|
810
|
+
this.ondata(null, chunk, final);
|
|
811
|
+
};
|
|
812
|
+
ZipPassThrough2.prototype.push = function(chunk, final) {
|
|
813
|
+
if (!this.ondata)
|
|
814
|
+
err(5);
|
|
815
|
+
this.c.p(chunk);
|
|
816
|
+
this.size += chunk.length;
|
|
817
|
+
if (final)
|
|
818
|
+
this.crc = this.c.d();
|
|
819
|
+
this.process(chunk, final || false);
|
|
820
|
+
};
|
|
821
|
+
return ZipPassThrough2;
|
|
822
|
+
})();
|
|
823
|
+
var Zip = /* @__PURE__ */ (function() {
|
|
824
|
+
function Zip2(cb) {
|
|
825
|
+
this.ondata = cb;
|
|
826
|
+
this.u = [];
|
|
827
|
+
this.d = 1;
|
|
828
|
+
}
|
|
829
|
+
Zip2.prototype.add = function(file) {
|
|
830
|
+
var _this = this;
|
|
831
|
+
if (!this.ondata)
|
|
832
|
+
err(5);
|
|
833
|
+
if (this.d & 2)
|
|
834
|
+
this.ondata(err(4 + (this.d & 1) * 8, 0, 1), null, false);
|
|
835
|
+
else {
|
|
836
|
+
var f = strToU8(file.filename), fl_1 = f.length;
|
|
837
|
+
var com = file.comment, o = com && strToU8(com);
|
|
838
|
+
var u = fl_1 != file.filename.length || o && com.length != o.length;
|
|
839
|
+
var hl_1 = fl_1 + exfl(file.extra) + 30;
|
|
840
|
+
if (fl_1 > 65535)
|
|
841
|
+
this.ondata(err(11, 0, 1), null, false);
|
|
842
|
+
var header = new u8(hl_1);
|
|
843
|
+
wzh(header, 0, file, f, u, -1);
|
|
844
|
+
var chks_1 = [header];
|
|
845
|
+
var pAll_1 = function() {
|
|
846
|
+
for (var _i = 0, chks_2 = chks_1; _i < chks_2.length; _i++) {
|
|
847
|
+
var chk = chks_2[_i];
|
|
848
|
+
_this.ondata(null, chk, false);
|
|
849
|
+
}
|
|
850
|
+
chks_1 = [];
|
|
851
|
+
};
|
|
852
|
+
var tr_1 = this.d;
|
|
853
|
+
this.d = 0;
|
|
854
|
+
var ind_1 = this.u.length;
|
|
855
|
+
var uf_1 = mrg(file, {
|
|
856
|
+
f,
|
|
857
|
+
u,
|
|
858
|
+
o,
|
|
859
|
+
t: function() {
|
|
860
|
+
if (file.terminate)
|
|
861
|
+
file.terminate();
|
|
463
862
|
},
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
863
|
+
r: function() {
|
|
864
|
+
pAll_1();
|
|
865
|
+
if (tr_1) {
|
|
866
|
+
var nxt = _this.u[ind_1 + 1];
|
|
867
|
+
if (nxt)
|
|
868
|
+
nxt.r();
|
|
869
|
+
else
|
|
870
|
+
_this.d = 1;
|
|
871
|
+
}
|
|
872
|
+
tr_1 = 1;
|
|
873
|
+
}
|
|
874
|
+
});
|
|
875
|
+
var cl_1 = 0;
|
|
876
|
+
file.ondata = function(err2, dat, final) {
|
|
877
|
+
if (err2) {
|
|
878
|
+
_this.ondata(err2, dat, final);
|
|
879
|
+
_this.terminate();
|
|
880
|
+
} else {
|
|
881
|
+
cl_1 += dat.length;
|
|
882
|
+
chks_1.push(dat);
|
|
883
|
+
if (final) {
|
|
884
|
+
var dd = new u8(16);
|
|
885
|
+
wbytes(dd, 0, 134695760);
|
|
886
|
+
wbytes(dd, 4, file.crc);
|
|
887
|
+
wbytes(dd, 8, cl_1);
|
|
888
|
+
wbytes(dd, 12, file.size);
|
|
889
|
+
chks_1.push(dd);
|
|
890
|
+
uf_1.c = cl_1, uf_1.b = hl_1 + cl_1 + 16, uf_1.crc = file.crc, uf_1.size = file.size;
|
|
891
|
+
if (tr_1)
|
|
892
|
+
uf_1.r();
|
|
893
|
+
tr_1 = 1;
|
|
894
|
+
} else if (tr_1)
|
|
895
|
+
pAll_1();
|
|
896
|
+
}
|
|
897
|
+
};
|
|
898
|
+
this.u.push(uf_1);
|
|
470
899
|
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
throw new DropgateNetworkError("Could not connect to the server.", {
|
|
491
|
-
cause: err
|
|
900
|
+
};
|
|
901
|
+
Zip2.prototype.end = function() {
|
|
902
|
+
var _this = this;
|
|
903
|
+
if (this.d & 2) {
|
|
904
|
+
this.ondata(err(4 + (this.d & 1) * 8, 0, 1), null, true);
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
if (this.d)
|
|
908
|
+
this.e();
|
|
909
|
+
else
|
|
910
|
+
this.u.push({
|
|
911
|
+
r: function() {
|
|
912
|
+
if (!(_this.d & 1))
|
|
913
|
+
return;
|
|
914
|
+
_this.u.splice(-1, 1);
|
|
915
|
+
_this.e();
|
|
916
|
+
},
|
|
917
|
+
t: function() {
|
|
918
|
+
}
|
|
492
919
|
});
|
|
920
|
+
this.d = 3;
|
|
921
|
+
};
|
|
922
|
+
Zip2.prototype.e = function() {
|
|
923
|
+
var bt = 0, l = 0, tl = 0;
|
|
924
|
+
for (var _i = 0, _a2 = this.u; _i < _a2.length; _i++) {
|
|
925
|
+
var f = _a2[_i];
|
|
926
|
+
tl += 46 + f.f.length + exfl(f.extra) + (f.o ? f.o.length : 0);
|
|
493
927
|
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
return {
|
|
500
|
-
compatible: false,
|
|
501
|
-
clientVersion,
|
|
502
|
-
serverVersion,
|
|
503
|
-
message: `Incompatible versions. Client v${clientVersion}, Server v${serverVersion}${serverInfo?.name ? ` (${serverInfo.name})` : ""}.`,
|
|
504
|
-
serverInfo,
|
|
505
|
-
baseUrl
|
|
506
|
-
};
|
|
928
|
+
var out = new u8(tl + 22);
|
|
929
|
+
for (var _b2 = 0, _c = this.u; _b2 < _c.length; _b2++) {
|
|
930
|
+
var f = _c[_b2];
|
|
931
|
+
wzh(out, bt, f, f.f, f.u, -f.c - 2, l, f.o);
|
|
932
|
+
bt += 46 + f.f.length + exfl(f.extra) + (f.o ? f.o.length : 0), l += f.b;
|
|
507
933
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
};
|
|
934
|
+
wzf(out, bt, this.u.length, tl, l);
|
|
935
|
+
this.ondata(null, out, true);
|
|
936
|
+
this.d = 2;
|
|
937
|
+
};
|
|
938
|
+
Zip2.prototype.terminate = function() {
|
|
939
|
+
for (var _i = 0, _a2 = this.u; _i < _a2.length; _i++) {
|
|
940
|
+
var f = _a2[_i];
|
|
941
|
+
f.t();
|
|
517
942
|
}
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
943
|
+
this.d = 2;
|
|
944
|
+
};
|
|
945
|
+
return Zip2;
|
|
946
|
+
})();
|
|
947
|
+
|
|
948
|
+
// src/zip/stream-zip.ts
|
|
949
|
+
var StreamingZipWriter = class {
|
|
950
|
+
constructor(onData) {
|
|
951
|
+
__publicField(this, "zip");
|
|
952
|
+
__publicField(this, "currentFile", null);
|
|
953
|
+
__publicField(this, "onData");
|
|
954
|
+
__publicField(this, "finalized", false);
|
|
955
|
+
__publicField(this, "pendingWrites", Promise.resolve());
|
|
956
|
+
this.onData = onData;
|
|
957
|
+
this.zip = new Zip((err2, data, _final) => {
|
|
958
|
+
if (err2) throw err2;
|
|
959
|
+
this.pendingWrites = this.pendingWrites.then(() => this.onData(data));
|
|
960
|
+
});
|
|
526
961
|
}
|
|
527
962
|
/**
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
* @
|
|
531
|
-
* @throws {DropgateValidationError} If any validation check fails.
|
|
963
|
+
* Begin a new file entry in the ZIP.
|
|
964
|
+
* Must call endFile() before starting another file.
|
|
965
|
+
* @param name - Filename within the ZIP archive.
|
|
532
966
|
*/
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
if (!caps || !caps.enabled) {
|
|
537
|
-
throw new DropgateValidationError("Server does not support file uploads.");
|
|
538
|
-
}
|
|
539
|
-
const fileSize = Number(file?.size || 0);
|
|
540
|
-
if (!file || !Number.isFinite(fileSize) || fileSize <= 0) {
|
|
541
|
-
throw new DropgateValidationError("File is missing or invalid.");
|
|
542
|
-
}
|
|
543
|
-
const maxMB = Number(caps.maxSizeMB);
|
|
544
|
-
if (Number.isFinite(maxMB) && maxMB > 0) {
|
|
545
|
-
const limitBytes = maxMB * 1e3 * 1e3;
|
|
546
|
-
const totalChunks = Math.ceil(fileSize / this.chunkSize);
|
|
547
|
-
const estimatedBytes = estimateTotalUploadSizeBytes(
|
|
548
|
-
fileSize,
|
|
549
|
-
totalChunks,
|
|
550
|
-
Boolean(encrypt)
|
|
551
|
-
);
|
|
552
|
-
if (estimatedBytes > limitBytes) {
|
|
553
|
-
const msg = encrypt ? `File too large once encryption overhead is included. Server limit: ${maxMB} MB.` : `File too large. Server limit: ${maxMB} MB.`;
|
|
554
|
-
throw new DropgateValidationError(msg);
|
|
555
|
-
}
|
|
967
|
+
startFile(name) {
|
|
968
|
+
if (this.currentFile) {
|
|
969
|
+
throw new Error("Must call endFile() before starting a new file.");
|
|
556
970
|
}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
if (!Number.isFinite(lt) || lt < 0 || !Number.isInteger(lt)) {
|
|
560
|
-
throw new DropgateValidationError(
|
|
561
|
-
"Invalid lifetime. Must be a non-negative integer (milliseconds)."
|
|
562
|
-
);
|
|
971
|
+
if (this.finalized) {
|
|
972
|
+
throw new Error("ZIP has already been finalized.");
|
|
563
973
|
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
}
|
|
974
|
+
const entry = new ZipPassThrough(name);
|
|
975
|
+
this.zip.add(entry);
|
|
976
|
+
this.currentFile = entry;
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Write a chunk of data to the current file entry.
|
|
980
|
+
* @param data - The data chunk to write.
|
|
981
|
+
*/
|
|
982
|
+
writeChunk(data) {
|
|
983
|
+
if (!this.currentFile) {
|
|
984
|
+
throw new Error("No file started. Call startFile() first.");
|
|
576
985
|
}
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
986
|
+
this.currentFile.push(data, false);
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* End the current file entry.
|
|
990
|
+
*/
|
|
991
|
+
endFile() {
|
|
992
|
+
if (!this.currentFile) {
|
|
993
|
+
throw new Error("No file to end.");
|
|
581
994
|
}
|
|
582
|
-
|
|
995
|
+
this.currentFile.push(new Uint8Array(0), true);
|
|
996
|
+
this.currentFile = null;
|
|
583
997
|
}
|
|
584
998
|
/**
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
-
* @returns Upload result containing the download URL and file identifiers.
|
|
588
|
-
* @throws {DropgateValidationError} If input validation fails.
|
|
589
|
-
* @throws {DropgateNetworkError} If the server cannot be reached.
|
|
590
|
-
* @throws {DropgateProtocolError} If the server returns an error.
|
|
591
|
-
* @throws {DropgateAbortError} If the upload is cancelled.
|
|
999
|
+
* Finalize the ZIP archive. Must be called after all files are written.
|
|
1000
|
+
* Waits for all pending async writes to complete before resolving.
|
|
592
1001
|
*/
|
|
593
|
-
async
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
);
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
isEncrypted: effectiveEncrypt,
|
|
679
|
-
totalSize: totalUploadSize,
|
|
680
|
-
totalChunks,
|
|
681
|
-
...maxDownloads !== void 0 ? { maxDownloads } : {}
|
|
682
|
-
};
|
|
683
|
-
const initRes = await fetchJson(this.fetchFn, `${baseUrl}/upload/init`, {
|
|
684
|
-
method: "POST",
|
|
685
|
-
timeoutMs: timeouts.initMs ?? 15e3,
|
|
686
|
-
signal: effectiveSignal,
|
|
687
|
-
headers: {
|
|
688
|
-
"Content-Type": "application/json",
|
|
689
|
-
Accept: "application/json"
|
|
690
|
-
},
|
|
691
|
-
body: JSON.stringify(initPayload)
|
|
692
|
-
});
|
|
693
|
-
if (!initRes.res.ok) {
|
|
694
|
-
const errorJson = initRes.json;
|
|
695
|
-
const msg = errorJson?.error || `Server initialisation failed: ${initRes.res.status}`;
|
|
696
|
-
throw new DropgateProtocolError(msg, {
|
|
697
|
-
details: initRes.json || initRes.text
|
|
698
|
-
});
|
|
699
|
-
}
|
|
700
|
-
const initJson = initRes.json;
|
|
701
|
-
const uploadId = initJson?.uploadId;
|
|
702
|
-
if (!uploadId || typeof uploadId !== "string") {
|
|
703
|
-
throw new DropgateProtocolError(
|
|
704
|
-
"Server did not return a valid uploadId."
|
|
705
|
-
);
|
|
706
|
-
}
|
|
707
|
-
currentUploadId = uploadId;
|
|
708
|
-
currentBaseUrl = baseUrl;
|
|
709
|
-
uploadState = "uploading";
|
|
710
|
-
const retries = Number.isFinite(retry.retries) ? retry.retries : 5;
|
|
711
|
-
const baseBackoffMs = Number.isFinite(retry.backoffMs) ? retry.backoffMs : 1e3;
|
|
712
|
-
const maxBackoffMs = Number.isFinite(retry.maxBackoffMs) ? retry.maxBackoffMs : 3e4;
|
|
713
|
-
for (let i = 0; i < totalChunks; i++) {
|
|
714
|
-
if (effectiveSignal?.aborted) {
|
|
715
|
-
throw effectiveSignal.reason || new DropgateAbortError();
|
|
1002
|
+
async finalize() {
|
|
1003
|
+
if (this.currentFile) {
|
|
1004
|
+
throw new Error("Cannot finalize with an open file. Call endFile() first.");
|
|
1005
|
+
}
|
|
1006
|
+
if (this.finalized) return;
|
|
1007
|
+
this.finalized = true;
|
|
1008
|
+
this.zip.end();
|
|
1009
|
+
await this.pendingWrites;
|
|
1010
|
+
}
|
|
1011
|
+
};
|
|
1012
|
+
|
|
1013
|
+
// src/p2p/utils.ts
|
|
1014
|
+
function isLocalhostHostname(hostname) {
|
|
1015
|
+
const host = String(hostname || "").toLowerCase();
|
|
1016
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
1017
|
+
}
|
|
1018
|
+
function isSecureContextForP2P(hostname, isSecureContext) {
|
|
1019
|
+
return Boolean(isSecureContext) || isLocalhostHostname(hostname || "");
|
|
1020
|
+
}
|
|
1021
|
+
function generateP2PCode(cryptoObj) {
|
|
1022
|
+
const crypto2 = cryptoObj || getDefaultCrypto();
|
|
1023
|
+
const letters = "ABCDEFGHJKLMNPQRSTUVWXYZ";
|
|
1024
|
+
if (crypto2) {
|
|
1025
|
+
const randomBytes = new Uint8Array(8);
|
|
1026
|
+
crypto2.getRandomValues(randomBytes);
|
|
1027
|
+
let letterPart = "";
|
|
1028
|
+
for (let i = 0; i < 4; i++) {
|
|
1029
|
+
letterPart += letters[randomBytes[i] % letters.length];
|
|
1030
|
+
}
|
|
1031
|
+
let numberPart = "";
|
|
1032
|
+
for (let i = 4; i < 8; i++) {
|
|
1033
|
+
numberPart += (randomBytes[i] % 10).toString();
|
|
1034
|
+
}
|
|
1035
|
+
return `${letterPart}-${numberPart}`;
|
|
1036
|
+
}
|
|
1037
|
+
let a = "";
|
|
1038
|
+
for (let i = 0; i < 4; i++) {
|
|
1039
|
+
a += letters[Math.floor(Math.random() * letters.length)];
|
|
1040
|
+
}
|
|
1041
|
+
let b = "";
|
|
1042
|
+
for (let i = 0; i < 4; i++) {
|
|
1043
|
+
b += Math.floor(Math.random() * 10);
|
|
1044
|
+
}
|
|
1045
|
+
return `${a}-${b}`;
|
|
1046
|
+
}
|
|
1047
|
+
function isP2PCodeLike(code) {
|
|
1048
|
+
return /^[A-Z]{4}-\d{4}$/.test(String(code || "").trim());
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// src/p2p/helpers.ts
|
|
1052
|
+
function resolvePeerConfig(userConfig, serverCaps) {
|
|
1053
|
+
return {
|
|
1054
|
+
path: userConfig.peerjsPath ?? serverCaps?.peerjsPath ?? "/peerjs",
|
|
1055
|
+
iceServers: userConfig.iceServers ?? serverCaps?.iceServers ?? []
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
function buildPeerOptions(config = {}) {
|
|
1059
|
+
const { host, port, peerjsPath = "/peerjs", secure = false, iceServers = [] } = config;
|
|
1060
|
+
const peerOpts = {
|
|
1061
|
+
host,
|
|
1062
|
+
path: peerjsPath,
|
|
1063
|
+
secure,
|
|
1064
|
+
config: { iceServers },
|
|
1065
|
+
debug: 0
|
|
1066
|
+
};
|
|
1067
|
+
if (port) {
|
|
1068
|
+
peerOpts.port = port;
|
|
1069
|
+
}
|
|
1070
|
+
return peerOpts;
|
|
1071
|
+
}
|
|
1072
|
+
async function createPeerWithRetries(opts) {
|
|
1073
|
+
const { code, codeGenerator, maxAttempts, buildPeer, onCode } = opts;
|
|
1074
|
+
let nextCode = code || codeGenerator();
|
|
1075
|
+
let peer = null;
|
|
1076
|
+
let lastError = null;
|
|
1077
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1078
|
+
onCode?.(nextCode, attempt);
|
|
1079
|
+
try {
|
|
1080
|
+
peer = await new Promise((resolve, reject) => {
|
|
1081
|
+
const instance = buildPeer(nextCode);
|
|
1082
|
+
instance.on("open", () => resolve(instance));
|
|
1083
|
+
instance.on("error", (err2) => {
|
|
1084
|
+
try {
|
|
1085
|
+
instance.destroy();
|
|
1086
|
+
} catch {
|
|
716
1087
|
}
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
1088
|
+
reject(err2);
|
|
1089
|
+
});
|
|
1090
|
+
});
|
|
1091
|
+
return { peer, code: nextCode };
|
|
1092
|
+
} catch (err2) {
|
|
1093
|
+
lastError = err2;
|
|
1094
|
+
nextCode = codeGenerator();
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
throw lastError || new DropgateNetworkError("Could not establish PeerJS connection.");
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// src/p2p/protocol.ts
|
|
1101
|
+
var P2P_PROTOCOL_VERSION = 3;
|
|
1102
|
+
function isP2PMessage(value) {
|
|
1103
|
+
if (!value || typeof value !== "object") return false;
|
|
1104
|
+
const msg = value;
|
|
1105
|
+
return typeof msg.t === "string" && [
|
|
1106
|
+
"hello",
|
|
1107
|
+
"file_list",
|
|
1108
|
+
"meta",
|
|
1109
|
+
"ready",
|
|
1110
|
+
"chunk",
|
|
1111
|
+
"chunk_ack",
|
|
1112
|
+
"file_end",
|
|
1113
|
+
"file_end_ack",
|
|
1114
|
+
"end",
|
|
1115
|
+
"end_ack",
|
|
1116
|
+
"ping",
|
|
1117
|
+
"pong",
|
|
1118
|
+
"error",
|
|
1119
|
+
"cancelled",
|
|
1120
|
+
"resume",
|
|
1121
|
+
"resume_ack"
|
|
1122
|
+
].includes(msg.t);
|
|
1123
|
+
}
|
|
1124
|
+
var P2P_CHUNK_SIZE = 64 * 1024;
|
|
1125
|
+
var P2P_MAX_UNACKED_CHUNKS = 32;
|
|
1126
|
+
var P2P_END_ACK_TIMEOUT_MS = 15e3;
|
|
1127
|
+
var P2P_END_ACK_RETRIES = 3;
|
|
1128
|
+
var P2P_END_ACK_RETRY_DELAY_MS = 100;
|
|
1129
|
+
var P2P_CLOSE_GRACE_PERIOD_MS = 2e3;
|
|
1130
|
+
|
|
1131
|
+
// src/p2p/send.ts
|
|
1132
|
+
var P2P_UNACKED_CHUNK_TIMEOUT_MS = 3e4;
|
|
1133
|
+
function generateSessionId() {
|
|
1134
|
+
return crypto.randomUUID();
|
|
1135
|
+
}
|
|
1136
|
+
var ALLOWED_TRANSITIONS = {
|
|
1137
|
+
initializing: ["listening", "closed"],
|
|
1138
|
+
listening: ["handshaking", "closed", "cancelled"],
|
|
1139
|
+
handshaking: ["negotiating", "closed", "cancelled"],
|
|
1140
|
+
negotiating: ["transferring", "closed", "cancelled"],
|
|
1141
|
+
transferring: ["finishing", "closed", "cancelled"],
|
|
1142
|
+
finishing: ["awaiting_ack", "closed", "cancelled"],
|
|
1143
|
+
awaiting_ack: ["completed", "closed", "cancelled"],
|
|
1144
|
+
completed: ["closed"],
|
|
1145
|
+
cancelled: ["closed"],
|
|
1146
|
+
closed: []
|
|
1147
|
+
};
|
|
1148
|
+
async function startP2PSend(opts) {
|
|
1149
|
+
const {
|
|
1150
|
+
file,
|
|
1151
|
+
Peer,
|
|
1152
|
+
serverInfo,
|
|
1153
|
+
host,
|
|
1154
|
+
port,
|
|
1155
|
+
peerjsPath,
|
|
1156
|
+
secure = false,
|
|
1157
|
+
iceServers,
|
|
1158
|
+
codeGenerator,
|
|
1159
|
+
cryptoObj,
|
|
1160
|
+
maxAttempts = 4,
|
|
1161
|
+
chunkSize = P2P_CHUNK_SIZE,
|
|
1162
|
+
endAckTimeoutMs = P2P_END_ACK_TIMEOUT_MS,
|
|
1163
|
+
bufferHighWaterMark = 8 * 1024 * 1024,
|
|
1164
|
+
bufferLowWaterMark = 2 * 1024 * 1024,
|
|
1165
|
+
heartbeatIntervalMs = 5e3,
|
|
1166
|
+
chunkAcknowledgments = true,
|
|
1167
|
+
maxUnackedChunks = P2P_MAX_UNACKED_CHUNKS,
|
|
1168
|
+
onCode,
|
|
1169
|
+
onStatus,
|
|
1170
|
+
onProgress,
|
|
1171
|
+
onComplete,
|
|
1172
|
+
onError,
|
|
1173
|
+
onDisconnect,
|
|
1174
|
+
onCancel,
|
|
1175
|
+
onConnectionHealth
|
|
1176
|
+
} = opts;
|
|
1177
|
+
const files = Array.isArray(file) ? file : [file];
|
|
1178
|
+
const isMultiFile = files.length > 1;
|
|
1179
|
+
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
|
|
1180
|
+
if (!files.length) {
|
|
1181
|
+
throw new DropgateValidationError("At least one file is required.");
|
|
1182
|
+
}
|
|
1183
|
+
if (!Peer) {
|
|
1184
|
+
throw new DropgateValidationError(
|
|
1185
|
+
"PeerJS Peer constructor is required. Install peerjs and pass it as the Peer option."
|
|
1186
|
+
);
|
|
1187
|
+
}
|
|
1188
|
+
const p2pCaps = serverInfo?.capabilities?.p2p;
|
|
1189
|
+
if (serverInfo && !p2pCaps?.enabled) {
|
|
1190
|
+
throw new DropgateValidationError("Direct transfer is disabled on this server.");
|
|
1191
|
+
}
|
|
1192
|
+
const { path: finalPath, iceServers: finalIceServers } = resolvePeerConfig(
|
|
1193
|
+
{ peerjsPath, iceServers },
|
|
1194
|
+
p2pCaps
|
|
1195
|
+
);
|
|
1196
|
+
const peerOpts = buildPeerOptions({
|
|
1197
|
+
host,
|
|
1198
|
+
port,
|
|
1199
|
+
peerjsPath: finalPath,
|
|
1200
|
+
secure,
|
|
1201
|
+
iceServers: finalIceServers
|
|
1202
|
+
});
|
|
1203
|
+
const finalCodeGenerator = codeGenerator || (() => generateP2PCode(cryptoObj));
|
|
1204
|
+
const buildPeer = (id) => new Peer(id, peerOpts);
|
|
1205
|
+
const { peer, code } = await createPeerWithRetries({
|
|
1206
|
+
code: null,
|
|
1207
|
+
codeGenerator: finalCodeGenerator,
|
|
1208
|
+
maxAttempts,
|
|
1209
|
+
buildPeer,
|
|
1210
|
+
onCode
|
|
1211
|
+
});
|
|
1212
|
+
const sessionId = generateSessionId();
|
|
1213
|
+
let state = "listening";
|
|
1214
|
+
let activeConn = null;
|
|
1215
|
+
let sentBytes = 0;
|
|
1216
|
+
let heartbeatTimer = null;
|
|
1217
|
+
let healthCheckTimer = null;
|
|
1218
|
+
let lastActivityTime = Date.now();
|
|
1219
|
+
const unackedChunks = /* @__PURE__ */ new Map();
|
|
1220
|
+
let nextSeq = 0;
|
|
1221
|
+
let ackResolvers = [];
|
|
1222
|
+
let transferEverStarted = false;
|
|
1223
|
+
const connectionAttempts = [];
|
|
1224
|
+
const MAX_CONNECTION_ATTEMPTS = 10;
|
|
1225
|
+
const CONNECTION_RATE_WINDOW_MS = 1e4;
|
|
1226
|
+
const transitionTo = (newState) => {
|
|
1227
|
+
if (!ALLOWED_TRANSITIONS[state].includes(newState)) {
|
|
1228
|
+
console.warn(`[P2P Send] Invalid state transition: ${state} -> ${newState}`);
|
|
1229
|
+
return false;
|
|
1230
|
+
}
|
|
1231
|
+
state = newState;
|
|
1232
|
+
return true;
|
|
1233
|
+
};
|
|
1234
|
+
const reportProgress = (data) => {
|
|
1235
|
+
if (isStopped()) return;
|
|
1236
|
+
const safeTotal = Number.isFinite(data.total) && data.total > 0 ? data.total : totalSize;
|
|
1237
|
+
const safeReceived = Math.min(Number(data.received) || 0, safeTotal || 0);
|
|
1238
|
+
const percent = safeTotal ? safeReceived / safeTotal * 100 : 0;
|
|
1239
|
+
onProgress?.({ processedBytes: safeReceived, totalBytes: safeTotal, percent });
|
|
1240
|
+
};
|
|
1241
|
+
const safeError = (err2) => {
|
|
1242
|
+
if (state === "closed" || state === "completed" || state === "cancelled") return;
|
|
1243
|
+
transitionTo("closed");
|
|
1244
|
+
onError?.(err2);
|
|
1245
|
+
cleanup();
|
|
1246
|
+
};
|
|
1247
|
+
const safeComplete = () => {
|
|
1248
|
+
if (state !== "awaiting_ack" && state !== "finishing") return;
|
|
1249
|
+
transitionTo("completed");
|
|
1250
|
+
onComplete?.();
|
|
1251
|
+
cleanup();
|
|
1252
|
+
};
|
|
1253
|
+
const cleanup = () => {
|
|
1254
|
+
if (heartbeatTimer) {
|
|
1255
|
+
clearInterval(heartbeatTimer);
|
|
1256
|
+
heartbeatTimer = null;
|
|
1257
|
+
}
|
|
1258
|
+
if (healthCheckTimer) {
|
|
1259
|
+
clearInterval(healthCheckTimer);
|
|
1260
|
+
healthCheckTimer = null;
|
|
1261
|
+
}
|
|
1262
|
+
ackResolvers.forEach((resolve) => resolve());
|
|
1263
|
+
ackResolvers = [];
|
|
1264
|
+
unackedChunks.clear();
|
|
1265
|
+
if (typeof window !== "undefined") {
|
|
1266
|
+
window.removeEventListener("beforeunload", handleUnload);
|
|
1267
|
+
}
|
|
1268
|
+
try {
|
|
1269
|
+
activeConn?.close();
|
|
1270
|
+
} catch {
|
|
1271
|
+
}
|
|
1272
|
+
try {
|
|
1273
|
+
peer.destroy();
|
|
1274
|
+
} catch {
|
|
1275
|
+
}
|
|
1276
|
+
};
|
|
1277
|
+
const handleUnload = () => {
|
|
1278
|
+
try {
|
|
1279
|
+
activeConn?.send({ t: "error", message: "Sender closed the connection." });
|
|
1280
|
+
} catch {
|
|
1281
|
+
}
|
|
1282
|
+
stop();
|
|
1283
|
+
};
|
|
1284
|
+
if (typeof window !== "undefined") {
|
|
1285
|
+
window.addEventListener("beforeunload", handleUnload);
|
|
1286
|
+
}
|
|
1287
|
+
const stop = () => {
|
|
1288
|
+
if (state === "closed" || state === "cancelled") return;
|
|
1289
|
+
if (state === "completed") {
|
|
1290
|
+
cleanup();
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
const wasActive = state === "transferring" || state === "finishing" || state === "awaiting_ack";
|
|
1294
|
+
transitionTo("cancelled");
|
|
1295
|
+
try {
|
|
1296
|
+
if (activeConn && activeConn.open) {
|
|
1297
|
+
activeConn.send({ t: "cancelled", message: "Sender cancelled the transfer." });
|
|
1298
|
+
}
|
|
1299
|
+
} catch {
|
|
1300
|
+
}
|
|
1301
|
+
if (wasActive && onCancel) {
|
|
1302
|
+
onCancel({ cancelledBy: "sender" });
|
|
1303
|
+
}
|
|
1304
|
+
cleanup();
|
|
1305
|
+
};
|
|
1306
|
+
const isStopped = () => state === "closed" || state === "cancelled";
|
|
1307
|
+
const startHealthMonitoring = (conn) => {
|
|
1308
|
+
if (!onConnectionHealth) return;
|
|
1309
|
+
healthCheckTimer = setInterval(() => {
|
|
1310
|
+
if (isStopped()) return;
|
|
1311
|
+
const dc = conn._dc;
|
|
1312
|
+
if (!dc) return;
|
|
1313
|
+
const health = {
|
|
1314
|
+
iceConnectionState: dc.readyState === "open" ? "connected" : "disconnected",
|
|
1315
|
+
bufferedAmount: dc.bufferedAmount,
|
|
1316
|
+
lastActivityMs: Date.now() - lastActivityTime
|
|
1317
|
+
};
|
|
1318
|
+
onConnectionHealth(health);
|
|
1319
|
+
}, 2e3);
|
|
1320
|
+
};
|
|
1321
|
+
const handleChunkAck = (msg) => {
|
|
1322
|
+
lastActivityTime = Date.now();
|
|
1323
|
+
unackedChunks.delete(msg.seq);
|
|
1324
|
+
reportProgress({ received: msg.received, total: totalSize });
|
|
1325
|
+
const resolver = ackResolvers.shift();
|
|
1326
|
+
if (resolver) resolver();
|
|
1327
|
+
};
|
|
1328
|
+
const waitForAck = () => {
|
|
1329
|
+
return new Promise((resolve) => {
|
|
1330
|
+
ackResolvers.push(resolve);
|
|
1331
|
+
});
|
|
1332
|
+
};
|
|
1333
|
+
const sendChunk = async (conn, data, offset, fileTotal) => {
|
|
1334
|
+
if (chunkAcknowledgments) {
|
|
1335
|
+
while (unackedChunks.size >= maxUnackedChunks) {
|
|
1336
|
+
const now = Date.now();
|
|
1337
|
+
for (const [_seq, chunk] of unackedChunks) {
|
|
1338
|
+
if (now - chunk.sentAt > P2P_UNACKED_CHUNK_TIMEOUT_MS) {
|
|
1339
|
+
throw new DropgateNetworkError("Receiver stopped acknowledging chunks");
|
|
737
1340
|
}
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
1341
|
+
}
|
|
1342
|
+
await Promise.race([
|
|
1343
|
+
waitForAck(),
|
|
1344
|
+
sleep(1e3)
|
|
1345
|
+
// Timeout to prevent deadlock
|
|
1346
|
+
]);
|
|
1347
|
+
if (isStopped()) return;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
const seq = nextSeq++;
|
|
1351
|
+
if (chunkAcknowledgments) {
|
|
1352
|
+
unackedChunks.set(seq, { offset, size: data.byteLength, sentAt: Date.now() });
|
|
1353
|
+
}
|
|
1354
|
+
conn.send({ t: "chunk", seq, offset, size: data.byteLength, total: fileTotal ?? totalSize });
|
|
1355
|
+
conn.send(data);
|
|
1356
|
+
sentBytes += data.byteLength;
|
|
1357
|
+
const dc = conn._dc;
|
|
1358
|
+
if (dc && bufferHighWaterMark > 0) {
|
|
1359
|
+
while (dc.bufferedAmount > bufferHighWaterMark) {
|
|
1360
|
+
await new Promise((resolve) => {
|
|
1361
|
+
const fallback = setTimeout(resolve, 60);
|
|
1362
|
+
try {
|
|
1363
|
+
dc.addEventListener(
|
|
1364
|
+
"bufferedamountlow",
|
|
1365
|
+
() => {
|
|
1366
|
+
clearTimeout(fallback);
|
|
1367
|
+
resolve();
|
|
1368
|
+
},
|
|
1369
|
+
{ once: true }
|
|
741
1370
|
);
|
|
1371
|
+
} catch {
|
|
742
1372
|
}
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
1373
|
+
});
|
|
1374
|
+
if (isStopped()) return;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
};
|
|
1378
|
+
const waitForEndAck = async (conn, ackPromise) => {
|
|
1379
|
+
const baseTimeout = endAckTimeoutMs;
|
|
1380
|
+
for (let attempt = 0; attempt < P2P_END_ACK_RETRIES; attempt++) {
|
|
1381
|
+
conn.send({ t: "end", attempt });
|
|
1382
|
+
const timeout = baseTimeout * Math.pow(1.5, attempt);
|
|
1383
|
+
const result = await Promise.race([
|
|
1384
|
+
ackPromise,
|
|
1385
|
+
sleep(timeout).then(() => null)
|
|
1386
|
+
]);
|
|
1387
|
+
if (result && result.t === "end_ack") {
|
|
1388
|
+
return result;
|
|
1389
|
+
}
|
|
1390
|
+
if (isStopped()) {
|
|
1391
|
+
throw new DropgateNetworkError("Connection closed during completion.");
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
throw new DropgateNetworkError("Receiver did not confirm completion after retries.");
|
|
1395
|
+
};
|
|
1396
|
+
peer.on("connection", (conn) => {
|
|
1397
|
+
if (isStopped()) return;
|
|
1398
|
+
const now = Date.now();
|
|
1399
|
+
while (connectionAttempts.length > 0 && connectionAttempts[0] < now - CONNECTION_RATE_WINDOW_MS) {
|
|
1400
|
+
connectionAttempts.shift();
|
|
1401
|
+
}
|
|
1402
|
+
if (connectionAttempts.length >= MAX_CONNECTION_ATTEMPTS) {
|
|
1403
|
+
console.warn("[P2P Send] Connection rate limit exceeded, rejecting connection");
|
|
1404
|
+
try {
|
|
1405
|
+
conn.send({ t: "error", message: "Too many connection attempts. Please wait." });
|
|
1406
|
+
} catch {
|
|
1407
|
+
}
|
|
1408
|
+
try {
|
|
1409
|
+
conn.close();
|
|
1410
|
+
} catch {
|
|
1411
|
+
}
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
connectionAttempts.push(now);
|
|
1415
|
+
if (activeConn) {
|
|
1416
|
+
const isOldConnOpen = activeConn.open !== false;
|
|
1417
|
+
if (isOldConnOpen && state === "transferring") {
|
|
1418
|
+
try {
|
|
1419
|
+
conn.send({ t: "error", message: "Transfer already in progress." });
|
|
1420
|
+
} catch {
|
|
772
1421
|
}
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
this.fetchFn,
|
|
777
|
-
`${baseUrl}/upload/complete`,
|
|
778
|
-
{
|
|
779
|
-
method: "POST",
|
|
780
|
-
timeoutMs: timeouts.completeMs ?? 3e4,
|
|
781
|
-
signal: effectiveSignal,
|
|
782
|
-
headers: {
|
|
783
|
-
"Content-Type": "application/json",
|
|
784
|
-
Accept: "application/json"
|
|
785
|
-
},
|
|
786
|
-
body: JSON.stringify({ uploadId })
|
|
787
|
-
}
|
|
788
|
-
);
|
|
789
|
-
if (!completeRes.res.ok) {
|
|
790
|
-
const errorJson = completeRes.json;
|
|
791
|
-
const msg = errorJson?.error || "Finalisation failed.";
|
|
792
|
-
throw new DropgateProtocolError(msg, {
|
|
793
|
-
details: completeRes.json || completeRes.text
|
|
794
|
-
});
|
|
1422
|
+
try {
|
|
1423
|
+
conn.close();
|
|
1424
|
+
} catch {
|
|
795
1425
|
}
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
);
|
|
1426
|
+
return;
|
|
1427
|
+
} else if (!isOldConnOpen) {
|
|
1428
|
+
try {
|
|
1429
|
+
activeConn.close();
|
|
1430
|
+
} catch {
|
|
802
1431
|
}
|
|
803
|
-
|
|
804
|
-
if (
|
|
805
|
-
|
|
1432
|
+
activeConn = null;
|
|
1433
|
+
if (transferEverStarted) {
|
|
1434
|
+
try {
|
|
1435
|
+
conn.send({ t: "error", message: "Transfer already started with another receiver. Cannot reconnect." });
|
|
1436
|
+
} catch {
|
|
1437
|
+
}
|
|
1438
|
+
try {
|
|
1439
|
+
conn.close();
|
|
1440
|
+
} catch {
|
|
1441
|
+
}
|
|
1442
|
+
return;
|
|
806
1443
|
}
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
};
|
|
816
|
-
} catch (err) {
|
|
817
|
-
if (err instanceof Error && (err.name === "AbortError" || err.message?.includes("abort"))) {
|
|
818
|
-
uploadState = "cancelled";
|
|
819
|
-
onCancel?.();
|
|
820
|
-
} else {
|
|
821
|
-
uploadState = "error";
|
|
1444
|
+
state = "listening";
|
|
1445
|
+
sentBytes = 0;
|
|
1446
|
+
nextSeq = 0;
|
|
1447
|
+
unackedChunks.clear();
|
|
1448
|
+
} else {
|
|
1449
|
+
try {
|
|
1450
|
+
conn.send({ t: "error", message: "Another receiver is already connected." });
|
|
1451
|
+
} catch {
|
|
822
1452
|
}
|
|
823
|
-
|
|
1453
|
+
try {
|
|
1454
|
+
conn.close();
|
|
1455
|
+
} catch {
|
|
1456
|
+
}
|
|
1457
|
+
return;
|
|
824
1458
|
}
|
|
825
|
-
}
|
|
826
|
-
|
|
1459
|
+
}
|
|
1460
|
+
activeConn = conn;
|
|
1461
|
+
transitionTo("handshaking");
|
|
1462
|
+
if (!isStopped()) onStatus?.({ phase: "connected", message: "Receiver connected." });
|
|
1463
|
+
lastActivityTime = Date.now();
|
|
1464
|
+
let helloResolve = null;
|
|
1465
|
+
let readyResolve = null;
|
|
1466
|
+
let endAckResolve = null;
|
|
1467
|
+
let fileEndAckResolve = null;
|
|
1468
|
+
const helloPromise = new Promise((resolve) => {
|
|
1469
|
+
helloResolve = resolve;
|
|
1470
|
+
});
|
|
1471
|
+
const readyPromise = new Promise((resolve) => {
|
|
1472
|
+
readyResolve = resolve;
|
|
1473
|
+
});
|
|
1474
|
+
const endAckPromise = new Promise((resolve) => {
|
|
1475
|
+
endAckResolve = resolve;
|
|
1476
|
+
});
|
|
1477
|
+
conn.on("data", (data) => {
|
|
1478
|
+
lastActivityTime = Date.now();
|
|
1479
|
+
if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
if (!isP2PMessage(data)) return;
|
|
1483
|
+
const msg = data;
|
|
1484
|
+
switch (msg.t) {
|
|
1485
|
+
case "hello":
|
|
1486
|
+
helloResolve?.(msg.protocolVersion);
|
|
1487
|
+
break;
|
|
1488
|
+
case "ready":
|
|
1489
|
+
if (!isStopped()) onStatus?.({ phase: "transferring", message: "Receiver accepted. Starting transfer..." });
|
|
1490
|
+
readyResolve?.();
|
|
1491
|
+
break;
|
|
1492
|
+
case "chunk_ack":
|
|
1493
|
+
handleChunkAck(msg);
|
|
1494
|
+
break;
|
|
1495
|
+
case "file_end_ack":
|
|
1496
|
+
fileEndAckResolve?.(msg);
|
|
1497
|
+
break;
|
|
1498
|
+
case "end_ack":
|
|
1499
|
+
endAckResolve?.(msg);
|
|
1500
|
+
break;
|
|
1501
|
+
case "pong":
|
|
1502
|
+
break;
|
|
1503
|
+
case "error":
|
|
1504
|
+
safeError(new DropgateNetworkError(msg.message || "Receiver reported an error."));
|
|
1505
|
+
break;
|
|
1506
|
+
case "cancelled":
|
|
1507
|
+
if (state === "cancelled" || state === "closed" || state === "completed") return;
|
|
1508
|
+
transitionTo("cancelled");
|
|
1509
|
+
onCancel?.({ cancelledBy: "receiver", message: msg.reason });
|
|
1510
|
+
cleanup();
|
|
1511
|
+
break;
|
|
1512
|
+
}
|
|
1513
|
+
});
|
|
1514
|
+
conn.on("open", async () => {
|
|
827
1515
|
try {
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
},
|
|
835
|
-
body: JSON.stringify({ uploadId })
|
|
1516
|
+
if (isStopped()) return;
|
|
1517
|
+
startHealthMonitoring(conn);
|
|
1518
|
+
conn.send({
|
|
1519
|
+
t: "hello",
|
|
1520
|
+
protocolVersion: P2P_PROTOCOL_VERSION,
|
|
1521
|
+
sessionId
|
|
836
1522
|
});
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
1523
|
+
const receiverVersion = await Promise.race([
|
|
1524
|
+
helloPromise,
|
|
1525
|
+
sleep(1e4).then(() => null)
|
|
1526
|
+
]);
|
|
1527
|
+
if (isStopped()) return;
|
|
1528
|
+
if (receiverVersion === null) {
|
|
1529
|
+
throw new DropgateNetworkError("Receiver did not respond to handshake.");
|
|
1530
|
+
} else if (receiverVersion !== P2P_PROTOCOL_VERSION) {
|
|
1531
|
+
throw new DropgateNetworkError(
|
|
1532
|
+
`Protocol version mismatch: sender v${P2P_PROTOCOL_VERSION}, receiver v${receiverVersion}`
|
|
1533
|
+
);
|
|
1534
|
+
}
|
|
1535
|
+
transitionTo("negotiating");
|
|
1536
|
+
if (!isStopped()) onStatus?.({ phase: "waiting", message: "Connected. Waiting for receiver to accept..." });
|
|
1537
|
+
if (isMultiFile) {
|
|
1538
|
+
conn.send({
|
|
1539
|
+
t: "file_list",
|
|
1540
|
+
fileCount: files.length,
|
|
1541
|
+
files: files.map((f) => ({ name: f.name, size: f.size, mime: f.type || "application/octet-stream" })),
|
|
1542
|
+
totalSize
|
|
847
1543
|
});
|
|
848
1544
|
}
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
1545
|
+
conn.send({
|
|
1546
|
+
t: "meta",
|
|
1547
|
+
sessionId,
|
|
1548
|
+
name: files[0].name,
|
|
1549
|
+
size: files[0].size,
|
|
1550
|
+
mime: files[0].type || "application/octet-stream",
|
|
1551
|
+
...isMultiFile ? { fileIndex: 0 } : {}
|
|
1552
|
+
});
|
|
1553
|
+
const dc = conn._dc;
|
|
1554
|
+
if (dc && Number.isFinite(bufferLowWaterMark)) {
|
|
1555
|
+
try {
|
|
1556
|
+
dc.bufferedAmountLowThreshold = bufferLowWaterMark;
|
|
1557
|
+
} catch {
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
await readyPromise;
|
|
1561
|
+
if (isStopped()) return;
|
|
1562
|
+
if (heartbeatIntervalMs > 0) {
|
|
1563
|
+
heartbeatTimer = setInterval(() => {
|
|
1564
|
+
if (state === "transferring" || state === "finishing" || state === "awaiting_ack") {
|
|
1565
|
+
try {
|
|
1566
|
+
conn.send({ t: "ping", timestamp: Date.now() });
|
|
1567
|
+
} catch {
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
}, heartbeatIntervalMs);
|
|
1571
|
+
}
|
|
1572
|
+
transitionTo("transferring");
|
|
1573
|
+
transferEverStarted = true;
|
|
1574
|
+
let overallSentBytes = 0;
|
|
1575
|
+
for (let fi = 0; fi < files.length; fi++) {
|
|
1576
|
+
const currentFile = files[fi];
|
|
1577
|
+
if (isMultiFile && fi > 0) {
|
|
1578
|
+
conn.send({
|
|
1579
|
+
t: "meta",
|
|
1580
|
+
sessionId,
|
|
1581
|
+
name: currentFile.name,
|
|
1582
|
+
size: currentFile.size,
|
|
1583
|
+
mime: currentFile.type || "application/octet-stream",
|
|
1584
|
+
fileIndex: fi
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1587
|
+
for (let offset = 0; offset < currentFile.size; offset += chunkSize) {
|
|
1588
|
+
if (isStopped()) return;
|
|
1589
|
+
const slice = currentFile.slice(offset, offset + chunkSize);
|
|
1590
|
+
const buf = await slice.arrayBuffer();
|
|
1591
|
+
if (isStopped()) return;
|
|
1592
|
+
await sendChunk(conn, buf, offset, currentFile.size);
|
|
1593
|
+
overallSentBytes += buf.byteLength;
|
|
1594
|
+
reportProgress({ received: overallSentBytes, total: totalSize });
|
|
1595
|
+
}
|
|
1596
|
+
if (isStopped()) return;
|
|
1597
|
+
if (isMultiFile) {
|
|
1598
|
+
const fileEndAckPromise = new Promise((resolve) => {
|
|
1599
|
+
fileEndAckResolve = resolve;
|
|
1600
|
+
});
|
|
1601
|
+
conn.send({ t: "file_end", fileIndex: fi });
|
|
1602
|
+
const feAck = await Promise.race([
|
|
1603
|
+
fileEndAckPromise,
|
|
1604
|
+
sleep(endAckTimeoutMs).then(() => null)
|
|
1605
|
+
]);
|
|
1606
|
+
if (isStopped()) return;
|
|
1607
|
+
if (!feAck) {
|
|
1608
|
+
throw new DropgateNetworkError(`Receiver did not confirm receipt of file ${fi + 1}/${files.length}.`);
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
if (isStopped()) return;
|
|
1613
|
+
transitionTo("finishing");
|
|
1614
|
+
transitionTo("awaiting_ack");
|
|
1615
|
+
const ackResult = await waitForEndAck(conn, endAckPromise);
|
|
1616
|
+
if (isStopped()) return;
|
|
1617
|
+
const ackTotal = Number(ackResult.total) || totalSize;
|
|
1618
|
+
const ackReceived = Number(ackResult.received) || 0;
|
|
1619
|
+
if (ackTotal && ackReceived < ackTotal) {
|
|
1620
|
+
throw new DropgateNetworkError("Receiver reported an incomplete transfer.");
|
|
1621
|
+
}
|
|
1622
|
+
reportProgress({ received: ackReceived || ackTotal, total: ackTotal });
|
|
1623
|
+
safeComplete();
|
|
1624
|
+
} catch (err2) {
|
|
1625
|
+
safeError(err2);
|
|
886
1626
|
}
|
|
887
|
-
};
|
|
888
|
-
if (!fileId || typeof fileId !== "string") {
|
|
889
|
-
throw new DropgateValidationError("File ID is required.");
|
|
890
|
-
}
|
|
891
|
-
progress({ phase: "server-info", text: "Checking server...", processedBytes: 0, totalBytes: 0, percent: 0 });
|
|
892
|
-
const compat = await this.checkCompatibility({
|
|
893
|
-
host,
|
|
894
|
-
port,
|
|
895
|
-
secure,
|
|
896
|
-
timeoutMs,
|
|
897
|
-
signal
|
|
898
1627
|
});
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
let metadata;
|
|
907
|
-
try {
|
|
908
|
-
const metaRes = await this.fetchFn(`${baseUrl}/api/file/${fileId}/meta`, {
|
|
909
|
-
method: "GET",
|
|
910
|
-
headers: { Accept: "application/json" },
|
|
911
|
-
signal: metaSignal
|
|
912
|
-
});
|
|
913
|
-
if (!metaRes.ok) {
|
|
914
|
-
if (metaRes.status === 404) {
|
|
915
|
-
throw new DropgateProtocolError("File not found or has expired.");
|
|
916
|
-
}
|
|
917
|
-
throw new DropgateProtocolError(`Failed to fetch file metadata (status ${metaRes.status}).`);
|
|
1628
|
+
conn.on("error", (err2) => {
|
|
1629
|
+
safeError(err2);
|
|
1630
|
+
});
|
|
1631
|
+
conn.on("close", () => {
|
|
1632
|
+
if (state === "closed" || state === "completed" || state === "cancelled") {
|
|
1633
|
+
cleanup();
|
|
1634
|
+
return;
|
|
918
1635
|
}
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
1636
|
+
if (state === "awaiting_ack") {
|
|
1637
|
+
setTimeout(() => {
|
|
1638
|
+
if (state === "awaiting_ack") {
|
|
1639
|
+
safeError(new DropgateNetworkError("Connection closed while awaiting confirmation."));
|
|
1640
|
+
}
|
|
1641
|
+
}, P2P_CLOSE_GRACE_PERIOD_MS);
|
|
1642
|
+
return;
|
|
924
1643
|
}
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
1644
|
+
if (state === "transferring" || state === "finishing") {
|
|
1645
|
+
transitionTo("cancelled");
|
|
1646
|
+
onCancel?.({ cancelledBy: "receiver" });
|
|
1647
|
+
cleanup();
|
|
1648
|
+
} else {
|
|
1649
|
+
activeConn = null;
|
|
1650
|
+
state = "listening";
|
|
1651
|
+
sentBytes = 0;
|
|
1652
|
+
nextSeq = 0;
|
|
1653
|
+
unackedChunks.clear();
|
|
1654
|
+
onDisconnect?.();
|
|
1655
|
+
}
|
|
1656
|
+
});
|
|
1657
|
+
});
|
|
1658
|
+
return {
|
|
1659
|
+
peer,
|
|
1660
|
+
code,
|
|
1661
|
+
sessionId,
|
|
1662
|
+
stop,
|
|
1663
|
+
getStatus: () => state,
|
|
1664
|
+
getBytesSent: () => sentBytes,
|
|
1665
|
+
getConnectedPeerId: () => {
|
|
1666
|
+
if (!activeConn) return null;
|
|
1667
|
+
return activeConn.peer || null;
|
|
928
1668
|
}
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
// src/p2p/receive.ts
|
|
1673
|
+
var ALLOWED_TRANSITIONS2 = {
|
|
1674
|
+
initializing: ["connecting", "closed"],
|
|
1675
|
+
connecting: ["handshaking", "closed", "cancelled"],
|
|
1676
|
+
handshaking: ["negotiating", "closed", "cancelled"],
|
|
1677
|
+
negotiating: ["transferring", "closed", "cancelled"],
|
|
1678
|
+
transferring: ["completed", "closed", "cancelled"],
|
|
1679
|
+
completed: ["closed"],
|
|
1680
|
+
cancelled: ["closed"],
|
|
1681
|
+
closed: []
|
|
1682
|
+
};
|
|
1683
|
+
async function startP2PReceive(opts) {
|
|
1684
|
+
const {
|
|
1685
|
+
code,
|
|
1686
|
+
Peer,
|
|
1687
|
+
serverInfo,
|
|
1688
|
+
host,
|
|
1689
|
+
port,
|
|
1690
|
+
peerjsPath,
|
|
1691
|
+
secure = false,
|
|
1692
|
+
iceServers,
|
|
1693
|
+
autoReady = true,
|
|
1694
|
+
watchdogTimeoutMs = 15e3,
|
|
1695
|
+
onStatus,
|
|
1696
|
+
onMeta,
|
|
1697
|
+
onData,
|
|
1698
|
+
onProgress,
|
|
1699
|
+
onFileStart,
|
|
1700
|
+
onFileEnd,
|
|
1701
|
+
onComplete,
|
|
1702
|
+
onError,
|
|
1703
|
+
onDisconnect,
|
|
1704
|
+
onCancel
|
|
1705
|
+
} = opts;
|
|
1706
|
+
if (!code) {
|
|
1707
|
+
throw new DropgateValidationError("No sharing code was provided.");
|
|
1708
|
+
}
|
|
1709
|
+
if (!Peer) {
|
|
1710
|
+
throw new DropgateValidationError(
|
|
1711
|
+
"PeerJS Peer constructor is required. Install peerjs and pass it as the Peer option."
|
|
1712
|
+
);
|
|
1713
|
+
}
|
|
1714
|
+
const p2pCaps = serverInfo?.capabilities?.p2p;
|
|
1715
|
+
if (serverInfo && !p2pCaps?.enabled) {
|
|
1716
|
+
throw new DropgateValidationError("Direct transfer is disabled on this server.");
|
|
1717
|
+
}
|
|
1718
|
+
const normalizedCode = String(code).trim().replace(/\s+/g, "").toUpperCase();
|
|
1719
|
+
if (!isP2PCodeLike(normalizedCode)) {
|
|
1720
|
+
throw new DropgateValidationError("Invalid direct transfer code.");
|
|
1721
|
+
}
|
|
1722
|
+
const { path: finalPath, iceServers: finalIceServers } = resolvePeerConfig(
|
|
1723
|
+
{ peerjsPath, iceServers },
|
|
1724
|
+
p2pCaps
|
|
1725
|
+
);
|
|
1726
|
+
const peerOpts = buildPeerOptions({
|
|
1727
|
+
host,
|
|
1728
|
+
port,
|
|
1729
|
+
peerjsPath: finalPath,
|
|
1730
|
+
secure,
|
|
1731
|
+
iceServers: finalIceServers
|
|
1732
|
+
});
|
|
1733
|
+
const peer = new Peer(void 0, peerOpts);
|
|
1734
|
+
let state = "initializing";
|
|
1735
|
+
let total = 0;
|
|
1736
|
+
let received = 0;
|
|
1737
|
+
let currentSessionId = null;
|
|
1738
|
+
let writeQueue = Promise.resolve();
|
|
1739
|
+
let watchdogTimer = null;
|
|
1740
|
+
let activeConn = null;
|
|
1741
|
+
let pendingChunk = null;
|
|
1742
|
+
let fileList = null;
|
|
1743
|
+
let currentFileReceived = 0;
|
|
1744
|
+
let totalReceivedAllFiles = 0;
|
|
1745
|
+
let expectedChunkSeq = 0;
|
|
1746
|
+
let writeQueueDepth = 0;
|
|
1747
|
+
const MAX_WRITE_QUEUE_DEPTH = 100;
|
|
1748
|
+
const MAX_FILE_COUNT = 1e4;
|
|
1749
|
+
const transitionTo = (newState) => {
|
|
1750
|
+
if (!ALLOWED_TRANSITIONS2[state].includes(newState)) {
|
|
1751
|
+
console.warn(`[P2P Receive] Invalid state transition: ${state} -> ${newState}`);
|
|
1752
|
+
return false;
|
|
937
1753
|
}
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1754
|
+
state = newState;
|
|
1755
|
+
return true;
|
|
1756
|
+
};
|
|
1757
|
+
const isStopped = () => state === "closed" || state === "cancelled";
|
|
1758
|
+
const resetWatchdog = () => {
|
|
1759
|
+
if (watchdogTimeoutMs <= 0) return;
|
|
1760
|
+
if (watchdogTimer) {
|
|
1761
|
+
clearTimeout(watchdogTimer);
|
|
1762
|
+
}
|
|
1763
|
+
watchdogTimer = setTimeout(() => {
|
|
1764
|
+
if (state === "transferring") {
|
|
1765
|
+
safeError(new DropgateNetworkError("Connection timed out (no data received)."));
|
|
946
1766
|
}
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
1767
|
+
}, watchdogTimeoutMs);
|
|
1768
|
+
};
|
|
1769
|
+
const clearWatchdog = () => {
|
|
1770
|
+
if (watchdogTimer) {
|
|
1771
|
+
clearTimeout(watchdogTimer);
|
|
1772
|
+
watchdogTimer = null;
|
|
1773
|
+
}
|
|
1774
|
+
};
|
|
1775
|
+
const safeError = (err2) => {
|
|
1776
|
+
if (state === "closed" || state === "completed" || state === "cancelled") return;
|
|
1777
|
+
transitionTo("closed");
|
|
1778
|
+
onError?.(err2);
|
|
1779
|
+
cleanup();
|
|
1780
|
+
};
|
|
1781
|
+
const safeComplete = (completeData) => {
|
|
1782
|
+
if (state !== "transferring") return;
|
|
1783
|
+
transitionTo("completed");
|
|
1784
|
+
onComplete?.(completeData);
|
|
1785
|
+
};
|
|
1786
|
+
const cleanup = () => {
|
|
1787
|
+
clearWatchdog();
|
|
1788
|
+
if (typeof window !== "undefined") {
|
|
1789
|
+
window.removeEventListener("beforeunload", handleUnload);
|
|
1790
|
+
}
|
|
1791
|
+
try {
|
|
1792
|
+
peer.destroy();
|
|
1793
|
+
} catch {
|
|
1794
|
+
}
|
|
1795
|
+
};
|
|
1796
|
+
const handleUnload = () => {
|
|
1797
|
+
try {
|
|
1798
|
+
activeConn?.send({ t: "error", message: "Receiver closed the connection." });
|
|
1799
|
+
} catch {
|
|
1800
|
+
}
|
|
1801
|
+
stop();
|
|
1802
|
+
};
|
|
1803
|
+
if (typeof window !== "undefined") {
|
|
1804
|
+
window.addEventListener("beforeunload", handleUnload);
|
|
1805
|
+
}
|
|
1806
|
+
const stop = () => {
|
|
1807
|
+
if (state === "closed" || state === "cancelled") return;
|
|
1808
|
+
if (state === "completed") {
|
|
1809
|
+
cleanup();
|
|
1810
|
+
return;
|
|
1811
|
+
}
|
|
1812
|
+
const wasActive = state === "transferring";
|
|
1813
|
+
transitionTo("cancelled");
|
|
1814
|
+
try {
|
|
1815
|
+
if (activeConn && activeConn.open) {
|
|
1816
|
+
activeConn.send({ t: "cancelled", reason: "Receiver cancelled the transfer." });
|
|
961
1817
|
}
|
|
962
|
-
}
|
|
963
|
-
filename = metadata.filename || "file";
|
|
1818
|
+
} catch {
|
|
964
1819
|
}
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1820
|
+
if (wasActive && onCancel) {
|
|
1821
|
+
onCancel({ cancelledBy: "receiver" });
|
|
1822
|
+
}
|
|
1823
|
+
cleanup();
|
|
1824
|
+
};
|
|
1825
|
+
const sendChunkAck = (conn, seq) => {
|
|
970
1826
|
try {
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1827
|
+
conn.send({ t: "chunk_ack", seq, received });
|
|
1828
|
+
} catch {
|
|
1829
|
+
}
|
|
1830
|
+
};
|
|
1831
|
+
peer.on("error", (err2) => {
|
|
1832
|
+
safeError(err2);
|
|
1833
|
+
});
|
|
1834
|
+
peer.on("open", () => {
|
|
1835
|
+
transitionTo("connecting");
|
|
1836
|
+
const conn = peer.connect(normalizedCode, { reliable: true });
|
|
1837
|
+
activeConn = conn;
|
|
1838
|
+
conn.on("open", () => {
|
|
1839
|
+
transitionTo("handshaking");
|
|
1840
|
+
onStatus?.({ phase: "connected", message: "Connected." });
|
|
1841
|
+
conn.send({
|
|
1842
|
+
t: "hello",
|
|
1843
|
+
protocolVersion: P2P_PROTOCOL_VERSION,
|
|
1844
|
+
sessionId: ""
|
|
974
1845
|
});
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
const ENCRYPTED_CHUNK_SIZE = this.chunkSize + ENCRYPTION_OVERHEAD_PER_CHUNK;
|
|
984
|
-
const pendingChunks = [];
|
|
985
|
-
let pendingLength = 0;
|
|
986
|
-
const flushPending = () => {
|
|
987
|
-
if (pendingChunks.length === 0) return new Uint8Array(0);
|
|
988
|
-
if (pendingChunks.length === 1) {
|
|
989
|
-
const result2 = pendingChunks[0];
|
|
990
|
-
pendingChunks.length = 0;
|
|
991
|
-
pendingLength = 0;
|
|
992
|
-
return result2;
|
|
1846
|
+
});
|
|
1847
|
+
conn.on("data", async (data) => {
|
|
1848
|
+
try {
|
|
1849
|
+
if (data instanceof ArrayBuffer || ArrayBuffer.isView(data) || typeof Blob !== "undefined" && data instanceof Blob) {
|
|
1850
|
+
if (state !== "transferring") {
|
|
1851
|
+
throw new DropgateValidationError(
|
|
1852
|
+
"Received binary data before transfer was accepted. Possible malicious sender."
|
|
1853
|
+
);
|
|
993
1854
|
}
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
result.set(chunk, offset);
|
|
998
|
-
offset += chunk.length;
|
|
1855
|
+
resetWatchdog();
|
|
1856
|
+
if (writeQueueDepth >= MAX_WRITE_QUEUE_DEPTH) {
|
|
1857
|
+
throw new DropgateNetworkError("Write queue overflow - receiver cannot keep up");
|
|
999
1858
|
}
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1859
|
+
let bufPromise;
|
|
1860
|
+
if (data instanceof ArrayBuffer) {
|
|
1861
|
+
bufPromise = Promise.resolve(new Uint8Array(data));
|
|
1862
|
+
} else if (ArrayBuffer.isView(data)) {
|
|
1863
|
+
bufPromise = Promise.resolve(
|
|
1864
|
+
new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
|
|
1865
|
+
);
|
|
1866
|
+
} else if (typeof Blob !== "undefined" && data instanceof Blob) {
|
|
1867
|
+
bufPromise = data.arrayBuffer().then((buffer) => new Uint8Array(buffer));
|
|
1868
|
+
} else {
|
|
1869
|
+
return;
|
|
1007
1870
|
}
|
|
1008
|
-
const
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
const
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1871
|
+
const chunkSeq = pendingChunk?.seq ?? -1;
|
|
1872
|
+
const expectedSize = pendingChunk?.size;
|
|
1873
|
+
pendingChunk = null;
|
|
1874
|
+
writeQueueDepth++;
|
|
1875
|
+
writeQueue = writeQueue.then(async () => {
|
|
1876
|
+
const buf = await bufPromise;
|
|
1877
|
+
if (expectedSize !== void 0 && buf.byteLength !== expectedSize) {
|
|
1878
|
+
throw new DropgateValidationError(
|
|
1879
|
+
`Chunk size mismatch: expected ${expectedSize}, got ${buf.byteLength}`
|
|
1880
|
+
);
|
|
1881
|
+
}
|
|
1882
|
+
const newReceived = received + buf.byteLength;
|
|
1883
|
+
if (total > 0 && newReceived > total) {
|
|
1884
|
+
throw new DropgateValidationError(
|
|
1885
|
+
`Received more data than expected: ${newReceived} > ${total}`
|
|
1886
|
+
);
|
|
1887
|
+
}
|
|
1888
|
+
if (onData) {
|
|
1889
|
+
await onData(buf);
|
|
1890
|
+
}
|
|
1891
|
+
received += buf.byteLength;
|
|
1892
|
+
currentFileReceived += buf.byteLength;
|
|
1893
|
+
const progressReceived = fileList ? totalReceivedAllFiles + currentFileReceived : received;
|
|
1894
|
+
const progressTotal = fileList ? fileList.totalSize : total;
|
|
1895
|
+
const percent = progressTotal ? Math.min(100, progressReceived / progressTotal * 100) : 0;
|
|
1896
|
+
if (!isStopped()) onProgress?.({ processedBytes: progressReceived, totalBytes: progressTotal, percent });
|
|
1897
|
+
if (chunkSeq >= 0) {
|
|
1898
|
+
sendChunkAck(conn, chunkSeq);
|
|
1019
1899
|
}
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1900
|
+
}).catch((err2) => {
|
|
1901
|
+
try {
|
|
1902
|
+
conn.send({
|
|
1903
|
+
t: "error",
|
|
1904
|
+
message: err2?.message || "Receiver write failed."
|
|
1905
|
+
});
|
|
1906
|
+
} catch {
|
|
1026
1907
|
}
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
progress({
|
|
1031
|
-
phase: "decrypting",
|
|
1032
|
-
text: `Downloading & decrypting... (${percent}%)`,
|
|
1033
|
-
percent,
|
|
1034
|
-
processedBytes: receivedBytes,
|
|
1035
|
-
totalBytes
|
|
1908
|
+
safeError(err2);
|
|
1909
|
+
}).finally(() => {
|
|
1910
|
+
writeQueueDepth--;
|
|
1036
1911
|
});
|
|
1912
|
+
return;
|
|
1037
1913
|
}
|
|
1038
|
-
if (
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1914
|
+
if (!isP2PMessage(data)) return;
|
|
1915
|
+
const msg = data;
|
|
1916
|
+
switch (msg.t) {
|
|
1917
|
+
case "hello":
|
|
1918
|
+
currentSessionId = msg.sessionId || null;
|
|
1919
|
+
transitionTo("negotiating");
|
|
1920
|
+
onStatus?.({ phase: "waiting", message: "Waiting for file details..." });
|
|
1921
|
+
break;
|
|
1922
|
+
case "file_list": {
|
|
1923
|
+
const fileListMsg = msg;
|
|
1924
|
+
if (fileListMsg.fileCount > MAX_FILE_COUNT) {
|
|
1925
|
+
throw new DropgateValidationError(`Too many files: ${fileListMsg.fileCount}`);
|
|
1926
|
+
}
|
|
1927
|
+
const sumSize = fileListMsg.files.reduce((sum, f) => sum + f.size, 0);
|
|
1928
|
+
if (sumSize !== fileListMsg.totalSize) {
|
|
1929
|
+
throw new DropgateValidationError(
|
|
1930
|
+
`File list size mismatch: declared ${fileListMsg.totalSize}, actual sum ${sumSize}`
|
|
1931
|
+
);
|
|
1932
|
+
}
|
|
1933
|
+
fileList = fileListMsg;
|
|
1934
|
+
total = fileListMsg.totalSize;
|
|
1935
|
+
break;
|
|
1046
1936
|
}
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1937
|
+
case "meta": {
|
|
1938
|
+
if (state !== "negotiating" && !(state === "transferring" && fileList)) {
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
if (currentSessionId && msg.sessionId && msg.sessionId !== currentSessionId) {
|
|
1942
|
+
try {
|
|
1943
|
+
conn.send({ t: "error", message: "Busy with another session." });
|
|
1944
|
+
} catch {
|
|
1945
|
+
}
|
|
1946
|
+
return;
|
|
1947
|
+
}
|
|
1948
|
+
if (msg.sessionId) {
|
|
1949
|
+
currentSessionId = msg.sessionId;
|
|
1950
|
+
}
|
|
1951
|
+
const name = String(msg.name || "file");
|
|
1952
|
+
const fileSize = Number(msg.size) || 0;
|
|
1953
|
+
const fi = msg.fileIndex;
|
|
1954
|
+
if (fileList && typeof fi === "number" && fi > 0) {
|
|
1955
|
+
currentFileReceived = 0;
|
|
1956
|
+
onFileStart?.({ fileIndex: fi, name, size: fileSize });
|
|
1957
|
+
break;
|
|
1958
|
+
}
|
|
1959
|
+
received = 0;
|
|
1960
|
+
currentFileReceived = 0;
|
|
1961
|
+
totalReceivedAllFiles = 0;
|
|
1962
|
+
if (!fileList) {
|
|
1963
|
+
total = fileSize;
|
|
1964
|
+
}
|
|
1965
|
+
writeQueue = Promise.resolve();
|
|
1966
|
+
const sendReady = () => {
|
|
1967
|
+
transitionTo("transferring");
|
|
1968
|
+
resetWatchdog();
|
|
1969
|
+
if (fileList) {
|
|
1970
|
+
onFileStart?.({ fileIndex: 0, name, size: fileSize });
|
|
1971
|
+
}
|
|
1972
|
+
try {
|
|
1973
|
+
conn.send({ t: "ready" });
|
|
1974
|
+
} catch {
|
|
1975
|
+
}
|
|
1976
|
+
};
|
|
1977
|
+
const metaEvt = { name, total };
|
|
1978
|
+
if (fileList) {
|
|
1979
|
+
metaEvt.fileCount = fileList.fileCount;
|
|
1980
|
+
metaEvt.files = fileList.files.map((f) => ({ name: f.name, size: f.size }));
|
|
1981
|
+
metaEvt.totalSize = fileList.totalSize;
|
|
1982
|
+
}
|
|
1983
|
+
if (autoReady) {
|
|
1984
|
+
if (!isStopped()) {
|
|
1985
|
+
onMeta?.(metaEvt);
|
|
1986
|
+
onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
|
|
1987
|
+
}
|
|
1988
|
+
sendReady();
|
|
1989
|
+
} else {
|
|
1990
|
+
metaEvt.sendReady = sendReady;
|
|
1991
|
+
if (!isStopped()) {
|
|
1992
|
+
onMeta?.(metaEvt);
|
|
1993
|
+
onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
break;
|
|
1052
1997
|
}
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1998
|
+
case "chunk": {
|
|
1999
|
+
const chunkMsg = msg;
|
|
2000
|
+
if (state !== "transferring") {
|
|
2001
|
+
throw new DropgateValidationError(
|
|
2002
|
+
"Received chunk message before transfer was accepted."
|
|
2003
|
+
);
|
|
2004
|
+
}
|
|
2005
|
+
if (chunkMsg.seq !== expectedChunkSeq) {
|
|
2006
|
+
throw new DropgateValidationError(
|
|
2007
|
+
`Chunk sequence error: expected ${expectedChunkSeq}, got ${chunkMsg.seq}`
|
|
2008
|
+
);
|
|
2009
|
+
}
|
|
2010
|
+
expectedChunkSeq++;
|
|
2011
|
+
pendingChunk = chunkMsg;
|
|
2012
|
+
break;
|
|
1059
2013
|
}
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
2014
|
+
case "ping":
|
|
2015
|
+
try {
|
|
2016
|
+
conn.send({ t: "pong", timestamp: Date.now() });
|
|
2017
|
+
} catch {
|
|
2018
|
+
}
|
|
2019
|
+
break;
|
|
2020
|
+
case "file_end": {
|
|
2021
|
+
clearWatchdog();
|
|
2022
|
+
await writeQueue;
|
|
2023
|
+
const feIdx = msg.fileIndex;
|
|
2024
|
+
onFileEnd?.({ fileIndex: feIdx, receivedBytes: currentFileReceived });
|
|
2025
|
+
try {
|
|
2026
|
+
conn.send({ t: "file_end_ack", fileIndex: feIdx, received: currentFileReceived, size: currentFileReceived });
|
|
2027
|
+
} catch {
|
|
2028
|
+
}
|
|
2029
|
+
totalReceivedAllFiles += currentFileReceived;
|
|
2030
|
+
currentFileReceived = 0;
|
|
2031
|
+
resetWatchdog();
|
|
2032
|
+
break;
|
|
2033
|
+
}
|
|
2034
|
+
case "end":
|
|
2035
|
+
clearWatchdog();
|
|
2036
|
+
await writeQueue;
|
|
2037
|
+
const finalReceived = fileList ? totalReceivedAllFiles + currentFileReceived : received;
|
|
2038
|
+
const finalTotal = fileList ? fileList.totalSize : total;
|
|
2039
|
+
if (finalTotal && finalReceived < finalTotal) {
|
|
2040
|
+
const err2 = new DropgateNetworkError(
|
|
2041
|
+
"Transfer ended before all data was received."
|
|
2042
|
+
);
|
|
2043
|
+
try {
|
|
2044
|
+
conn.send({ t: "error", message: err2.message });
|
|
2045
|
+
} catch {
|
|
2046
|
+
}
|
|
2047
|
+
throw err2;
|
|
2048
|
+
}
|
|
2049
|
+
try {
|
|
2050
|
+
conn.send({ t: "end_ack", received: finalReceived, total: finalTotal });
|
|
2051
|
+
} catch {
|
|
2052
|
+
}
|
|
2053
|
+
safeComplete({ received: finalReceived, total: finalTotal });
|
|
2054
|
+
(async () => {
|
|
2055
|
+
for (let i = 0; i < 2; i++) {
|
|
2056
|
+
await sleep(P2P_END_ACK_RETRY_DELAY_MS);
|
|
2057
|
+
try {
|
|
2058
|
+
conn.send({ t: "end_ack", received: finalReceived, total: finalTotal });
|
|
2059
|
+
} catch {
|
|
2060
|
+
break;
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
})().catch(() => {
|
|
2064
|
+
});
|
|
2065
|
+
break;
|
|
2066
|
+
case "error":
|
|
2067
|
+
throw new DropgateNetworkError(msg.message || "Sender reported an error.");
|
|
2068
|
+
case "cancelled":
|
|
2069
|
+
if (state === "cancelled" || state === "closed" || state === "completed") return;
|
|
2070
|
+
transitionTo("cancelled");
|
|
2071
|
+
onCancel?.({ cancelledBy: "sender", message: msg.reason });
|
|
2072
|
+
cleanup();
|
|
2073
|
+
break;
|
|
1069
2074
|
}
|
|
2075
|
+
} catch (err2) {
|
|
2076
|
+
safeError(err2);
|
|
1070
2077
|
}
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
if (
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
throw new DropgateNetworkError("Download failed.", { cause: err });
|
|
1077
|
-
} finally {
|
|
1078
|
-
downloadCleanup();
|
|
1079
|
-
}
|
|
1080
|
-
progress({ phase: "complete", text: "Download complete!", percent: 100, processedBytes: receivedBytes, totalBytes });
|
|
1081
|
-
let data;
|
|
1082
|
-
if (collectData && dataChunks.length > 0) {
|
|
1083
|
-
const totalLength = dataChunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
1084
|
-
data = new Uint8Array(totalLength);
|
|
1085
|
-
let offset = 0;
|
|
1086
|
-
for (const chunk of dataChunks) {
|
|
1087
|
-
data.set(chunk, offset);
|
|
1088
|
-
offset += chunk.length;
|
|
1089
|
-
}
|
|
1090
|
-
}
|
|
1091
|
-
return {
|
|
1092
|
-
filename,
|
|
1093
|
-
receivedBytes,
|
|
1094
|
-
wasEncrypted: isEncrypted,
|
|
1095
|
-
...data ? { data } : {}
|
|
1096
|
-
};
|
|
1097
|
-
}
|
|
1098
|
-
async attemptChunkUpload(url, fetchOptions, opts) {
|
|
1099
|
-
const {
|
|
1100
|
-
retries,
|
|
1101
|
-
backoffMs,
|
|
1102
|
-
maxBackoffMs,
|
|
1103
|
-
timeoutMs,
|
|
1104
|
-
signal,
|
|
1105
|
-
progress,
|
|
1106
|
-
chunkIndex,
|
|
1107
|
-
totalChunks,
|
|
1108
|
-
chunkSize,
|
|
1109
|
-
fileSizeBytes
|
|
1110
|
-
} = opts;
|
|
1111
|
-
let attemptsLeft = retries;
|
|
1112
|
-
let currentBackoff = backoffMs;
|
|
1113
|
-
const maxRetries = retries;
|
|
1114
|
-
while (true) {
|
|
1115
|
-
if (signal?.aborted) {
|
|
1116
|
-
throw signal.reason || new DropgateAbortError();
|
|
2078
|
+
});
|
|
2079
|
+
conn.on("close", () => {
|
|
2080
|
+
if (state === "closed" || state === "completed" || state === "cancelled") {
|
|
2081
|
+
cleanup();
|
|
2082
|
+
return;
|
|
1117
2083
|
}
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
if (res.ok) return;
|
|
1122
|
-
const text = await res.text().catch(() => "");
|
|
1123
|
-
const err = new DropgateProtocolError(
|
|
1124
|
-
`Chunk ${chunkIndex + 1} failed (HTTP ${res.status}).`,
|
|
1125
|
-
{
|
|
1126
|
-
details: { status: res.status, bodySnippet: text.slice(0, 120) }
|
|
1127
|
-
}
|
|
1128
|
-
);
|
|
1129
|
-
throw err;
|
|
1130
|
-
} catch (err) {
|
|
2084
|
+
if (state === "transferring") {
|
|
2085
|
+
transitionTo("cancelled");
|
|
2086
|
+
onCancel?.({ cancelledBy: "sender" });
|
|
1131
2087
|
cleanup();
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
}
|
|
1135
|
-
if (signal?.aborted) {
|
|
1136
|
-
throw signal.reason || new DropgateAbortError();
|
|
1137
|
-
}
|
|
1138
|
-
if (attemptsLeft <= 0) {
|
|
1139
|
-
throw err instanceof DropgateError ? err : new DropgateNetworkError("Chunk upload failed.", { cause: err });
|
|
1140
|
-
}
|
|
1141
|
-
const attemptNumber = maxRetries - attemptsLeft + 1;
|
|
1142
|
-
const processedBytes = chunkIndex * chunkSize;
|
|
1143
|
-
const percent = chunkIndex / totalChunks * 100;
|
|
1144
|
-
let remaining = currentBackoff;
|
|
1145
|
-
const tick = 100;
|
|
1146
|
-
while (remaining > 0) {
|
|
1147
|
-
const secondsLeft = (remaining / 1e3).toFixed(1);
|
|
1148
|
-
progress({
|
|
1149
|
-
phase: "retry-wait",
|
|
1150
|
-
text: `Chunk upload failed. Retrying in ${secondsLeft}s... (${attemptNumber}/${maxRetries})`,
|
|
1151
|
-
percent,
|
|
1152
|
-
processedBytes,
|
|
1153
|
-
totalBytes: fileSizeBytes,
|
|
1154
|
-
chunkIndex,
|
|
1155
|
-
totalChunks
|
|
1156
|
-
});
|
|
1157
|
-
await sleep(Math.min(tick, remaining), signal);
|
|
1158
|
-
remaining -= tick;
|
|
1159
|
-
}
|
|
1160
|
-
progress({
|
|
1161
|
-
phase: "retry",
|
|
1162
|
-
text: `Chunk upload failed. Retrying now... (${attemptNumber}/${maxRetries})`,
|
|
1163
|
-
percent,
|
|
1164
|
-
processedBytes,
|
|
1165
|
-
totalBytes: fileSizeBytes,
|
|
1166
|
-
chunkIndex,
|
|
1167
|
-
totalChunks
|
|
1168
|
-
});
|
|
1169
|
-
attemptsLeft -= 1;
|
|
1170
|
-
currentBackoff = Math.min(currentBackoff * 2, maxBackoffMs);
|
|
1171
|
-
continue;
|
|
1172
|
-
} finally {
|
|
2088
|
+
} else if (state === "negotiating") {
|
|
2089
|
+
transitionTo("closed");
|
|
1173
2090
|
cleanup();
|
|
2091
|
+
onDisconnect?.();
|
|
2092
|
+
} else {
|
|
2093
|
+
safeError(new DropgateNetworkError("Sender disconnected before file details were received."));
|
|
1174
2094
|
}
|
|
1175
|
-
}
|
|
1176
|
-
}
|
|
1177
|
-
|
|
2095
|
+
});
|
|
2096
|
+
});
|
|
2097
|
+
return {
|
|
2098
|
+
peer,
|
|
2099
|
+
stop,
|
|
2100
|
+
getStatus: () => state,
|
|
2101
|
+
getBytesReceived: () => received,
|
|
2102
|
+
getTotalBytes: () => total,
|
|
2103
|
+
getSessionId: () => currentSessionId
|
|
2104
|
+
};
|
|
2105
|
+
}
|
|
1178
2106
|
|
|
1179
|
-
// src/
|
|
1180
|
-
function
|
|
1181
|
-
|
|
1182
|
-
|
|
2107
|
+
// src/client/DropgateClient.ts
|
|
2108
|
+
function resolveServerToBaseUrl(server) {
|
|
2109
|
+
if (typeof server === "string") {
|
|
2110
|
+
return buildBaseUrl(parseServerUrl(server));
|
|
2111
|
+
}
|
|
2112
|
+
return buildBaseUrl(server);
|
|
1183
2113
|
}
|
|
1184
|
-
function
|
|
1185
|
-
|
|
2114
|
+
function estimateTotalUploadSizeBytes(fileSizeBytes, totalChunks, isEncrypted) {
|
|
2115
|
+
const base = Number(fileSizeBytes) || 0;
|
|
2116
|
+
if (!isEncrypted) return base;
|
|
2117
|
+
return base + (Number(totalChunks) || 0) * ENCRYPTION_OVERHEAD_PER_CHUNK;
|
|
1186
2118
|
}
|
|
1187
|
-
function
|
|
1188
|
-
const
|
|
1189
|
-
const
|
|
1190
|
-
if (
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
2119
|
+
async function getServerInfo(opts) {
|
|
2120
|
+
const { server, timeoutMs = 5e3, signal, fetchFn: customFetch } = opts;
|
|
2121
|
+
const fetchFn = customFetch || getDefaultFetch();
|
|
2122
|
+
if (!fetchFn) {
|
|
2123
|
+
throw new DropgateValidationError("No fetch() implementation found.");
|
|
2124
|
+
}
|
|
2125
|
+
const baseUrl = resolveServerToBaseUrl(server);
|
|
2126
|
+
try {
|
|
2127
|
+
const { res, json } = await fetchJson(
|
|
2128
|
+
fetchFn,
|
|
2129
|
+
`${baseUrl}/api/info`,
|
|
2130
|
+
{
|
|
2131
|
+
method: "GET",
|
|
2132
|
+
timeoutMs,
|
|
2133
|
+
signal,
|
|
2134
|
+
headers: { Accept: "application/json" }
|
|
2135
|
+
}
|
|
2136
|
+
);
|
|
2137
|
+
if (res.ok && json && typeof json === "object" && "version" in json) {
|
|
2138
|
+
return { baseUrl, serverInfo: json };
|
|
2139
|
+
}
|
|
2140
|
+
throw new DropgateProtocolError(
|
|
2141
|
+
`Server info request failed (status ${res.status}).`
|
|
2142
|
+
);
|
|
2143
|
+
} catch (err2) {
|
|
2144
|
+
if (err2 instanceof DropgateError) throw err2;
|
|
2145
|
+
throw new DropgateNetworkError("Could not reach server /api/info.", {
|
|
2146
|
+
cause: err2
|
|
2147
|
+
});
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
var DropgateClient = class {
|
|
2151
|
+
/**
|
|
2152
|
+
* Create a new DropgateClient instance.
|
|
2153
|
+
* @param opts - Client configuration options including server URL.
|
|
2154
|
+
* @throws {DropgateValidationError} If clientVersion or server is missing or invalid.
|
|
2155
|
+
*/
|
|
2156
|
+
constructor(opts) {
|
|
2157
|
+
/** Client version string for compatibility checking. */
|
|
2158
|
+
__publicField(this, "clientVersion");
|
|
2159
|
+
/** Chunk size in bytes for upload splitting. */
|
|
2160
|
+
__publicField(this, "chunkSize");
|
|
2161
|
+
/** Fetch implementation used for HTTP requests. */
|
|
2162
|
+
__publicField(this, "fetchFn");
|
|
2163
|
+
/** Crypto implementation for encryption operations. */
|
|
2164
|
+
__publicField(this, "cryptoObj");
|
|
2165
|
+
/** Base64 encoder/decoder for binary data. */
|
|
2166
|
+
__publicField(this, "base64");
|
|
2167
|
+
/** Resolved base URL (e.g. 'https://dropgate.link'). May change during HTTP fallback. */
|
|
2168
|
+
__publicField(this, "baseUrl");
|
|
2169
|
+
/** Whether to automatically retry with HTTP when HTTPS fails. */
|
|
2170
|
+
__publicField(this, "_fallbackToHttp");
|
|
2171
|
+
/** Cached compatibility result (null until first connect()). */
|
|
2172
|
+
__publicField(this, "_compat", null);
|
|
2173
|
+
/** In-flight connect promise to deduplicate concurrent calls. */
|
|
2174
|
+
__publicField(this, "_connectPromise", null);
|
|
2175
|
+
if (!opts || typeof opts.clientVersion !== "string") {
|
|
2176
|
+
throw new DropgateValidationError(
|
|
2177
|
+
"DropgateClient requires clientVersion (string)."
|
|
2178
|
+
);
|
|
2179
|
+
}
|
|
2180
|
+
if (!opts.server) {
|
|
2181
|
+
throw new DropgateValidationError(
|
|
2182
|
+
"DropgateClient requires server (URL string or ServerTarget object)."
|
|
2183
|
+
);
|
|
1196
2184
|
}
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
2185
|
+
this.clientVersion = opts.clientVersion;
|
|
2186
|
+
this.chunkSize = Number.isFinite(opts.chunkSize) ? opts.chunkSize : DEFAULT_CHUNK_SIZE;
|
|
2187
|
+
const fetchFn = opts.fetchFn || getDefaultFetch();
|
|
2188
|
+
if (!fetchFn) {
|
|
2189
|
+
throw new DropgateValidationError("No fetch() implementation found.");
|
|
1200
2190
|
}
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
2191
|
+
this.fetchFn = fetchFn;
|
|
2192
|
+
const cryptoObj = opts.cryptoObj || getDefaultCrypto();
|
|
2193
|
+
if (!cryptoObj) {
|
|
2194
|
+
throw new DropgateValidationError("No crypto implementation found.");
|
|
2195
|
+
}
|
|
2196
|
+
this.cryptoObj = cryptoObj;
|
|
2197
|
+
this.base64 = opts.base64 || getDefaultBase64();
|
|
2198
|
+
this._fallbackToHttp = Boolean(opts.fallbackToHttp);
|
|
2199
|
+
this.baseUrl = resolveServerToBaseUrl(opts.server);
|
|
1206
2200
|
}
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
2201
|
+
/**
|
|
2202
|
+
* Get the server target (host, port, secure) derived from the current baseUrl.
|
|
2203
|
+
* Useful for passing to standalone functions that still need a ServerTarget.
|
|
2204
|
+
*/
|
|
2205
|
+
get serverTarget() {
|
|
2206
|
+
const url = new URL(this.baseUrl);
|
|
2207
|
+
return {
|
|
2208
|
+
host: url.hostname,
|
|
2209
|
+
port: url.port ? Number(url.port) : void 0,
|
|
2210
|
+
secure: url.protocol === "https:"
|
|
2211
|
+
};
|
|
1210
2212
|
}
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
secure,
|
|
1230
|
-
config: { iceServers },
|
|
1231
|
-
debug: 0
|
|
1232
|
-
};
|
|
1233
|
-
if (port) {
|
|
1234
|
-
peerOpts.port = port;
|
|
2213
|
+
/**
|
|
2214
|
+
* Connect to the server: fetch server info and check version compatibility.
|
|
2215
|
+
* Results are cached — subsequent calls return instantly without network requests.
|
|
2216
|
+
* Concurrent calls are deduplicated.
|
|
2217
|
+
*
|
|
2218
|
+
* @param opts - Optional timeout and abort signal.
|
|
2219
|
+
* @returns Compatibility result with server info.
|
|
2220
|
+
* @throws {DropgateNetworkError} If the server cannot be reached.
|
|
2221
|
+
* @throws {DropgateProtocolError} If the server returns an invalid response.
|
|
2222
|
+
*/
|
|
2223
|
+
async connect(opts) {
|
|
2224
|
+
if (this._compat) return this._compat;
|
|
2225
|
+
if (!this._connectPromise) {
|
|
2226
|
+
this._connectPromise = this._fetchAndCheckCompat(opts).finally(() => {
|
|
2227
|
+
this._connectPromise = null;
|
|
2228
|
+
});
|
|
2229
|
+
}
|
|
2230
|
+
return this._connectPromise;
|
|
1235
2231
|
}
|
|
1236
|
-
|
|
1237
|
-
}
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
let nextCode = code || codeGenerator();
|
|
1241
|
-
let peer = null;
|
|
1242
|
-
let lastError = null;
|
|
1243
|
-
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1244
|
-
onCode?.(nextCode, attempt);
|
|
2232
|
+
async _fetchAndCheckCompat(opts) {
|
|
2233
|
+
const { timeoutMs = 5e3, signal } = opts ?? {};
|
|
2234
|
+
let baseUrl = this.baseUrl;
|
|
2235
|
+
let serverInfo;
|
|
1245
2236
|
try {
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
instance.destroy();
|
|
1252
|
-
} catch {
|
|
1253
|
-
}
|
|
1254
|
-
reject(err);
|
|
1255
|
-
});
|
|
2237
|
+
const result = await getServerInfo({
|
|
2238
|
+
server: baseUrl,
|
|
2239
|
+
timeoutMs,
|
|
2240
|
+
signal,
|
|
2241
|
+
fetchFn: this.fetchFn
|
|
1256
2242
|
});
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
2243
|
+
baseUrl = result.baseUrl;
|
|
2244
|
+
serverInfo = result.serverInfo;
|
|
2245
|
+
} catch (err2) {
|
|
2246
|
+
if (this._fallbackToHttp && this.baseUrl.startsWith("https://")) {
|
|
2247
|
+
const httpBaseUrl = this.baseUrl.replace("https://", "http://");
|
|
2248
|
+
try {
|
|
2249
|
+
const result = await getServerInfo({
|
|
2250
|
+
server: httpBaseUrl,
|
|
2251
|
+
timeoutMs,
|
|
2252
|
+
signal,
|
|
2253
|
+
fetchFn: this.fetchFn
|
|
2254
|
+
});
|
|
2255
|
+
this.baseUrl = httpBaseUrl;
|
|
2256
|
+
baseUrl = result.baseUrl;
|
|
2257
|
+
serverInfo = result.serverInfo;
|
|
2258
|
+
} catch {
|
|
2259
|
+
if (err2 instanceof DropgateError) throw err2;
|
|
2260
|
+
throw new DropgateNetworkError("Could not connect to the server.", { cause: err2 });
|
|
2261
|
+
}
|
|
2262
|
+
} else {
|
|
2263
|
+
if (err2 instanceof DropgateError) throw err2;
|
|
2264
|
+
throw new DropgateNetworkError("Could not connect to the server.", { cause: err2 });
|
|
2265
|
+
}
|
|
1261
2266
|
}
|
|
2267
|
+
const compat = this._checkVersionCompat(serverInfo);
|
|
2268
|
+
this._compat = { ...compat, serverInfo, baseUrl };
|
|
2269
|
+
return this._compat;
|
|
1262
2270
|
}
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
onProgress,
|
|
1294
|
-
onComplete,
|
|
1295
|
-
onError,
|
|
1296
|
-
onDisconnect,
|
|
1297
|
-
onCancel
|
|
1298
|
-
} = opts;
|
|
1299
|
-
if (!file) {
|
|
1300
|
-
throw new DropgateValidationError("File is missing.");
|
|
2271
|
+
/**
|
|
2272
|
+
* Pure version compatibility check (no network calls).
|
|
2273
|
+
*/
|
|
2274
|
+
_checkVersionCompat(serverInfo) {
|
|
2275
|
+
const serverVersion = String(serverInfo?.version || "0.0.0");
|
|
2276
|
+
const clientVersion = String(this.clientVersion || "0.0.0");
|
|
2277
|
+
const c = parseSemverMajorMinor(clientVersion);
|
|
2278
|
+
const s = parseSemverMajorMinor(serverVersion);
|
|
2279
|
+
if (c.major !== s.major) {
|
|
2280
|
+
return {
|
|
2281
|
+
compatible: false,
|
|
2282
|
+
clientVersion,
|
|
2283
|
+
serverVersion,
|
|
2284
|
+
message: `Incompatible versions. Client v${clientVersion}, Server v${serverVersion}${serverInfo?.name ? ` (${serverInfo.name})` : ""}.`
|
|
2285
|
+
};
|
|
2286
|
+
}
|
|
2287
|
+
if (c.minor > s.minor) {
|
|
2288
|
+
return {
|
|
2289
|
+
compatible: true,
|
|
2290
|
+
clientVersion,
|
|
2291
|
+
serverVersion,
|
|
2292
|
+
message: `Client (v${clientVersion}) is newer than Server (v${serverVersion})${serverInfo?.name ? ` (${serverInfo.name})` : ""}. Some features may not work.`
|
|
2293
|
+
};
|
|
2294
|
+
}
|
|
2295
|
+
return {
|
|
2296
|
+
compatible: true,
|
|
2297
|
+
clientVersion,
|
|
2298
|
+
serverVersion,
|
|
2299
|
+
message: `Server: v${serverVersion}, Client: v${clientVersion}${serverInfo?.name ? ` (${serverInfo.name})` : ""}.`
|
|
2300
|
+
};
|
|
1301
2301
|
}
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
2302
|
+
/**
|
|
2303
|
+
* Resolve a user-entered sharing code or URL via the server.
|
|
2304
|
+
* @param value - The sharing code or URL to resolve.
|
|
2305
|
+
* @param opts - Optional timeout and abort signal.
|
|
2306
|
+
* @returns The resolved share target information.
|
|
2307
|
+
* @throws {DropgateProtocolError} If the share lookup fails.
|
|
2308
|
+
*/
|
|
2309
|
+
async resolveShareTarget(value, opts) {
|
|
2310
|
+
const { timeoutMs = 5e3, signal } = opts ?? {};
|
|
2311
|
+
const compat = await this.connect(opts);
|
|
2312
|
+
if (!compat.compatible) {
|
|
2313
|
+
throw new DropgateValidationError(compat.message);
|
|
2314
|
+
}
|
|
2315
|
+
const { baseUrl } = compat;
|
|
2316
|
+
const { res, json } = await fetchJson(
|
|
2317
|
+
this.fetchFn,
|
|
2318
|
+
`${baseUrl}/api/resolve`,
|
|
2319
|
+
{
|
|
2320
|
+
method: "POST",
|
|
2321
|
+
timeoutMs,
|
|
2322
|
+
signal,
|
|
2323
|
+
headers: {
|
|
2324
|
+
"Content-Type": "application/json",
|
|
2325
|
+
Accept: "application/json"
|
|
2326
|
+
},
|
|
2327
|
+
body: JSON.stringify({ value })
|
|
2328
|
+
}
|
|
1305
2329
|
);
|
|
2330
|
+
if (!res.ok) {
|
|
2331
|
+
const msg = (json && typeof json === "object" && "error" in json ? json.error : null) || `Share lookup failed (status ${res.status}).`;
|
|
2332
|
+
throw new DropgateProtocolError(msg, { details: json });
|
|
2333
|
+
}
|
|
2334
|
+
return json || { valid: false, reason: "Unknown response." };
|
|
1306
2335
|
}
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
2336
|
+
/**
|
|
2337
|
+
* Fetch metadata for a single file from the server.
|
|
2338
|
+
* @param fileId - The file ID to fetch metadata for.
|
|
2339
|
+
* @param opts - Optional connection options (timeout, signal).
|
|
2340
|
+
* @returns File metadata including size, filename, and encryption status.
|
|
2341
|
+
* @throws {DropgateNetworkError} If the server cannot be reached.
|
|
2342
|
+
* @throws {DropgateProtocolError} If the file is not found or server returns an error.
|
|
2343
|
+
*/
|
|
2344
|
+
async getFileMetadata(fileId, opts) {
|
|
2345
|
+
if (!fileId || typeof fileId !== "string") {
|
|
2346
|
+
throw new DropgateValidationError("File ID is required.");
|
|
2347
|
+
}
|
|
2348
|
+
const { timeoutMs = 5e3, signal } = opts ?? {};
|
|
2349
|
+
const url = `${this.baseUrl}/api/file/${encodeURIComponent(fileId)}/meta`;
|
|
2350
|
+
const { res, json } = await fetchJson(this.fetchFn, url, {
|
|
2351
|
+
method: "GET",
|
|
2352
|
+
timeoutMs,
|
|
2353
|
+
signal
|
|
2354
|
+
});
|
|
2355
|
+
if (!res.ok) {
|
|
2356
|
+
const msg = (json && typeof json === "object" && "error" in json ? json.error : null) || `Failed to fetch file metadata (status ${res.status}).`;
|
|
2357
|
+
throw new DropgateProtocolError(msg, { details: json });
|
|
2358
|
+
}
|
|
2359
|
+
return json;
|
|
1310
2360
|
}
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
codeGenerator: finalCodeGenerator,
|
|
1327
|
-
maxAttempts,
|
|
1328
|
-
buildPeer,
|
|
1329
|
-
onCode
|
|
1330
|
-
});
|
|
1331
|
-
const sessionId = generateSessionId();
|
|
1332
|
-
let state = "listening";
|
|
1333
|
-
let activeConn = null;
|
|
1334
|
-
let sentBytes = 0;
|
|
1335
|
-
let heartbeatTimer = null;
|
|
1336
|
-
const reportProgress = (data) => {
|
|
1337
|
-
const safeTotal = Number.isFinite(data.total) && data.total > 0 ? data.total : file.size;
|
|
1338
|
-
const safeReceived = Math.min(Number(data.received) || 0, safeTotal || 0);
|
|
1339
|
-
const percent = safeTotal ? safeReceived / safeTotal * 100 : 0;
|
|
1340
|
-
onProgress?.({ processedBytes: safeReceived, totalBytes: safeTotal, percent });
|
|
1341
|
-
};
|
|
1342
|
-
const safeError = (err) => {
|
|
1343
|
-
if (state === "closed" || state === "completed" || state === "cancelled") return;
|
|
1344
|
-
state = "closed";
|
|
1345
|
-
onError?.(err);
|
|
1346
|
-
cleanup();
|
|
1347
|
-
};
|
|
1348
|
-
const safeComplete = () => {
|
|
1349
|
-
if (state !== "finishing") return;
|
|
1350
|
-
state = "completed";
|
|
1351
|
-
onComplete?.();
|
|
1352
|
-
cleanup();
|
|
1353
|
-
};
|
|
1354
|
-
const cleanup = () => {
|
|
1355
|
-
if (heartbeatTimer) {
|
|
1356
|
-
clearInterval(heartbeatTimer);
|
|
1357
|
-
heartbeatTimer = null;
|
|
2361
|
+
/**
|
|
2362
|
+
* Fetch metadata for a bundle from the server and derive computed fields.
|
|
2363
|
+
* For sealed bundles, decrypts the manifest to extract file list.
|
|
2364
|
+
* Automatically derives totalSizeBytes and fileCount from the files array.
|
|
2365
|
+
* @param bundleId - The bundle ID to fetch metadata for.
|
|
2366
|
+
* @param keyB64 - Base64-encoded decryption key (required for encrypted bundles).
|
|
2367
|
+
* @param opts - Optional connection options (timeout, signal).
|
|
2368
|
+
* @returns Complete bundle metadata with all files and computed fields.
|
|
2369
|
+
* @throws {DropgateNetworkError} If the server cannot be reached.
|
|
2370
|
+
* @throws {DropgateProtocolError} If the bundle is not found or server returns an error.
|
|
2371
|
+
* @throws {DropgateValidationError} If decryption key is missing for encrypted bundle.
|
|
2372
|
+
*/
|
|
2373
|
+
async getBundleMetadata(bundleId, keyB64, opts) {
|
|
2374
|
+
if (!bundleId || typeof bundleId !== "string") {
|
|
2375
|
+
throw new DropgateValidationError("Bundle ID is required.");
|
|
1358
2376
|
}
|
|
1359
|
-
|
|
1360
|
-
|
|
2377
|
+
const { timeoutMs = 5e3, signal } = opts ?? {};
|
|
2378
|
+
const url = `${this.baseUrl}/api/bundle/${encodeURIComponent(bundleId)}/meta`;
|
|
2379
|
+
const { res, json } = await fetchJson(this.fetchFn, url, {
|
|
2380
|
+
method: "GET",
|
|
2381
|
+
timeoutMs,
|
|
2382
|
+
signal
|
|
2383
|
+
});
|
|
2384
|
+
if (!res.ok) {
|
|
2385
|
+
const msg = (json && typeof json === "object" && "error" in json ? json.error : null) || `Failed to fetch bundle metadata (status ${res.status}).`;
|
|
2386
|
+
throw new DropgateProtocolError(msg, { details: json });
|
|
2387
|
+
}
|
|
2388
|
+
const serverMeta = json;
|
|
2389
|
+
let files = [];
|
|
2390
|
+
if (serverMeta.sealed && serverMeta.encryptedManifest) {
|
|
2391
|
+
if (!keyB64) {
|
|
2392
|
+
throw new DropgateValidationError(
|
|
2393
|
+
"Decryption key (keyB64) is required for encrypted sealed bundles."
|
|
2394
|
+
);
|
|
2395
|
+
}
|
|
2396
|
+
const key = await importKeyFromBase64(this.cryptoObj, keyB64);
|
|
2397
|
+
const encryptedBytes = this.base64.decode(serverMeta.encryptedManifest);
|
|
2398
|
+
const decryptedBuffer = await decryptChunk(this.cryptoObj, encryptedBytes, key);
|
|
2399
|
+
const manifestJson = new TextDecoder().decode(decryptedBuffer);
|
|
2400
|
+
const manifest = JSON.parse(manifestJson);
|
|
2401
|
+
files = manifest.files.map((f) => ({
|
|
2402
|
+
fileId: f.fileId,
|
|
2403
|
+
sizeBytes: f.sizeBytes,
|
|
2404
|
+
filename: f.name
|
|
2405
|
+
}));
|
|
2406
|
+
} else if (serverMeta.files) {
|
|
2407
|
+
files = serverMeta.files;
|
|
2408
|
+
} else {
|
|
2409
|
+
throw new DropgateProtocolError("Invalid bundle metadata: missing files or manifest.");
|
|
2410
|
+
}
|
|
2411
|
+
const totalSizeBytes = files.reduce((sum, f) => sum + (f.sizeBytes || 0), 0);
|
|
2412
|
+
const fileCount = files.length;
|
|
2413
|
+
return {
|
|
2414
|
+
isEncrypted: serverMeta.isEncrypted,
|
|
2415
|
+
sealed: serverMeta.sealed,
|
|
2416
|
+
encryptedManifest: serverMeta.encryptedManifest,
|
|
2417
|
+
files,
|
|
2418
|
+
totalSizeBytes,
|
|
2419
|
+
fileCount
|
|
2420
|
+
};
|
|
2421
|
+
}
|
|
2422
|
+
/**
|
|
2423
|
+
* Validate file and upload settings against server capabilities.
|
|
2424
|
+
* @param opts - Validation options containing file, settings, and server info.
|
|
2425
|
+
* @returns True if validation passes.
|
|
2426
|
+
* @throws {DropgateValidationError} If any validation check fails.
|
|
2427
|
+
*/
|
|
2428
|
+
validateUploadInputs(opts) {
|
|
2429
|
+
const { files: rawFiles, lifetimeMs, encrypt, serverInfo } = opts;
|
|
2430
|
+
const caps = serverInfo?.capabilities?.upload;
|
|
2431
|
+
if (!caps || !caps.enabled) {
|
|
2432
|
+
throw new DropgateValidationError("Server does not support file uploads.");
|
|
1361
2433
|
}
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
2434
|
+
const files = Array.isArray(rawFiles) ? rawFiles : [rawFiles];
|
|
2435
|
+
if (files.length === 0) {
|
|
2436
|
+
throw new DropgateValidationError("At least one file is required.");
|
|
1365
2437
|
}
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
2438
|
+
for (let i = 0; i < files.length; i++) {
|
|
2439
|
+
const file = files[i];
|
|
2440
|
+
const fileSize = Number(file?.size || 0);
|
|
2441
|
+
if (!file || !Number.isFinite(fileSize) || fileSize <= 0) {
|
|
2442
|
+
throw new DropgateValidationError(`File at index ${i} is missing or invalid.`);
|
|
2443
|
+
}
|
|
2444
|
+
const maxMB = Number(caps.maxSizeMB);
|
|
2445
|
+
if (Number.isFinite(maxMB) && maxMB > 0) {
|
|
2446
|
+
const limitBytes = maxMB * 1e3 * 1e3;
|
|
2447
|
+
const validationChunkSize = Number.isFinite(caps.chunkSize) && caps.chunkSize > 0 ? caps.chunkSize : this.chunkSize;
|
|
2448
|
+
const totalChunks = Math.ceil(fileSize / validationChunkSize);
|
|
2449
|
+
const estimatedBytes = estimateTotalUploadSizeBytes(
|
|
2450
|
+
fileSize,
|
|
2451
|
+
totalChunks,
|
|
2452
|
+
Boolean(encrypt)
|
|
2453
|
+
);
|
|
2454
|
+
if (estimatedBytes > limitBytes) {
|
|
2455
|
+
const msg = encrypt ? `File at index ${i} too large once encryption overhead is included. Server limit: ${maxMB} MB.` : `File at index ${i} too large. Server limit: ${maxMB} MB.`;
|
|
2456
|
+
throw new DropgateValidationError(msg);
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
1369
2459
|
}
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
2460
|
+
const maxHours = Number(caps.maxLifetimeHours);
|
|
2461
|
+
const lt = Number(lifetimeMs);
|
|
2462
|
+
if (!Number.isFinite(lt) || lt < 0 || !Number.isInteger(lt)) {
|
|
2463
|
+
throw new DropgateValidationError(
|
|
2464
|
+
"Invalid lifetime. Must be a non-negative integer (milliseconds)."
|
|
2465
|
+
);
|
|
1375
2466
|
}
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
activeConn.send({ t: "cancelled", message: "Sender cancelled the transfer." });
|
|
2467
|
+
if (Number.isFinite(maxHours) && maxHours > 0) {
|
|
2468
|
+
const limitMs = Math.round(maxHours * 60 * 60 * 1e3);
|
|
2469
|
+
if (lt === 0) {
|
|
2470
|
+
throw new DropgateValidationError(
|
|
2471
|
+
`Server does not allow unlimited file lifetime. Max: ${maxHours} hours.`
|
|
2472
|
+
);
|
|
2473
|
+
}
|
|
2474
|
+
if (lt > limitMs) {
|
|
2475
|
+
throw new DropgateValidationError(
|
|
2476
|
+
`File lifetime too long. Server limit: ${maxHours} hours.`
|
|
2477
|
+
);
|
|
1388
2478
|
}
|
|
1389
|
-
} catch {
|
|
1390
2479
|
}
|
|
1391
|
-
if (
|
|
1392
|
-
|
|
2480
|
+
if (encrypt && !caps.e2ee) {
|
|
2481
|
+
throw new DropgateValidationError(
|
|
2482
|
+
"End-to-end encryption is not supported on this server."
|
|
2483
|
+
);
|
|
1393
2484
|
}
|
|
1394
|
-
|
|
1395
|
-
}
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
2485
|
+
return true;
|
|
2486
|
+
}
|
|
2487
|
+
/**
|
|
2488
|
+
* Upload one or more files to the server with optional encryption.
|
|
2489
|
+
* Single files use the standard upload protocol.
|
|
2490
|
+
* Multiple files use the bundle protocol, grouping files under a single download link.
|
|
2491
|
+
*
|
|
2492
|
+
* @param opts - Upload options including file(s) and settings.
|
|
2493
|
+
* @returns Upload session with result promise and cancellation support.
|
|
2494
|
+
*/
|
|
2495
|
+
async uploadFiles(opts) {
|
|
2496
|
+
const {
|
|
2497
|
+
files: rawFiles,
|
|
2498
|
+
lifetimeMs,
|
|
2499
|
+
encrypt,
|
|
2500
|
+
maxDownloads,
|
|
2501
|
+
filenameOverrides,
|
|
2502
|
+
onProgress,
|
|
2503
|
+
onCancel,
|
|
2504
|
+
signal,
|
|
2505
|
+
timeouts = {},
|
|
2506
|
+
retry = {}
|
|
2507
|
+
} = opts;
|
|
2508
|
+
const files = Array.isArray(rawFiles) ? rawFiles : [rawFiles];
|
|
2509
|
+
if (files.length === 0) {
|
|
2510
|
+
throw new DropgateValidationError("At least one file is required.");
|
|
2511
|
+
}
|
|
2512
|
+
const internalController = signal ? null : new AbortController();
|
|
2513
|
+
const effectiveSignal = signal || internalController?.signal;
|
|
2514
|
+
let uploadState = "initializing";
|
|
2515
|
+
const currentUploadIds = [];
|
|
2516
|
+
const totalSizeBytes = files.reduce((sum, f) => sum + f.size, 0);
|
|
2517
|
+
const uploadPromise = (async () => {
|
|
2518
|
+
try {
|
|
2519
|
+
const progress = (evt) => {
|
|
2520
|
+
try {
|
|
2521
|
+
if (onProgress) onProgress(evt);
|
|
2522
|
+
} catch {
|
|
2523
|
+
}
|
|
2524
|
+
};
|
|
2525
|
+
progress({ phase: "server-info", text: "Checking server...", percent: 0, processedBytes: 0, totalBytes: totalSizeBytes });
|
|
2526
|
+
const compat = await this.connect({
|
|
2527
|
+
timeoutMs: timeouts.serverInfoMs ?? 5e3,
|
|
2528
|
+
signal: effectiveSignal
|
|
2529
|
+
});
|
|
2530
|
+
const { baseUrl, serverInfo } = compat;
|
|
2531
|
+
progress({ phase: "server-compat", text: compat.message, percent: 0, processedBytes: 0, totalBytes: totalSizeBytes });
|
|
2532
|
+
if (!compat.compatible) {
|
|
2533
|
+
throw new DropgateValidationError(compat.message);
|
|
1405
2534
|
}
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
2535
|
+
const filenames = files.map((f, i) => filenameOverrides?.[i] ?? f.name ?? "file");
|
|
2536
|
+
const serverSupportsE2EE = Boolean(serverInfo?.capabilities?.upload?.e2ee);
|
|
2537
|
+
const effectiveEncrypt = encrypt ?? serverSupportsE2EE;
|
|
2538
|
+
if (!effectiveEncrypt) {
|
|
2539
|
+
for (const name of filenames) validatePlainFilename(name);
|
|
1409
2540
|
}
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
2541
|
+
this.validateUploadInputs({ files, lifetimeMs, encrypt: effectiveEncrypt, serverInfo });
|
|
2542
|
+
let cryptoKey = null;
|
|
2543
|
+
let keyB64 = null;
|
|
2544
|
+
const transmittedFilenames = [];
|
|
2545
|
+
if (effectiveEncrypt) {
|
|
2546
|
+
if (!this.cryptoObj?.subtle) {
|
|
2547
|
+
throw new DropgateValidationError(
|
|
2548
|
+
"Web Crypto API not available (crypto.subtle). Encryption requires a secure context (HTTPS or localhost)."
|
|
2549
|
+
);
|
|
2550
|
+
}
|
|
2551
|
+
progress({ phase: "crypto", text: "Generating encryption key...", percent: 0, processedBytes: 0, totalBytes: totalSizeBytes });
|
|
2552
|
+
try {
|
|
2553
|
+
cryptoKey = await generateAesGcmKey(this.cryptoObj);
|
|
2554
|
+
keyB64 = await exportKeyBase64(this.cryptoObj, cryptoKey);
|
|
2555
|
+
for (const name of filenames) {
|
|
2556
|
+
transmittedFilenames.push(
|
|
2557
|
+
await encryptFilenameToBase64(this.cryptoObj, name, cryptoKey)
|
|
2558
|
+
);
|
|
2559
|
+
}
|
|
2560
|
+
} catch (err2) {
|
|
2561
|
+
throw new DropgateError("Failed to prepare encryption.", { code: "CRYPTO_PREP_FAILED", cause: err2 });
|
|
2562
|
+
}
|
|
2563
|
+
} else {
|
|
2564
|
+
transmittedFilenames.push(...filenames);
|
|
1415
2565
|
}
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
2566
|
+
const serverChunkSize = serverInfo?.capabilities?.upload?.chunkSize;
|
|
2567
|
+
const effectiveChunkSize = Number.isFinite(serverChunkSize) && serverChunkSize > 0 ? serverChunkSize : this.chunkSize;
|
|
2568
|
+
const retries = Number.isFinite(retry.retries) ? retry.retries : 5;
|
|
2569
|
+
const baseBackoffMs = Number.isFinite(retry.backoffMs) ? retry.backoffMs : 1e3;
|
|
2570
|
+
const maxBackoffMs = Number.isFinite(retry.maxBackoffMs) ? retry.maxBackoffMs : 3e4;
|
|
2571
|
+
if (files.length === 1) {
|
|
2572
|
+
const file = files[0];
|
|
2573
|
+
const totalChunks = Math.ceil(file.size / effectiveChunkSize);
|
|
2574
|
+
const totalUploadSize = estimateTotalUploadSizeBytes(file.size, totalChunks, effectiveEncrypt);
|
|
2575
|
+
progress({ phase: "init", text: "Reserving server storage...", percent: 0, processedBytes: 0, totalBytes: file.size });
|
|
2576
|
+
const initRes = await fetchJson(this.fetchFn, `${baseUrl}/upload/init`, {
|
|
2577
|
+
method: "POST",
|
|
2578
|
+
timeoutMs: timeouts.initMs ?? 15e3,
|
|
2579
|
+
signal: effectiveSignal,
|
|
2580
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
2581
|
+
body: JSON.stringify({
|
|
2582
|
+
filename: transmittedFilenames[0],
|
|
2583
|
+
lifetime: lifetimeMs,
|
|
2584
|
+
isEncrypted: effectiveEncrypt,
|
|
2585
|
+
totalSize: totalUploadSize,
|
|
2586
|
+
totalChunks,
|
|
2587
|
+
...maxDownloads !== void 0 ? { maxDownloads } : {}
|
|
2588
|
+
})
|
|
2589
|
+
});
|
|
2590
|
+
if (!initRes.res.ok) {
|
|
2591
|
+
const errorJson = initRes.json;
|
|
2592
|
+
throw new DropgateProtocolError(errorJson?.error || `Server initialisation failed: ${initRes.res.status}`, { details: initRes.json || initRes.text });
|
|
2593
|
+
}
|
|
2594
|
+
const uploadId = initRes.json?.uploadId;
|
|
2595
|
+
if (!uploadId) throw new DropgateProtocolError("Server did not return a valid uploadId.");
|
|
2596
|
+
currentUploadIds.push(uploadId);
|
|
2597
|
+
uploadState = "uploading";
|
|
2598
|
+
await this._uploadFileChunks({
|
|
2599
|
+
file,
|
|
2600
|
+
uploadId,
|
|
2601
|
+
cryptoKey,
|
|
2602
|
+
effectiveChunkSize,
|
|
2603
|
+
totalChunks,
|
|
2604
|
+
totalUploadSize,
|
|
2605
|
+
baseOffset: 0,
|
|
2606
|
+
totalBytesAllFiles: file.size,
|
|
2607
|
+
progress,
|
|
2608
|
+
signal: effectiveSignal,
|
|
2609
|
+
baseUrl,
|
|
2610
|
+
retries,
|
|
2611
|
+
backoffMs: baseBackoffMs,
|
|
2612
|
+
maxBackoffMs,
|
|
2613
|
+
chunkTimeoutMs: timeouts.chunkMs ?? 6e4
|
|
2614
|
+
});
|
|
2615
|
+
progress({ phase: "complete", text: "Finalising upload...", percent: 100, processedBytes: file.size, totalBytes: file.size });
|
|
2616
|
+
uploadState = "completing";
|
|
2617
|
+
const completeRes = await fetchJson(this.fetchFn, `${baseUrl}/upload/complete`, {
|
|
2618
|
+
method: "POST",
|
|
2619
|
+
timeoutMs: timeouts.completeMs ?? 3e4,
|
|
2620
|
+
signal: effectiveSignal,
|
|
2621
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
2622
|
+
body: JSON.stringify({ uploadId })
|
|
2623
|
+
});
|
|
2624
|
+
if (!completeRes.res.ok) {
|
|
2625
|
+
const errorJson = completeRes.json;
|
|
2626
|
+
throw new DropgateProtocolError(errorJson?.error || "Finalisation failed.", { details: completeRes.json || completeRes.text });
|
|
2627
|
+
}
|
|
2628
|
+
const fileId = completeRes.json?.id;
|
|
2629
|
+
if (!fileId) throw new DropgateProtocolError("Server did not return a valid file id.");
|
|
2630
|
+
let downloadUrl2 = `${baseUrl}/${fileId}`;
|
|
2631
|
+
if (effectiveEncrypt && keyB64) downloadUrl2 += `#${keyB64}`;
|
|
2632
|
+
progress({ phase: "done", text: "Upload successful!", percent: 100, processedBytes: file.size, totalBytes: file.size });
|
|
2633
|
+
uploadState = "completed";
|
|
2634
|
+
return {
|
|
2635
|
+
downloadUrl: downloadUrl2,
|
|
2636
|
+
fileId,
|
|
2637
|
+
uploadId,
|
|
2638
|
+
baseUrl,
|
|
2639
|
+
...effectiveEncrypt && keyB64 ? { keyB64 } : {}
|
|
2640
|
+
};
|
|
1423
2641
|
}
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
2642
|
+
const fileManifest = files.map((f, i) => {
|
|
2643
|
+
const totalChunks = Math.ceil(f.size / effectiveChunkSize);
|
|
2644
|
+
const totalUploadSize = estimateTotalUploadSizeBytes(f.size, totalChunks, effectiveEncrypt);
|
|
2645
|
+
return { filename: transmittedFilenames[i], totalSize: totalUploadSize, totalChunks };
|
|
2646
|
+
});
|
|
2647
|
+
progress({ phase: "init", text: `Reserving server storage for ${files.length} files...`, percent: 0, processedBytes: 0, totalBytes: totalSizeBytes, totalFiles: files.length });
|
|
2648
|
+
const initBundleRes = await fetchJson(this.fetchFn, `${baseUrl}/upload/init-bundle`, {
|
|
2649
|
+
method: "POST",
|
|
2650
|
+
timeoutMs: timeouts.initMs ?? 15e3,
|
|
2651
|
+
signal: effectiveSignal,
|
|
2652
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
2653
|
+
body: JSON.stringify({
|
|
2654
|
+
fileCount: files.length,
|
|
2655
|
+
files: fileManifest,
|
|
2656
|
+
lifetime: lifetimeMs,
|
|
2657
|
+
isEncrypted: effectiveEncrypt,
|
|
2658
|
+
...maxDownloads !== void 0 ? { maxDownloads } : {}
|
|
2659
|
+
})
|
|
2660
|
+
});
|
|
2661
|
+
if (!initBundleRes.res.ok) {
|
|
2662
|
+
const errorJson = initBundleRes.json;
|
|
2663
|
+
throw new DropgateProtocolError(errorJson?.error || `Bundle initialisation failed: ${initBundleRes.res.status}`, { details: initBundleRes.json || initBundleRes.text });
|
|
1427
2664
|
}
|
|
1428
|
-
|
|
2665
|
+
const bundleInitJson = initBundleRes.json;
|
|
2666
|
+
const bundleUploadId = bundleInitJson?.bundleUploadId;
|
|
2667
|
+
const fileUploadIds = bundleInitJson?.fileUploadIds;
|
|
2668
|
+
if (!bundleUploadId || !fileUploadIds || fileUploadIds.length !== files.length) {
|
|
2669
|
+
throw new DropgateProtocolError("Server did not return valid bundle upload IDs.");
|
|
2670
|
+
}
|
|
2671
|
+
currentUploadIds.push(...fileUploadIds);
|
|
2672
|
+
uploadState = "uploading";
|
|
2673
|
+
const fileResults = [];
|
|
2674
|
+
let cumulativeBytes = 0;
|
|
2675
|
+
for (let fi = 0; fi < files.length; fi++) {
|
|
2676
|
+
const file = files[fi];
|
|
2677
|
+
const uploadId = fileUploadIds[fi];
|
|
2678
|
+
const totalChunks = fileManifest[fi].totalChunks;
|
|
2679
|
+
const totalUploadSize = fileManifest[fi].totalSize;
|
|
2680
|
+
progress({
|
|
2681
|
+
phase: "file-start",
|
|
2682
|
+
text: `Uploading file ${fi + 1} of ${files.length}: ${filenames[fi]}`,
|
|
2683
|
+
percent: totalSizeBytes > 0 ? cumulativeBytes / totalSizeBytes * 100 : 0,
|
|
2684
|
+
processedBytes: cumulativeBytes,
|
|
2685
|
+
totalBytes: totalSizeBytes,
|
|
2686
|
+
fileIndex: fi,
|
|
2687
|
+
totalFiles: files.length,
|
|
2688
|
+
currentFileName: filenames[fi]
|
|
2689
|
+
});
|
|
2690
|
+
await this._uploadFileChunks({
|
|
2691
|
+
file,
|
|
2692
|
+
uploadId,
|
|
2693
|
+
cryptoKey,
|
|
2694
|
+
effectiveChunkSize,
|
|
2695
|
+
totalChunks,
|
|
2696
|
+
totalUploadSize,
|
|
2697
|
+
baseOffset: cumulativeBytes,
|
|
2698
|
+
totalBytesAllFiles: totalSizeBytes,
|
|
2699
|
+
progress,
|
|
2700
|
+
signal: effectiveSignal,
|
|
2701
|
+
baseUrl,
|
|
2702
|
+
retries,
|
|
2703
|
+
backoffMs: baseBackoffMs,
|
|
2704
|
+
maxBackoffMs,
|
|
2705
|
+
chunkTimeoutMs: timeouts.chunkMs ?? 6e4,
|
|
2706
|
+
fileIndex: fi,
|
|
2707
|
+
totalFiles: files.length,
|
|
2708
|
+
currentFileName: filenames[fi]
|
|
2709
|
+
});
|
|
2710
|
+
const completeRes = await fetchJson(this.fetchFn, `${baseUrl}/upload/complete`, {
|
|
2711
|
+
method: "POST",
|
|
2712
|
+
timeoutMs: timeouts.completeMs ?? 3e4,
|
|
2713
|
+
signal: effectiveSignal,
|
|
2714
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
2715
|
+
body: JSON.stringify({ uploadId })
|
|
2716
|
+
});
|
|
2717
|
+
if (!completeRes.res.ok) {
|
|
2718
|
+
const errorJson = completeRes.json;
|
|
2719
|
+
throw new DropgateProtocolError(errorJson?.error || `File ${fi + 1} finalisation failed.`, { details: completeRes.json || completeRes.text });
|
|
2720
|
+
}
|
|
2721
|
+
const fileId = completeRes.json?.id;
|
|
2722
|
+
if (!fileId) throw new DropgateProtocolError(`Server did not return a valid file id for file ${fi + 1}.`);
|
|
2723
|
+
fileResults.push({ fileId, name: filenames[fi], size: file.size });
|
|
2724
|
+
cumulativeBytes += file.size;
|
|
2725
|
+
progress({
|
|
2726
|
+
phase: "file-complete",
|
|
2727
|
+
text: `File ${fi + 1} of ${files.length} uploaded.`,
|
|
2728
|
+
percent: totalSizeBytes > 0 ? cumulativeBytes / totalSizeBytes * 100 : 0,
|
|
2729
|
+
processedBytes: cumulativeBytes,
|
|
2730
|
+
totalBytes: totalSizeBytes,
|
|
2731
|
+
fileIndex: fi,
|
|
2732
|
+
totalFiles: files.length,
|
|
2733
|
+
currentFileName: filenames[fi]
|
|
2734
|
+
});
|
|
2735
|
+
}
|
|
2736
|
+
progress({ phase: "complete", text: "Finalising bundle...", percent: 100, processedBytes: totalSizeBytes, totalBytes: totalSizeBytes });
|
|
2737
|
+
uploadState = "completing";
|
|
2738
|
+
let encryptedManifestB64;
|
|
2739
|
+
if (effectiveEncrypt && cryptoKey) {
|
|
2740
|
+
const manifest = JSON.stringify({
|
|
2741
|
+
files: fileResults.map((r) => ({
|
|
2742
|
+
fileId: r.fileId,
|
|
2743
|
+
name: r.name,
|
|
2744
|
+
sizeBytes: r.size
|
|
2745
|
+
}))
|
|
2746
|
+
});
|
|
2747
|
+
const manifestBytes = new TextEncoder().encode(manifest);
|
|
2748
|
+
const encryptedBlob = await encryptToBlob(this.cryptoObj, manifestBytes.buffer, cryptoKey);
|
|
2749
|
+
const encryptedBuffer = new Uint8Array(await encryptedBlob.arrayBuffer());
|
|
2750
|
+
encryptedManifestB64 = this.base64.encode(encryptedBuffer);
|
|
2751
|
+
}
|
|
2752
|
+
const completeBundleRes = await fetchJson(this.fetchFn, `${baseUrl}/upload/complete-bundle`, {
|
|
2753
|
+
method: "POST",
|
|
2754
|
+
timeoutMs: timeouts.completeMs ?? 3e4,
|
|
2755
|
+
signal: effectiveSignal,
|
|
2756
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
2757
|
+
body: JSON.stringify({
|
|
2758
|
+
bundleUploadId,
|
|
2759
|
+
...encryptedManifestB64 ? { encryptedManifest: encryptedManifestB64 } : {}
|
|
2760
|
+
})
|
|
2761
|
+
});
|
|
2762
|
+
if (!completeBundleRes.res.ok) {
|
|
2763
|
+
const errorJson = completeBundleRes.json;
|
|
2764
|
+
throw new DropgateProtocolError(errorJson?.error || "Bundle finalisation failed.", { details: completeBundleRes.json || completeBundleRes.text });
|
|
2765
|
+
}
|
|
2766
|
+
const bundleId = completeBundleRes.json?.bundleId;
|
|
2767
|
+
if (!bundleId) throw new DropgateProtocolError("Server did not return a valid bundle id.");
|
|
2768
|
+
let downloadUrl = `${baseUrl}/b/${bundleId}`;
|
|
2769
|
+
if (effectiveEncrypt && keyB64) downloadUrl += `#${keyB64}`;
|
|
2770
|
+
progress({ phase: "done", text: "Upload successful!", percent: 100, processedBytes: totalSizeBytes, totalBytes: totalSizeBytes });
|
|
2771
|
+
uploadState = "completed";
|
|
2772
|
+
return {
|
|
2773
|
+
downloadUrl,
|
|
2774
|
+
bundleId,
|
|
2775
|
+
baseUrl,
|
|
2776
|
+
files: fileResults,
|
|
2777
|
+
...effectiveEncrypt && keyB64 ? { keyB64 } : {}
|
|
2778
|
+
};
|
|
2779
|
+
} catch (err2) {
|
|
2780
|
+
if (err2 instanceof Error && (err2.name === "AbortError" || err2.message?.includes("abort"))) {
|
|
2781
|
+
uploadState = "cancelled";
|
|
2782
|
+
onCancel?.();
|
|
2783
|
+
} else {
|
|
2784
|
+
uploadState = "error";
|
|
2785
|
+
}
|
|
2786
|
+
throw err2;
|
|
1429
2787
|
}
|
|
1430
|
-
}
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
ackResolve = resolve;
|
|
1441
|
-
});
|
|
1442
|
-
conn.on("data", (data) => {
|
|
1443
|
-
if (!data || typeof data !== "object" || data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
|
|
1444
|
-
return;
|
|
2788
|
+
})();
|
|
2789
|
+
const callCancelEndpoint = async (uploadId) => {
|
|
2790
|
+
try {
|
|
2791
|
+
await fetchJson(this.fetchFn, `${this.baseUrl}/upload/cancel`, {
|
|
2792
|
+
method: "POST",
|
|
2793
|
+
timeoutMs: 5e3,
|
|
2794
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
2795
|
+
body: JSON.stringify({ uploadId })
|
|
2796
|
+
});
|
|
2797
|
+
} catch {
|
|
1445
2798
|
}
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
2799
|
+
};
|
|
2800
|
+
return {
|
|
2801
|
+
result: uploadPromise,
|
|
2802
|
+
cancel: (reason) => {
|
|
2803
|
+
if (uploadState === "completed" || uploadState === "cancelled") return;
|
|
2804
|
+
uploadState = "cancelled";
|
|
2805
|
+
for (const id of currentUploadIds) {
|
|
2806
|
+
callCancelEndpoint(id).catch(() => {
|
|
2807
|
+
});
|
|
2808
|
+
}
|
|
2809
|
+
internalController?.abort(new DropgateAbortError(reason || "Upload cancelled by user."));
|
|
2810
|
+
},
|
|
2811
|
+
getStatus: () => uploadState
|
|
2812
|
+
};
|
|
2813
|
+
}
|
|
2814
|
+
/**
|
|
2815
|
+
* Upload a single file's chunks to the server. Used internally by uploadFiles().
|
|
2816
|
+
*/
|
|
2817
|
+
async _uploadFileChunks(params) {
|
|
2818
|
+
const {
|
|
2819
|
+
file,
|
|
2820
|
+
uploadId,
|
|
2821
|
+
cryptoKey,
|
|
2822
|
+
effectiveChunkSize,
|
|
2823
|
+
totalChunks,
|
|
2824
|
+
baseOffset,
|
|
2825
|
+
totalBytesAllFiles,
|
|
2826
|
+
progress,
|
|
2827
|
+
signal,
|
|
2828
|
+
baseUrl,
|
|
2829
|
+
retries,
|
|
2830
|
+
backoffMs,
|
|
2831
|
+
maxBackoffMs,
|
|
2832
|
+
chunkTimeoutMs,
|
|
2833
|
+
fileIndex,
|
|
2834
|
+
totalFiles,
|
|
2835
|
+
currentFileName
|
|
2836
|
+
} = params;
|
|
2837
|
+
for (let i = 0; i < totalChunks; i++) {
|
|
2838
|
+
if (signal?.aborted) {
|
|
2839
|
+
throw signal.reason || new DropgateAbortError();
|
|
1452
2840
|
}
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
2841
|
+
const start = i * effectiveChunkSize;
|
|
2842
|
+
const end = Math.min(start + effectiveChunkSize, file.size);
|
|
2843
|
+
const chunkSlice = file.slice(start, end);
|
|
2844
|
+
const processedBytes = baseOffset + start;
|
|
2845
|
+
const percent = totalBytesAllFiles > 0 ? processedBytes / totalBytesAllFiles * 100 : 0;
|
|
2846
|
+
progress({
|
|
2847
|
+
phase: "chunk",
|
|
2848
|
+
text: `Uploading chunk ${i + 1} of ${totalChunks}...`,
|
|
2849
|
+
percent,
|
|
2850
|
+
processedBytes,
|
|
2851
|
+
totalBytes: totalBytesAllFiles,
|
|
2852
|
+
chunkIndex: i,
|
|
2853
|
+
totalChunks,
|
|
2854
|
+
...fileIndex !== void 0 ? { fileIndex, totalFiles, currentFileName } : {}
|
|
2855
|
+
});
|
|
2856
|
+
const chunkBuffer = await chunkSlice.arrayBuffer();
|
|
2857
|
+
let uploadBlob;
|
|
2858
|
+
if (cryptoKey) {
|
|
2859
|
+
uploadBlob = await encryptToBlob(this.cryptoObj, chunkBuffer, cryptoKey);
|
|
2860
|
+
} else {
|
|
2861
|
+
uploadBlob = new Blob([chunkBuffer]);
|
|
1456
2862
|
}
|
|
1457
|
-
if (
|
|
1458
|
-
|
|
1459
|
-
|
|
2863
|
+
if (uploadBlob.size > effectiveChunkSize + 1024) {
|
|
2864
|
+
throw new DropgateValidationError("Chunk too large (client-side). Check chunk size settings.");
|
|
2865
|
+
}
|
|
2866
|
+
const toHash = await uploadBlob.arrayBuffer();
|
|
2867
|
+
const hashHex = await sha256Hex(this.cryptoObj, toHash);
|
|
2868
|
+
await this._attemptChunkUpload(
|
|
2869
|
+
`${baseUrl}/upload/chunk`,
|
|
2870
|
+
{ method: "POST", headers: { "Content-Type": "application/octet-stream", "X-Upload-ID": uploadId, "X-Chunk-Index": String(i), "X-Chunk-Hash": hashHex }, body: uploadBlob },
|
|
2871
|
+
{ retries, backoffMs, maxBackoffMs, timeoutMs: chunkTimeoutMs, signal, progress, chunkIndex: i, totalChunks, chunkSize: effectiveChunkSize, fileSizeBytes: totalBytesAllFiles }
|
|
2872
|
+
);
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
/**
|
|
2876
|
+
* Download one or more files from the server with optional decryption.
|
|
2877
|
+
*
|
|
2878
|
+
* For single files, use `fileId`. For bundles, use `bundleId`.
|
|
2879
|
+
* With `asZip: true` on bundles, streams a ZIP archive via `onData`.
|
|
2880
|
+
* Without `asZip`, delivers files individually via `onFileStart`/`onFileData`/`onFileEnd`.
|
|
2881
|
+
*
|
|
2882
|
+
* @param opts - Download options including file/bundle ID and optional key.
|
|
2883
|
+
* @returns Download result containing filename(s) and received bytes.
|
|
2884
|
+
*/
|
|
2885
|
+
async downloadFiles(opts) {
|
|
2886
|
+
const {
|
|
2887
|
+
fileId,
|
|
2888
|
+
bundleId,
|
|
2889
|
+
keyB64,
|
|
2890
|
+
asZip,
|
|
2891
|
+
zipFilename: _zipFilename,
|
|
2892
|
+
onProgress,
|
|
2893
|
+
onData,
|
|
2894
|
+
onFileStart,
|
|
2895
|
+
onFileData,
|
|
2896
|
+
onFileEnd,
|
|
2897
|
+
signal,
|
|
2898
|
+
timeoutMs = 6e4
|
|
2899
|
+
} = opts;
|
|
2900
|
+
const progress = (evt) => {
|
|
2901
|
+
try {
|
|
2902
|
+
if (onProgress) onProgress(evt);
|
|
2903
|
+
} catch {
|
|
1460
2904
|
}
|
|
1461
|
-
|
|
1462
|
-
|
|
2905
|
+
};
|
|
2906
|
+
if (!fileId && !bundleId) {
|
|
2907
|
+
throw new DropgateValidationError("Either fileId or bundleId is required.");
|
|
2908
|
+
}
|
|
2909
|
+
progress({ phase: "server-info", text: "Checking server...", processedBytes: 0, totalBytes: 0, percent: 0 });
|
|
2910
|
+
const compat = await this.connect({ timeoutMs, signal });
|
|
2911
|
+
const { baseUrl } = compat;
|
|
2912
|
+
progress({ phase: "server-compat", text: compat.message, processedBytes: 0, totalBytes: 0, percent: 0 });
|
|
2913
|
+
if (!compat.compatible) throw new DropgateValidationError(compat.message);
|
|
2914
|
+
if (fileId) {
|
|
2915
|
+
return this._downloadSingleFile({ fileId, keyB64, onProgress, onData, signal, timeoutMs, baseUrl, compat });
|
|
2916
|
+
}
|
|
2917
|
+
progress({ phase: "metadata", text: "Fetching bundle info...", processedBytes: 0, totalBytes: 0, percent: 0 });
|
|
2918
|
+
let bundleMeta;
|
|
2919
|
+
try {
|
|
2920
|
+
bundleMeta = await this.getBundleMetadata(bundleId, keyB64, { timeoutMs, signal });
|
|
2921
|
+
} catch (err2) {
|
|
2922
|
+
if (err2 instanceof DropgateError) throw err2;
|
|
2923
|
+
if (err2 instanceof Error && err2.name === "AbortError") throw new DropgateAbortError("Download cancelled.");
|
|
2924
|
+
throw new DropgateNetworkError("Could not fetch bundle metadata.", { cause: err2 });
|
|
2925
|
+
}
|
|
2926
|
+
const isEncrypted = Boolean(bundleMeta.isEncrypted);
|
|
2927
|
+
const totalBytes = bundleMeta.totalSizeBytes || 0;
|
|
2928
|
+
let cryptoKey;
|
|
2929
|
+
const filenames = [];
|
|
2930
|
+
if (isEncrypted) {
|
|
2931
|
+
if (!keyB64) throw new DropgateValidationError("Decryption key is required for encrypted bundles.");
|
|
2932
|
+
if (!this.cryptoObj?.subtle) throw new DropgateValidationError("Web Crypto API not available for decryption.");
|
|
2933
|
+
try {
|
|
2934
|
+
cryptoKey = await importKeyFromBase64(this.cryptoObj, keyB64, this.base64);
|
|
2935
|
+
if (bundleMeta.sealed && bundleMeta.encryptedManifest) {
|
|
2936
|
+
const encryptedBytes = this.base64.decode(bundleMeta.encryptedManifest);
|
|
2937
|
+
const decryptedBuffer = await decryptChunk(this.cryptoObj, encryptedBytes, cryptoKey);
|
|
2938
|
+
const manifestJson = new TextDecoder().decode(decryptedBuffer);
|
|
2939
|
+
const manifest = JSON.parse(manifestJson);
|
|
2940
|
+
bundleMeta.files = manifest.files.map((f) => ({
|
|
2941
|
+
fileId: f.fileId,
|
|
2942
|
+
sizeBytes: f.sizeBytes,
|
|
2943
|
+
filename: f.name
|
|
2944
|
+
}));
|
|
2945
|
+
bundleMeta.fileCount = bundleMeta.files.length;
|
|
2946
|
+
for (const f of bundleMeta.files) {
|
|
2947
|
+
filenames.push(f.filename || "file");
|
|
2948
|
+
}
|
|
2949
|
+
} else {
|
|
2950
|
+
for (const f of bundleMeta.files) {
|
|
2951
|
+
filenames.push(await decryptFilenameFromBase64(this.cryptoObj, f.encryptedFilename, cryptoKey, this.base64));
|
|
2952
|
+
}
|
|
2953
|
+
}
|
|
2954
|
+
} catch (err2) {
|
|
2955
|
+
throw new DropgateError("Failed to decrypt bundle manifest.", { code: "DECRYPT_MANIFEST_FAILED", cause: err2 });
|
|
1463
2956
|
}
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
2957
|
+
} else {
|
|
2958
|
+
for (const f of bundleMeta.files) {
|
|
2959
|
+
filenames.push(f.filename || "file");
|
|
1467
2960
|
}
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
2961
|
+
}
|
|
2962
|
+
let totalReceivedBytes = 0;
|
|
2963
|
+
if (asZip && onData) {
|
|
2964
|
+
const zipWriter = new StreamingZipWriter(onData);
|
|
2965
|
+
for (let fi = 0; fi < bundleMeta.files.length; fi++) {
|
|
2966
|
+
const fileMeta = bundleMeta.files[fi];
|
|
2967
|
+
const name = filenames[fi];
|
|
2968
|
+
progress({
|
|
2969
|
+
phase: "zipping",
|
|
2970
|
+
text: `Downloading ${name}...`,
|
|
2971
|
+
percent: totalBytes > 0 ? totalReceivedBytes / totalBytes * 100 : 0,
|
|
2972
|
+
processedBytes: totalReceivedBytes,
|
|
2973
|
+
totalBytes,
|
|
2974
|
+
fileIndex: fi,
|
|
2975
|
+
totalFiles: bundleMeta.files.length,
|
|
2976
|
+
currentFileName: name
|
|
2977
|
+
});
|
|
2978
|
+
zipWriter.startFile(name);
|
|
2979
|
+
const baseReceivedBytes = totalReceivedBytes;
|
|
2980
|
+
const bytesReceived = await this._streamFileIntoCallback(
|
|
2981
|
+
baseUrl,
|
|
2982
|
+
fileMeta.fileId,
|
|
2983
|
+
isEncrypted,
|
|
2984
|
+
cryptoKey,
|
|
2985
|
+
compat,
|
|
2986
|
+
signal,
|
|
2987
|
+
timeoutMs,
|
|
2988
|
+
(chunk) => {
|
|
2989
|
+
zipWriter.writeChunk(chunk);
|
|
2990
|
+
},
|
|
2991
|
+
(fileBytes) => {
|
|
2992
|
+
const current = baseReceivedBytes + fileBytes;
|
|
2993
|
+
progress({
|
|
2994
|
+
phase: "zipping",
|
|
2995
|
+
text: `Downloading ${name}...`,
|
|
2996
|
+
percent: totalBytes > 0 ? current / totalBytes * 100 : 0,
|
|
2997
|
+
processedBytes: current,
|
|
2998
|
+
totalBytes,
|
|
2999
|
+
fileIndex: fi,
|
|
3000
|
+
totalFiles: bundleMeta.files.length,
|
|
3001
|
+
currentFileName: name
|
|
3002
|
+
});
|
|
3003
|
+
}
|
|
3004
|
+
);
|
|
3005
|
+
zipWriter.endFile();
|
|
3006
|
+
totalReceivedBytes += bytesReceived;
|
|
1473
3007
|
}
|
|
1474
|
-
|
|
1475
|
-
conn.on("open", async () => {
|
|
3008
|
+
await zipWriter.finalize();
|
|
1476
3009
|
try {
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
size: file.size,
|
|
1483
|
-
mime: file.type || "application/octet-stream"
|
|
3010
|
+
await fetchJson(this.fetchFn, `${baseUrl}/api/bundle/${bundleId}/downloaded`, {
|
|
3011
|
+
method: "POST",
|
|
3012
|
+
timeoutMs: 5e3,
|
|
3013
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
3014
|
+
body: "{}"
|
|
1484
3015
|
});
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
}
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
}
|
|
1528
|
-
});
|
|
1529
|
-
}
|
|
3016
|
+
} catch {
|
|
3017
|
+
}
|
|
3018
|
+
progress({ phase: "complete", text: "Download complete!", percent: 100, processedBytes: totalReceivedBytes, totalBytes });
|
|
3019
|
+
return { filenames, receivedBytes: totalReceivedBytes, wasEncrypted: isEncrypted };
|
|
3020
|
+
} else {
|
|
3021
|
+
const dataCallback = onFileData || onData;
|
|
3022
|
+
for (let fi = 0; fi < bundleMeta.files.length; fi++) {
|
|
3023
|
+
const fileMeta = bundleMeta.files[fi];
|
|
3024
|
+
const name = filenames[fi];
|
|
3025
|
+
progress({
|
|
3026
|
+
phase: "downloading",
|
|
3027
|
+
text: `Downloading ${name}...`,
|
|
3028
|
+
percent: totalBytes > 0 ? totalReceivedBytes / totalBytes * 100 : 0,
|
|
3029
|
+
processedBytes: totalReceivedBytes,
|
|
3030
|
+
totalBytes,
|
|
3031
|
+
fileIndex: fi,
|
|
3032
|
+
totalFiles: bundleMeta.files.length,
|
|
3033
|
+
currentFileName: name
|
|
3034
|
+
});
|
|
3035
|
+
onFileStart?.({ name, size: fileMeta.sizeBytes, index: fi });
|
|
3036
|
+
const baseReceivedBytes = totalReceivedBytes;
|
|
3037
|
+
const bytesReceived = await this._streamFileIntoCallback(
|
|
3038
|
+
baseUrl,
|
|
3039
|
+
fileMeta.fileId,
|
|
3040
|
+
isEncrypted,
|
|
3041
|
+
cryptoKey,
|
|
3042
|
+
compat,
|
|
3043
|
+
signal,
|
|
3044
|
+
timeoutMs,
|
|
3045
|
+
dataCallback ? (chunk) => dataCallback(chunk) : void 0,
|
|
3046
|
+
(fileBytes) => {
|
|
3047
|
+
const current = baseReceivedBytes + fileBytes;
|
|
3048
|
+
progress({
|
|
3049
|
+
phase: "downloading",
|
|
3050
|
+
text: `Downloading ${name}...`,
|
|
3051
|
+
percent: totalBytes > 0 ? current / totalBytes * 100 : 0,
|
|
3052
|
+
processedBytes: current,
|
|
3053
|
+
totalBytes,
|
|
3054
|
+
fileIndex: fi,
|
|
3055
|
+
totalFiles: bundleMeta.files.length,
|
|
3056
|
+
currentFileName: name
|
|
3057
|
+
});
|
|
1530
3058
|
}
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
conn.send({ t: "end" });
|
|
1535
|
-
const ackTimeoutMs = Number.isFinite(endAckTimeoutMs) ? Math.max(endAckTimeoutMs, Math.ceil(file.size / (1024 * 1024)) * 1e3) : null;
|
|
1536
|
-
const ackResult = await Promise.race([
|
|
1537
|
-
ackPromise,
|
|
1538
|
-
sleep(ackTimeoutMs || 15e3).catch(() => null)
|
|
1539
|
-
]);
|
|
1540
|
-
if (isStopped()) return;
|
|
1541
|
-
if (!ackResult || typeof ackResult !== "object") {
|
|
1542
|
-
throw new DropgateNetworkError("Receiver did not confirm completion.");
|
|
1543
|
-
}
|
|
1544
|
-
const ackData = ackResult;
|
|
1545
|
-
const ackTotal = Number(ackData.total) || file.size;
|
|
1546
|
-
const ackReceived = Number(ackData.received) || 0;
|
|
1547
|
-
if (ackTotal && ackReceived < ackTotal) {
|
|
1548
|
-
throw new DropgateNetworkError("Receiver reported an incomplete transfer.");
|
|
1549
|
-
}
|
|
1550
|
-
reportProgress({ received: ackReceived || ackTotal, total: ackTotal });
|
|
1551
|
-
safeComplete();
|
|
1552
|
-
} catch (err) {
|
|
1553
|
-
safeError(err);
|
|
3059
|
+
);
|
|
3060
|
+
onFileEnd?.({ name, index: fi });
|
|
3061
|
+
totalReceivedBytes += bytesReceived;
|
|
1554
3062
|
}
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
3063
|
+
progress({ phase: "complete", text: "Download complete!", percent: 100, processedBytes: totalReceivedBytes, totalBytes });
|
|
3064
|
+
return { filenames, receivedBytes: totalReceivedBytes, wasEncrypted: isEncrypted };
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
/**
|
|
3068
|
+
* Download a single file, handling encryption/decryption internally.
|
|
3069
|
+
* Preserves the original downloadFile() behavior.
|
|
3070
|
+
*/
|
|
3071
|
+
async _downloadSingleFile(params) {
|
|
3072
|
+
const { fileId, keyB64, onProgress, onData, signal, timeoutMs, baseUrl, compat } = params;
|
|
3073
|
+
const progress = (evt) => {
|
|
3074
|
+
try {
|
|
3075
|
+
if (onProgress) onProgress(evt);
|
|
3076
|
+
} catch {
|
|
1563
3077
|
}
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
3078
|
+
};
|
|
3079
|
+
progress({ phase: "metadata", text: "Fetching file info...", processedBytes: 0, totalBytes: 0, percent: 0 });
|
|
3080
|
+
let metadata;
|
|
3081
|
+
try {
|
|
3082
|
+
metadata = await this.getFileMetadata(fileId, { timeoutMs, signal });
|
|
3083
|
+
} catch (err2) {
|
|
3084
|
+
if (err2 instanceof DropgateError) throw err2;
|
|
3085
|
+
if (err2 instanceof Error && err2.name === "AbortError") throw new DropgateAbortError("Download cancelled.");
|
|
3086
|
+
throw new DropgateNetworkError("Could not fetch file metadata.", { cause: err2 });
|
|
3087
|
+
}
|
|
3088
|
+
const isEncrypted = Boolean(metadata.isEncrypted);
|
|
3089
|
+
const totalBytes = metadata.sizeBytes || 0;
|
|
3090
|
+
if (!onData && totalBytes > MAX_IN_MEMORY_DOWNLOAD_BYTES) {
|
|
3091
|
+
const sizeMB = Math.round(totalBytes / (1024 * 1024));
|
|
3092
|
+
const limitMB = Math.round(MAX_IN_MEMORY_DOWNLOAD_BYTES / (1024 * 1024));
|
|
3093
|
+
throw new DropgateValidationError(
|
|
3094
|
+
`File is too large (${sizeMB}MB) to download without streaming. Provide an onData callback to stream files larger than ${limitMB}MB.`
|
|
3095
|
+
);
|
|
3096
|
+
}
|
|
3097
|
+
let filename;
|
|
3098
|
+
let cryptoKey;
|
|
3099
|
+
if (isEncrypted) {
|
|
3100
|
+
if (!keyB64) throw new DropgateValidationError("Decryption key is required for encrypted files.");
|
|
3101
|
+
if (!this.cryptoObj?.subtle) throw new DropgateValidationError("Web Crypto API not available for decryption.");
|
|
3102
|
+
progress({ phase: "decrypting", text: "Preparing decryption...", processedBytes: 0, totalBytes: 0, percent: 0 });
|
|
3103
|
+
try {
|
|
3104
|
+
cryptoKey = await importKeyFromBase64(this.cryptoObj, keyB64, this.base64);
|
|
3105
|
+
filename = await decryptFilenameFromBase64(this.cryptoObj, metadata.encryptedFilename, cryptoKey, this.base64);
|
|
3106
|
+
} catch (err2) {
|
|
3107
|
+
throw new DropgateError("Failed to decrypt filename.", { code: "DECRYPT_FILENAME_FAILED", cause: err2 });
|
|
1573
3108
|
}
|
|
1574
|
-
}
|
|
1575
|
-
|
|
1576
|
-
return {
|
|
1577
|
-
peer,
|
|
1578
|
-
code,
|
|
1579
|
-
sessionId,
|
|
1580
|
-
stop,
|
|
1581
|
-
getStatus: () => state,
|
|
1582
|
-
getBytesSent: () => sentBytes,
|
|
1583
|
-
getConnectedPeerId: () => {
|
|
1584
|
-
if (!activeConn) return null;
|
|
1585
|
-
return activeConn.peer || null;
|
|
3109
|
+
} else {
|
|
3110
|
+
filename = metadata.filename || "file";
|
|
1586
3111
|
}
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
}
|
|
1615
|
-
if (!Peer) {
|
|
1616
|
-
throw new DropgateValidationError(
|
|
1617
|
-
"PeerJS Peer constructor is required. Install peerjs and pass it as the Peer option."
|
|
3112
|
+
progress({ phase: "downloading", text: "Starting download...", percent: 0, processedBytes: 0, totalBytes });
|
|
3113
|
+
const dataChunks = [];
|
|
3114
|
+
const collectData = !onData;
|
|
3115
|
+
const receivedBytes = await this._streamFileIntoCallback(
|
|
3116
|
+
baseUrl,
|
|
3117
|
+
fileId,
|
|
3118
|
+
isEncrypted,
|
|
3119
|
+
cryptoKey,
|
|
3120
|
+
compat,
|
|
3121
|
+
signal,
|
|
3122
|
+
timeoutMs,
|
|
3123
|
+
async (chunk) => {
|
|
3124
|
+
if (collectData) {
|
|
3125
|
+
dataChunks.push(chunk);
|
|
3126
|
+
} else {
|
|
3127
|
+
await onData(chunk);
|
|
3128
|
+
}
|
|
3129
|
+
},
|
|
3130
|
+
(bytes) => {
|
|
3131
|
+
progress({
|
|
3132
|
+
phase: "downloading",
|
|
3133
|
+
text: "Downloading...",
|
|
3134
|
+
percent: totalBytes > 0 ? bytes / totalBytes * 100 : 0,
|
|
3135
|
+
processedBytes: bytes,
|
|
3136
|
+
totalBytes
|
|
3137
|
+
});
|
|
3138
|
+
}
|
|
1618
3139
|
);
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
{ peerjsPath, iceServers },
|
|
1630
|
-
p2pCaps
|
|
1631
|
-
);
|
|
1632
|
-
const peerOpts = buildPeerOptions({
|
|
1633
|
-
host,
|
|
1634
|
-
port,
|
|
1635
|
-
peerjsPath: finalPath,
|
|
1636
|
-
secure,
|
|
1637
|
-
iceServers: finalIceServers
|
|
1638
|
-
});
|
|
1639
|
-
const peer = new Peer(void 0, peerOpts);
|
|
1640
|
-
let state = "initializing";
|
|
1641
|
-
let total = 0;
|
|
1642
|
-
let received = 0;
|
|
1643
|
-
let currentSessionId = null;
|
|
1644
|
-
let lastProgressSentAt = 0;
|
|
1645
|
-
const progressIntervalMs = 120;
|
|
1646
|
-
let writeQueue = Promise.resolve();
|
|
1647
|
-
let watchdogTimer = null;
|
|
1648
|
-
let activeConn = null;
|
|
1649
|
-
const resetWatchdog = () => {
|
|
1650
|
-
if (watchdogTimeoutMs <= 0) return;
|
|
1651
|
-
if (watchdogTimer) {
|
|
1652
|
-
clearTimeout(watchdogTimer);
|
|
3140
|
+
progress({ phase: "complete", text: "Download complete!", percent: 100, processedBytes: receivedBytes, totalBytes });
|
|
3141
|
+
let data;
|
|
3142
|
+
if (collectData && dataChunks.length > 0) {
|
|
3143
|
+
const totalLength = dataChunks.reduce((sum, c) => sum + c.length, 0);
|
|
3144
|
+
data = new Uint8Array(totalLength);
|
|
3145
|
+
let offset = 0;
|
|
3146
|
+
for (const c of dataChunks) {
|
|
3147
|
+
data.set(c, offset);
|
|
3148
|
+
offset += c.length;
|
|
3149
|
+
}
|
|
1653
3150
|
}
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
3151
|
+
return {
|
|
3152
|
+
filename,
|
|
3153
|
+
receivedBytes,
|
|
3154
|
+
wasEncrypted: isEncrypted,
|
|
3155
|
+
...data ? { data } : {}
|
|
3156
|
+
};
|
|
3157
|
+
}
|
|
3158
|
+
/**
|
|
3159
|
+
* Stream a single file's content into a callback, handling decryption if needed.
|
|
3160
|
+
* Returns total bytes received from the network (encrypted size).
|
|
3161
|
+
*/
|
|
3162
|
+
async _streamFileIntoCallback(baseUrl, fileId, isEncrypted, cryptoKey, compat, signal, timeoutMs, onChunk, onBytesReceived) {
|
|
3163
|
+
const { signal: downloadSignal, cleanup: downloadCleanup } = makeAbortSignal(signal, timeoutMs);
|
|
3164
|
+
let receivedBytes = 0;
|
|
3165
|
+
try {
|
|
3166
|
+
const downloadRes = await this.fetchFn(`${baseUrl}/api/file/${fileId}`, {
|
|
3167
|
+
method: "GET",
|
|
3168
|
+
signal: downloadSignal
|
|
3169
|
+
});
|
|
3170
|
+
if (!downloadRes.ok) throw new DropgateProtocolError(`Download failed (status ${downloadRes.status}).`);
|
|
3171
|
+
if (!downloadRes.body) throw new DropgateProtocolError("Streaming response not available.");
|
|
3172
|
+
const reader = downloadRes.body.getReader();
|
|
3173
|
+
if (isEncrypted && cryptoKey) {
|
|
3174
|
+
const downloadChunkSize = Number.isFinite(compat.serverInfo?.capabilities?.upload?.chunkSize) && compat.serverInfo.capabilities.upload.chunkSize > 0 ? compat.serverInfo.capabilities.upload.chunkSize : this.chunkSize;
|
|
3175
|
+
const ENCRYPTED_CHUNK_SIZE = downloadChunkSize + ENCRYPTION_OVERHEAD_PER_CHUNK;
|
|
3176
|
+
const pendingChunks = [];
|
|
3177
|
+
let pendingLength = 0;
|
|
3178
|
+
const flushPending = () => {
|
|
3179
|
+
if (pendingChunks.length === 0) return new Uint8Array(0);
|
|
3180
|
+
if (pendingChunks.length === 1) {
|
|
3181
|
+
const result2 = pendingChunks[0];
|
|
3182
|
+
pendingChunks.length = 0;
|
|
3183
|
+
pendingLength = 0;
|
|
3184
|
+
return result2;
|
|
3185
|
+
}
|
|
3186
|
+
const result = new Uint8Array(pendingLength);
|
|
3187
|
+
let offset = 0;
|
|
3188
|
+
for (const chunk of pendingChunks) {
|
|
3189
|
+
result.set(chunk, offset);
|
|
3190
|
+
offset += chunk.length;
|
|
3191
|
+
}
|
|
3192
|
+
pendingChunks.length = 0;
|
|
3193
|
+
pendingLength = 0;
|
|
3194
|
+
return result;
|
|
3195
|
+
};
|
|
3196
|
+
while (true) {
|
|
3197
|
+
if (signal?.aborted) throw new DropgateAbortError("Download cancelled.");
|
|
3198
|
+
const { done, value } = await reader.read();
|
|
3199
|
+
if (done) break;
|
|
3200
|
+
pendingChunks.push(value);
|
|
3201
|
+
pendingLength += value.length;
|
|
3202
|
+
receivedBytes += value.length;
|
|
3203
|
+
if (onBytesReceived) onBytesReceived(receivedBytes);
|
|
3204
|
+
while (pendingLength >= ENCRYPTED_CHUNK_SIZE) {
|
|
3205
|
+
const buffer = flushPending();
|
|
3206
|
+
const encryptedChunk = buffer.subarray(0, ENCRYPTED_CHUNK_SIZE);
|
|
3207
|
+
if (buffer.length > ENCRYPTED_CHUNK_SIZE) {
|
|
3208
|
+
pendingChunks.push(buffer.subarray(ENCRYPTED_CHUNK_SIZE));
|
|
3209
|
+
pendingLength = buffer.length - ENCRYPTED_CHUNK_SIZE;
|
|
3210
|
+
}
|
|
3211
|
+
const decryptedBuffer = await decryptChunk(this.cryptoObj, encryptedChunk, cryptoKey);
|
|
3212
|
+
if (onChunk) await onChunk(new Uint8Array(decryptedBuffer));
|
|
3213
|
+
}
|
|
3214
|
+
}
|
|
3215
|
+
if (pendingLength > 0) {
|
|
3216
|
+
const buffer = flushPending();
|
|
3217
|
+
const decryptedBuffer = await decryptChunk(this.cryptoObj, buffer, cryptoKey);
|
|
3218
|
+
if (onChunk) await onChunk(new Uint8Array(decryptedBuffer));
|
|
3219
|
+
}
|
|
3220
|
+
} else {
|
|
3221
|
+
while (true) {
|
|
3222
|
+
if (signal?.aborted) throw new DropgateAbortError("Download cancelled.");
|
|
3223
|
+
const { done, value } = await reader.read();
|
|
3224
|
+
if (done) break;
|
|
3225
|
+
receivedBytes += value.length;
|
|
3226
|
+
if (onBytesReceived) onBytesReceived(receivedBytes);
|
|
3227
|
+
if (onChunk) await onChunk(value);
|
|
3228
|
+
}
|
|
1657
3229
|
}
|
|
1658
|
-
}
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
}
|
|
1665
|
-
};
|
|
1666
|
-
const safeError = (err) => {
|
|
1667
|
-
if (state === "closed" || state === "completed" || state === "cancelled") return;
|
|
1668
|
-
state = "closed";
|
|
1669
|
-
onError?.(err);
|
|
1670
|
-
cleanup();
|
|
1671
|
-
};
|
|
1672
|
-
const safeComplete = (completeData) => {
|
|
1673
|
-
if (state !== "transferring") return;
|
|
1674
|
-
state = "completed";
|
|
1675
|
-
onComplete?.(completeData);
|
|
1676
|
-
cleanup();
|
|
1677
|
-
};
|
|
1678
|
-
const cleanup = () => {
|
|
1679
|
-
clearWatchdog();
|
|
1680
|
-
if (typeof window !== "undefined") {
|
|
1681
|
-
window.removeEventListener("beforeunload", handleUnload);
|
|
3230
|
+
} catch (err2) {
|
|
3231
|
+
if (err2 instanceof DropgateError) throw err2;
|
|
3232
|
+
if (err2 instanceof Error && err2.name === "AbortError") throw new DropgateAbortError("Download cancelled.");
|
|
3233
|
+
throw new DropgateNetworkError("Download failed.", { cause: err2 });
|
|
3234
|
+
} finally {
|
|
3235
|
+
downloadCleanup();
|
|
1682
3236
|
}
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
3237
|
+
return receivedBytes;
|
|
3238
|
+
}
|
|
3239
|
+
/**
|
|
3240
|
+
* Start a P2P send session. Connects to the signalling server and waits for a receiver.
|
|
3241
|
+
*
|
|
3242
|
+
* Server info, peerjsPath, iceServers, and cryptoObj are provided automatically
|
|
3243
|
+
* from the client's cached server info and configuration.
|
|
3244
|
+
*
|
|
3245
|
+
* @param opts - P2P send options (file, Peer constructor, callbacks, tuning).
|
|
3246
|
+
* @returns P2P send session with control methods.
|
|
3247
|
+
* @throws {DropgateValidationError} If P2P is not enabled on the server.
|
|
3248
|
+
* @throws {DropgateNetworkError} If the signalling server cannot be reached.
|
|
3249
|
+
*/
|
|
3250
|
+
async p2pSend(opts) {
|
|
3251
|
+
const compat = await this.connect();
|
|
3252
|
+
if (!compat.compatible) {
|
|
3253
|
+
throw new DropgateValidationError(compat.message);
|
|
1686
3254
|
}
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
} catch {
|
|
3255
|
+
const { serverInfo } = compat;
|
|
3256
|
+
const p2pCaps = serverInfo?.capabilities?.p2p;
|
|
3257
|
+
if (!p2pCaps?.enabled) {
|
|
3258
|
+
throw new DropgateValidationError("Direct transfer is disabled on this server.");
|
|
1692
3259
|
}
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
3260
|
+
const { host, port, secure } = this.serverTarget;
|
|
3261
|
+
const { path: peerjsPath, iceServers } = resolvePeerConfig({}, p2pCaps);
|
|
3262
|
+
return startP2PSend({
|
|
3263
|
+
...opts,
|
|
3264
|
+
host,
|
|
3265
|
+
port,
|
|
3266
|
+
secure,
|
|
3267
|
+
peerjsPath,
|
|
3268
|
+
iceServers,
|
|
3269
|
+
serverInfo,
|
|
3270
|
+
cryptoObj: this.cryptoObj
|
|
3271
|
+
});
|
|
1697
3272
|
}
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
3273
|
+
/**
|
|
3274
|
+
* Start a P2P receive session. Connects to a sender via their sharing code.
|
|
3275
|
+
*
|
|
3276
|
+
* Server info, peerjsPath, and iceServers are provided automatically
|
|
3277
|
+
* from the client's cached server info.
|
|
3278
|
+
*
|
|
3279
|
+
* @param opts - P2P receive options (code, Peer constructor, callbacks, tuning).
|
|
3280
|
+
* @returns P2P receive session with control methods.
|
|
3281
|
+
* @throws {DropgateValidationError} If P2P is not enabled on the server.
|
|
3282
|
+
* @throws {DropgateNetworkError} If the signalling server cannot be reached.
|
|
3283
|
+
*/
|
|
3284
|
+
async p2pReceive(opts) {
|
|
3285
|
+
const compat = await this.connect();
|
|
3286
|
+
if (!compat.compatible) {
|
|
3287
|
+
throw new DropgateValidationError(compat.message);
|
|
1707
3288
|
}
|
|
1708
|
-
|
|
1709
|
-
|
|
3289
|
+
const { serverInfo } = compat;
|
|
3290
|
+
const p2pCaps = serverInfo?.capabilities?.p2p;
|
|
3291
|
+
if (!p2pCaps?.enabled) {
|
|
3292
|
+
throw new DropgateValidationError("Direct transfer is disabled on this server.");
|
|
1710
3293
|
}
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
state = "negotiating";
|
|
1722
|
-
onStatus?.({ phase: "connected", message: "Waiting for file details..." });
|
|
3294
|
+
const { host, port, secure } = this.serverTarget;
|
|
3295
|
+
const { path: peerjsPath, iceServers } = resolvePeerConfig({}, p2pCaps);
|
|
3296
|
+
return startP2PReceive({
|
|
3297
|
+
...opts,
|
|
3298
|
+
host,
|
|
3299
|
+
port,
|
|
3300
|
+
secure,
|
|
3301
|
+
peerjsPath,
|
|
3302
|
+
iceServers,
|
|
3303
|
+
serverInfo
|
|
1723
3304
|
});
|
|
1724
|
-
|
|
3305
|
+
}
|
|
3306
|
+
async _attemptChunkUpload(url, fetchOptions, opts) {
|
|
3307
|
+
const {
|
|
3308
|
+
retries,
|
|
3309
|
+
backoffMs,
|
|
3310
|
+
maxBackoffMs,
|
|
3311
|
+
timeoutMs,
|
|
3312
|
+
signal,
|
|
3313
|
+
progress,
|
|
3314
|
+
chunkIndex,
|
|
3315
|
+
totalChunks,
|
|
3316
|
+
chunkSize,
|
|
3317
|
+
fileSizeBytes
|
|
3318
|
+
} = opts;
|
|
3319
|
+
let attemptsLeft = retries;
|
|
3320
|
+
let currentBackoff = backoffMs;
|
|
3321
|
+
const maxRetries = retries;
|
|
3322
|
+
while (true) {
|
|
3323
|
+
if (signal?.aborted) {
|
|
3324
|
+
throw signal.reason || new DropgateAbortError();
|
|
3325
|
+
}
|
|
3326
|
+
const { signal: s, cleanup } = makeAbortSignal(signal, timeoutMs);
|
|
1725
3327
|
try {
|
|
1726
|
-
|
|
1727
|
-
if (
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
} catch {
|
|
1734
|
-
}
|
|
1735
|
-
return;
|
|
1736
|
-
}
|
|
1737
|
-
if (msg.sessionId) {
|
|
1738
|
-
currentSessionId = msg.sessionId;
|
|
1739
|
-
}
|
|
1740
|
-
const name = String(msg.name || "file");
|
|
1741
|
-
total = Number(msg.size) || 0;
|
|
1742
|
-
received = 0;
|
|
1743
|
-
writeQueue = Promise.resolve();
|
|
1744
|
-
const sendReady = () => {
|
|
1745
|
-
state = "transferring";
|
|
1746
|
-
resetWatchdog();
|
|
1747
|
-
try {
|
|
1748
|
-
conn.send({ t: "ready" });
|
|
1749
|
-
} catch {
|
|
1750
|
-
}
|
|
1751
|
-
};
|
|
1752
|
-
if (autoReady) {
|
|
1753
|
-
onMeta?.({ name, total });
|
|
1754
|
-
onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
|
|
1755
|
-
sendReady();
|
|
1756
|
-
} else {
|
|
1757
|
-
onMeta?.({ name, total, sendReady });
|
|
1758
|
-
onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
|
|
1759
|
-
}
|
|
1760
|
-
return;
|
|
1761
|
-
}
|
|
1762
|
-
if (msg.t === "ping") {
|
|
1763
|
-
try {
|
|
1764
|
-
conn.send({ t: "pong" });
|
|
1765
|
-
} catch {
|
|
1766
|
-
}
|
|
1767
|
-
return;
|
|
1768
|
-
}
|
|
1769
|
-
if (msg.t === "end") {
|
|
1770
|
-
clearWatchdog();
|
|
1771
|
-
await writeQueue;
|
|
1772
|
-
if (total && received < total) {
|
|
1773
|
-
const err = new DropgateNetworkError(
|
|
1774
|
-
"Transfer ended before the full file was received."
|
|
1775
|
-
);
|
|
1776
|
-
try {
|
|
1777
|
-
conn.send({ t: "error", message: err.message });
|
|
1778
|
-
} catch {
|
|
1779
|
-
}
|
|
1780
|
-
throw err;
|
|
1781
|
-
}
|
|
1782
|
-
try {
|
|
1783
|
-
conn.send({ t: "ack", phase: "end", received, total });
|
|
1784
|
-
} catch {
|
|
1785
|
-
}
|
|
1786
|
-
safeComplete({ received, total });
|
|
1787
|
-
return;
|
|
1788
|
-
}
|
|
1789
|
-
if (msg.t === "error") {
|
|
1790
|
-
throw new DropgateNetworkError(msg.message || "Sender reported an error.");
|
|
1791
|
-
}
|
|
1792
|
-
if (msg.t === "cancelled") {
|
|
1793
|
-
if (state === "cancelled" || state === "closed" || state === "completed") return;
|
|
1794
|
-
state = "cancelled";
|
|
1795
|
-
onCancel?.({ cancelledBy: "sender", message: msg.message });
|
|
1796
|
-
cleanup();
|
|
1797
|
-
return;
|
|
3328
|
+
const res = await this.fetchFn(url, { ...fetchOptions, signal: s });
|
|
3329
|
+
if (res.ok) return;
|
|
3330
|
+
const text = await res.text().catch(() => "");
|
|
3331
|
+
const err2 = new DropgateProtocolError(
|
|
3332
|
+
`Chunk ${chunkIndex + 1} failed (HTTP ${res.status}).`,
|
|
3333
|
+
{
|
|
3334
|
+
details: { status: res.status, bodySnippet: text.slice(0, 120) }
|
|
1798
3335
|
}
|
|
1799
|
-
|
|
3336
|
+
);
|
|
3337
|
+
throw err2;
|
|
3338
|
+
} catch (err2) {
|
|
3339
|
+
cleanup();
|
|
3340
|
+
if (err2 instanceof Error && (err2.name === "AbortError" || err2.code === "ABORT_ERR")) {
|
|
3341
|
+
throw err2;
|
|
1800
3342
|
}
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
bufPromise = Promise.resolve(new Uint8Array(data));
|
|
1804
|
-
} else if (ArrayBuffer.isView(data)) {
|
|
1805
|
-
bufPromise = Promise.resolve(
|
|
1806
|
-
new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
|
|
1807
|
-
);
|
|
1808
|
-
} else if (typeof Blob !== "undefined" && data instanceof Blob) {
|
|
1809
|
-
bufPromise = data.arrayBuffer().then((buffer) => new Uint8Array(buffer));
|
|
1810
|
-
} else {
|
|
1811
|
-
return;
|
|
3343
|
+
if (signal?.aborted) {
|
|
3344
|
+
throw signal.reason || new DropgateAbortError();
|
|
1812
3345
|
}
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
3346
|
+
if (attemptsLeft <= 0) {
|
|
3347
|
+
throw err2 instanceof DropgateError ? err2 : new DropgateNetworkError("Chunk upload failed.", { cause: err2 });
|
|
3348
|
+
}
|
|
3349
|
+
const attemptNumber = maxRetries - attemptsLeft + 1;
|
|
3350
|
+
const processedBytes = chunkIndex * chunkSize;
|
|
3351
|
+
const percent = chunkIndex / totalChunks * 100;
|
|
3352
|
+
let remaining = currentBackoff;
|
|
3353
|
+
const tick = 100;
|
|
3354
|
+
while (remaining > 0) {
|
|
3355
|
+
const secondsLeft = (remaining / 1e3).toFixed(1);
|
|
3356
|
+
progress({
|
|
3357
|
+
phase: "retry-wait",
|
|
3358
|
+
text: `Chunk upload failed. Retrying in ${secondsLeft}s... (${attemptNumber}/${maxRetries})`,
|
|
3359
|
+
percent,
|
|
3360
|
+
processedBytes,
|
|
3361
|
+
totalBytes: fileSizeBytes,
|
|
3362
|
+
chunkIndex,
|
|
3363
|
+
totalChunks
|
|
3364
|
+
});
|
|
3365
|
+
await sleep(Math.min(tick, remaining), signal);
|
|
3366
|
+
remaining -= tick;
|
|
3367
|
+
}
|
|
3368
|
+
progress({
|
|
3369
|
+
phase: "retry",
|
|
3370
|
+
text: `Chunk upload failed. Retrying now... (${attemptNumber}/${maxRetries})`,
|
|
3371
|
+
percent,
|
|
3372
|
+
processedBytes,
|
|
3373
|
+
totalBytes: fileSizeBytes,
|
|
3374
|
+
chunkIndex,
|
|
3375
|
+
totalChunks
|
|
1838
3376
|
});
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
conn.on("close", () => {
|
|
1844
|
-
if (state === "closed" || state === "completed" || state === "cancelled") {
|
|
1845
|
-
cleanup();
|
|
1846
|
-
return;
|
|
1847
|
-
}
|
|
1848
|
-
if (state === "transferring") {
|
|
1849
|
-
state = "cancelled";
|
|
1850
|
-
onCancel?.({ cancelledBy: "sender" });
|
|
1851
|
-
cleanup();
|
|
1852
|
-
} else if (state === "negotiating") {
|
|
1853
|
-
state = "closed";
|
|
3377
|
+
attemptsLeft -= 1;
|
|
3378
|
+
currentBackoff = Math.min(currentBackoff * 2, maxBackoffMs);
|
|
3379
|
+
continue;
|
|
3380
|
+
} finally {
|
|
1854
3381
|
cleanup();
|
|
1855
|
-
onDisconnect?.();
|
|
1856
|
-
} else {
|
|
1857
|
-
safeError(new DropgateNetworkError("Sender disconnected before file details were received."));
|
|
1858
3382
|
}
|
|
1859
|
-
}
|
|
1860
|
-
}
|
|
1861
|
-
|
|
1862
|
-
peer,
|
|
1863
|
-
stop,
|
|
1864
|
-
getStatus: () => state,
|
|
1865
|
-
getBytesReceived: () => received,
|
|
1866
|
-
getTotalBytes: () => total,
|
|
1867
|
-
getSessionId: () => currentSessionId
|
|
1868
|
-
};
|
|
1869
|
-
}
|
|
1870
|
-
// Annotate the CommonJS export names for ESM import in node:
|
|
1871
|
-
0 && (module.exports = {
|
|
1872
|
-
AES_GCM_IV_BYTES,
|
|
1873
|
-
AES_GCM_TAG_BYTES,
|
|
1874
|
-
DEFAULT_CHUNK_SIZE,
|
|
1875
|
-
DropgateAbortError,
|
|
1876
|
-
DropgateClient,
|
|
1877
|
-
DropgateError,
|
|
1878
|
-
DropgateNetworkError,
|
|
1879
|
-
DropgateProtocolError,
|
|
1880
|
-
DropgateTimeoutError,
|
|
1881
|
-
DropgateValidationError,
|
|
1882
|
-
ENCRYPTION_OVERHEAD_PER_CHUNK,
|
|
1883
|
-
arrayBufferToBase64,
|
|
1884
|
-
base64ToBytes,
|
|
1885
|
-
buildBaseUrl,
|
|
1886
|
-
buildPeerOptions,
|
|
1887
|
-
bytesToBase64,
|
|
1888
|
-
createPeerWithRetries,
|
|
1889
|
-
decryptChunk,
|
|
1890
|
-
decryptFilenameFromBase64,
|
|
1891
|
-
encryptFilenameToBase64,
|
|
1892
|
-
encryptToBlob,
|
|
1893
|
-
estimateTotalUploadSizeBytes,
|
|
1894
|
-
exportKeyBase64,
|
|
1895
|
-
fetchJson,
|
|
1896
|
-
generateAesGcmKey,
|
|
1897
|
-
generateP2PCode,
|
|
1898
|
-
getDefaultBase64,
|
|
1899
|
-
getDefaultCrypto,
|
|
1900
|
-
getDefaultFetch,
|
|
1901
|
-
getServerInfo,
|
|
1902
|
-
importKeyFromBase64,
|
|
1903
|
-
isLocalhostHostname,
|
|
1904
|
-
isP2PCodeLike,
|
|
1905
|
-
isSecureContextForP2P,
|
|
1906
|
-
lifetimeToMs,
|
|
1907
|
-
makeAbortSignal,
|
|
1908
|
-
parseSemverMajorMinor,
|
|
1909
|
-
parseServerUrl,
|
|
1910
|
-
resolvePeerConfig,
|
|
1911
|
-
sha256Hex,
|
|
1912
|
-
sleep,
|
|
1913
|
-
startP2PReceive,
|
|
1914
|
-
startP2PSend,
|
|
1915
|
-
validatePlainFilename
|
|
1916
|
-
});
|
|
3383
|
+
}
|
|
3384
|
+
}
|
|
3385
|
+
};
|
|
1917
3386
|
//# sourceMappingURL=index.cjs.map
|