@dropgate/core 2.2.1 → 3.0.0
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 +2814 -1436
- 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 +2816 -1389
- package/dist/index.js.map +1 -1
- package/dist/p2p/index.cjs +495 -190
- 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 +497 -178
- 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,2788 @@ 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
|
-
|
|
679
|
-
|
|
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)
|
|
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 {
|
|
1087
|
+
}
|
|
1088
|
+
reject(err2);
|
|
692
1089
|
});
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
+
function generateSessionId() {
|
|
1133
|
+
return crypto.randomUUID();
|
|
1134
|
+
}
|
|
1135
|
+
var ALLOWED_TRANSITIONS = {
|
|
1136
|
+
initializing: ["listening", "closed"],
|
|
1137
|
+
listening: ["handshaking", "closed", "cancelled"],
|
|
1138
|
+
handshaking: ["negotiating", "closed", "cancelled"],
|
|
1139
|
+
negotiating: ["transferring", "closed", "cancelled"],
|
|
1140
|
+
transferring: ["finishing", "closed", "cancelled"],
|
|
1141
|
+
finishing: ["awaiting_ack", "closed", "cancelled"],
|
|
1142
|
+
awaiting_ack: ["completed", "closed", "cancelled"],
|
|
1143
|
+
completed: ["closed"],
|
|
1144
|
+
cancelled: ["closed"],
|
|
1145
|
+
closed: []
|
|
1146
|
+
};
|
|
1147
|
+
async function startP2PSend(opts) {
|
|
1148
|
+
const {
|
|
1149
|
+
file,
|
|
1150
|
+
Peer,
|
|
1151
|
+
serverInfo,
|
|
1152
|
+
host,
|
|
1153
|
+
port,
|
|
1154
|
+
peerjsPath,
|
|
1155
|
+
secure = false,
|
|
1156
|
+
iceServers,
|
|
1157
|
+
codeGenerator,
|
|
1158
|
+
cryptoObj,
|
|
1159
|
+
maxAttempts = 4,
|
|
1160
|
+
chunkSize = P2P_CHUNK_SIZE,
|
|
1161
|
+
endAckTimeoutMs = P2P_END_ACK_TIMEOUT_MS,
|
|
1162
|
+
bufferHighWaterMark = 8 * 1024 * 1024,
|
|
1163
|
+
bufferLowWaterMark = 2 * 1024 * 1024,
|
|
1164
|
+
heartbeatIntervalMs = 5e3,
|
|
1165
|
+
chunkAcknowledgments = true,
|
|
1166
|
+
maxUnackedChunks = P2P_MAX_UNACKED_CHUNKS,
|
|
1167
|
+
onCode,
|
|
1168
|
+
onStatus,
|
|
1169
|
+
onProgress,
|
|
1170
|
+
onComplete,
|
|
1171
|
+
onError,
|
|
1172
|
+
onDisconnect,
|
|
1173
|
+
onCancel,
|
|
1174
|
+
onConnectionHealth
|
|
1175
|
+
} = opts;
|
|
1176
|
+
const files = Array.isArray(file) ? file : [file];
|
|
1177
|
+
const isMultiFile = files.length > 1;
|
|
1178
|
+
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
|
|
1179
|
+
if (!files.length) {
|
|
1180
|
+
throw new DropgateValidationError("At least one file is required.");
|
|
1181
|
+
}
|
|
1182
|
+
if (!Peer) {
|
|
1183
|
+
throw new DropgateValidationError(
|
|
1184
|
+
"PeerJS Peer constructor is required. Install peerjs and pass it as the Peer option."
|
|
1185
|
+
);
|
|
1186
|
+
}
|
|
1187
|
+
const p2pCaps = serverInfo?.capabilities?.p2p;
|
|
1188
|
+
if (serverInfo && !p2pCaps?.enabled) {
|
|
1189
|
+
throw new DropgateValidationError("Direct transfer is disabled on this server.");
|
|
1190
|
+
}
|
|
1191
|
+
const { path: finalPath, iceServers: finalIceServers } = resolvePeerConfig(
|
|
1192
|
+
{ peerjsPath, iceServers },
|
|
1193
|
+
p2pCaps
|
|
1194
|
+
);
|
|
1195
|
+
const peerOpts = buildPeerOptions({
|
|
1196
|
+
host,
|
|
1197
|
+
port,
|
|
1198
|
+
peerjsPath: finalPath,
|
|
1199
|
+
secure,
|
|
1200
|
+
iceServers: finalIceServers
|
|
1201
|
+
});
|
|
1202
|
+
const finalCodeGenerator = codeGenerator || (() => generateP2PCode(cryptoObj));
|
|
1203
|
+
const buildPeer = (id) => new Peer(id, peerOpts);
|
|
1204
|
+
const { peer, code } = await createPeerWithRetries({
|
|
1205
|
+
code: null,
|
|
1206
|
+
codeGenerator: finalCodeGenerator,
|
|
1207
|
+
maxAttempts,
|
|
1208
|
+
buildPeer,
|
|
1209
|
+
onCode
|
|
1210
|
+
});
|
|
1211
|
+
const sessionId = generateSessionId();
|
|
1212
|
+
let state = "listening";
|
|
1213
|
+
let activeConn = null;
|
|
1214
|
+
let sentBytes = 0;
|
|
1215
|
+
let heartbeatTimer = null;
|
|
1216
|
+
let healthCheckTimer = null;
|
|
1217
|
+
let lastActivityTime = Date.now();
|
|
1218
|
+
const unackedChunks = /* @__PURE__ */ new Map();
|
|
1219
|
+
let nextSeq = 0;
|
|
1220
|
+
let ackResolvers = [];
|
|
1221
|
+
const transitionTo = (newState) => {
|
|
1222
|
+
if (!ALLOWED_TRANSITIONS[state].includes(newState)) {
|
|
1223
|
+
console.warn(`[P2P Send] Invalid state transition: ${state} -> ${newState}`);
|
|
1224
|
+
return false;
|
|
1225
|
+
}
|
|
1226
|
+
state = newState;
|
|
1227
|
+
return true;
|
|
1228
|
+
};
|
|
1229
|
+
const reportProgress = (data) => {
|
|
1230
|
+
if (isStopped()) return;
|
|
1231
|
+
const safeTotal = Number.isFinite(data.total) && data.total > 0 ? data.total : totalSize;
|
|
1232
|
+
const safeReceived = Math.min(Number(data.received) || 0, safeTotal || 0);
|
|
1233
|
+
const percent = safeTotal ? safeReceived / safeTotal * 100 : 0;
|
|
1234
|
+
onProgress?.({ processedBytes: safeReceived, totalBytes: safeTotal, percent });
|
|
1235
|
+
};
|
|
1236
|
+
const safeError = (err2) => {
|
|
1237
|
+
if (state === "closed" || state === "completed" || state === "cancelled") return;
|
|
1238
|
+
transitionTo("closed");
|
|
1239
|
+
onError?.(err2);
|
|
1240
|
+
cleanup();
|
|
1241
|
+
};
|
|
1242
|
+
const safeComplete = () => {
|
|
1243
|
+
if (state !== "awaiting_ack" && state !== "finishing") return;
|
|
1244
|
+
transitionTo("completed");
|
|
1245
|
+
onComplete?.();
|
|
1246
|
+
cleanup();
|
|
1247
|
+
};
|
|
1248
|
+
const cleanup = () => {
|
|
1249
|
+
if (heartbeatTimer) {
|
|
1250
|
+
clearInterval(heartbeatTimer);
|
|
1251
|
+
heartbeatTimer = null;
|
|
1252
|
+
}
|
|
1253
|
+
if (healthCheckTimer) {
|
|
1254
|
+
clearInterval(healthCheckTimer);
|
|
1255
|
+
healthCheckTimer = null;
|
|
1256
|
+
}
|
|
1257
|
+
ackResolvers.forEach((resolve) => resolve());
|
|
1258
|
+
ackResolvers = [];
|
|
1259
|
+
unackedChunks.clear();
|
|
1260
|
+
if (typeof window !== "undefined") {
|
|
1261
|
+
window.removeEventListener("beforeunload", handleUnload);
|
|
1262
|
+
}
|
|
1263
|
+
try {
|
|
1264
|
+
activeConn?.close();
|
|
1265
|
+
} catch {
|
|
1266
|
+
}
|
|
1267
|
+
try {
|
|
1268
|
+
peer.destroy();
|
|
1269
|
+
} catch {
|
|
1270
|
+
}
|
|
1271
|
+
};
|
|
1272
|
+
const handleUnload = () => {
|
|
1273
|
+
try {
|
|
1274
|
+
activeConn?.send({ t: "error", message: "Sender closed the connection." });
|
|
1275
|
+
} catch {
|
|
1276
|
+
}
|
|
1277
|
+
stop();
|
|
1278
|
+
};
|
|
1279
|
+
if (typeof window !== "undefined") {
|
|
1280
|
+
window.addEventListener("beforeunload", handleUnload);
|
|
1281
|
+
}
|
|
1282
|
+
const stop = () => {
|
|
1283
|
+
if (state === "closed" || state === "cancelled") return;
|
|
1284
|
+
if (state === "completed") {
|
|
1285
|
+
cleanup();
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
const wasActive = state === "transferring" || state === "finishing" || state === "awaiting_ack";
|
|
1289
|
+
transitionTo("cancelled");
|
|
1290
|
+
try {
|
|
1291
|
+
if (activeConn && activeConn.open) {
|
|
1292
|
+
activeConn.send({ t: "cancelled", message: "Sender cancelled the transfer." });
|
|
1293
|
+
}
|
|
1294
|
+
} catch {
|
|
1295
|
+
}
|
|
1296
|
+
if (wasActive && onCancel) {
|
|
1297
|
+
onCancel({ cancelledBy: "sender" });
|
|
1298
|
+
}
|
|
1299
|
+
cleanup();
|
|
1300
|
+
};
|
|
1301
|
+
const isStopped = () => state === "closed" || state === "cancelled";
|
|
1302
|
+
const startHealthMonitoring = (conn) => {
|
|
1303
|
+
if (!onConnectionHealth) return;
|
|
1304
|
+
healthCheckTimer = setInterval(() => {
|
|
1305
|
+
if (isStopped()) return;
|
|
1306
|
+
const dc = conn._dc;
|
|
1307
|
+
if (!dc) return;
|
|
1308
|
+
const health = {
|
|
1309
|
+
iceConnectionState: dc.readyState === "open" ? "connected" : "disconnected",
|
|
1310
|
+
bufferedAmount: dc.bufferedAmount,
|
|
1311
|
+
lastActivityMs: Date.now() - lastActivityTime
|
|
1312
|
+
};
|
|
1313
|
+
onConnectionHealth(health);
|
|
1314
|
+
}, 2e3);
|
|
1315
|
+
};
|
|
1316
|
+
const handleChunkAck = (msg) => {
|
|
1317
|
+
lastActivityTime = Date.now();
|
|
1318
|
+
unackedChunks.delete(msg.seq);
|
|
1319
|
+
reportProgress({ received: msg.received, total: totalSize });
|
|
1320
|
+
const resolver = ackResolvers.shift();
|
|
1321
|
+
if (resolver) resolver();
|
|
1322
|
+
};
|
|
1323
|
+
const waitForAck = () => {
|
|
1324
|
+
return new Promise((resolve) => {
|
|
1325
|
+
ackResolvers.push(resolve);
|
|
1326
|
+
});
|
|
1327
|
+
};
|
|
1328
|
+
const sendChunk = async (conn, data, offset, fileTotal) => {
|
|
1329
|
+
if (chunkAcknowledgments) {
|
|
1330
|
+
while (unackedChunks.size >= maxUnackedChunks) {
|
|
1331
|
+
await Promise.race([
|
|
1332
|
+
waitForAck(),
|
|
1333
|
+
sleep(1e3)
|
|
1334
|
+
// Timeout to prevent deadlock
|
|
1335
|
+
]);
|
|
1336
|
+
if (isStopped()) return;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
const seq = nextSeq++;
|
|
1340
|
+
if (chunkAcknowledgments) {
|
|
1341
|
+
unackedChunks.set(seq, { offset, size: data.byteLength, sentAt: Date.now() });
|
|
1342
|
+
}
|
|
1343
|
+
conn.send({ t: "chunk", seq, offset, size: data.byteLength, total: fileTotal ?? totalSize });
|
|
1344
|
+
conn.send(data);
|
|
1345
|
+
sentBytes += data.byteLength;
|
|
1346
|
+
const dc = conn._dc;
|
|
1347
|
+
if (dc && bufferHighWaterMark > 0) {
|
|
1348
|
+
while (dc.bufferedAmount > bufferHighWaterMark) {
|
|
1349
|
+
await new Promise((resolve) => {
|
|
1350
|
+
const fallback = setTimeout(resolve, 60);
|
|
1351
|
+
try {
|
|
1352
|
+
dc.addEventListener(
|
|
1353
|
+
"bufferedamountlow",
|
|
1354
|
+
() => {
|
|
1355
|
+
clearTimeout(fallback);
|
|
1356
|
+
resolve();
|
|
1357
|
+
},
|
|
1358
|
+
{ once: true }
|
|
1359
|
+
);
|
|
1360
|
+
} catch {
|
|
1361
|
+
}
|
|
1362
|
+
});
|
|
1363
|
+
if (isStopped()) return;
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
};
|
|
1367
|
+
const waitForEndAck = async (conn, ackPromise) => {
|
|
1368
|
+
const baseTimeout = endAckTimeoutMs;
|
|
1369
|
+
for (let attempt = 0; attempt < P2P_END_ACK_RETRIES; attempt++) {
|
|
1370
|
+
conn.send({ t: "end", attempt });
|
|
1371
|
+
const timeout = baseTimeout * Math.pow(1.5, attempt);
|
|
1372
|
+
const result = await Promise.race([
|
|
1373
|
+
ackPromise,
|
|
1374
|
+
sleep(timeout).then(() => null)
|
|
1375
|
+
]);
|
|
1376
|
+
if (result && result.t === "end_ack") {
|
|
1377
|
+
return result;
|
|
1378
|
+
}
|
|
1379
|
+
if (isStopped()) {
|
|
1380
|
+
throw new DropgateNetworkError("Connection closed during completion.");
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
throw new DropgateNetworkError("Receiver did not confirm completion after retries.");
|
|
1384
|
+
};
|
|
1385
|
+
peer.on("connection", (conn) => {
|
|
1386
|
+
if (isStopped()) return;
|
|
1387
|
+
if (activeConn) {
|
|
1388
|
+
const isOldConnOpen = activeConn.open !== false;
|
|
1389
|
+
if (isOldConnOpen && state === "transferring") {
|
|
1390
|
+
try {
|
|
1391
|
+
conn.send({ t: "error", message: "Transfer already in progress." });
|
|
1392
|
+
} catch {
|
|
1393
|
+
}
|
|
1394
|
+
try {
|
|
1395
|
+
conn.close();
|
|
1396
|
+
} catch {
|
|
1397
|
+
}
|
|
1398
|
+
return;
|
|
1399
|
+
} else if (!isOldConnOpen) {
|
|
1400
|
+
try {
|
|
1401
|
+
activeConn.close();
|
|
1402
|
+
} catch {
|
|
1403
|
+
}
|
|
1404
|
+
activeConn = null;
|
|
1405
|
+
state = "listening";
|
|
1406
|
+
sentBytes = 0;
|
|
1407
|
+
nextSeq = 0;
|
|
1408
|
+
unackedChunks.clear();
|
|
1409
|
+
} else {
|
|
1410
|
+
try {
|
|
1411
|
+
conn.send({ t: "error", message: "Another receiver is already connected." });
|
|
1412
|
+
} catch {
|
|
1413
|
+
}
|
|
1414
|
+
try {
|
|
1415
|
+
conn.close();
|
|
1416
|
+
} catch {
|
|
699
1417
|
}
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
activeConn = conn;
|
|
1422
|
+
transitionTo("handshaking");
|
|
1423
|
+
if (!isStopped()) onStatus?.({ phase: "connected", message: "Receiver connected." });
|
|
1424
|
+
lastActivityTime = Date.now();
|
|
1425
|
+
let helloResolve = null;
|
|
1426
|
+
let readyResolve = null;
|
|
1427
|
+
let endAckResolve = null;
|
|
1428
|
+
let fileEndAckResolve = null;
|
|
1429
|
+
const helloPromise = new Promise((resolve) => {
|
|
1430
|
+
helloResolve = resolve;
|
|
1431
|
+
});
|
|
1432
|
+
const readyPromise = new Promise((resolve) => {
|
|
1433
|
+
readyResolve = resolve;
|
|
1434
|
+
});
|
|
1435
|
+
const endAckPromise = new Promise((resolve) => {
|
|
1436
|
+
endAckResolve = resolve;
|
|
1437
|
+
});
|
|
1438
|
+
conn.on("data", (data) => {
|
|
1439
|
+
lastActivityTime = Date.now();
|
|
1440
|
+
if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
|
|
1441
|
+
return;
|
|
1442
|
+
}
|
|
1443
|
+
if (!isP2PMessage(data)) return;
|
|
1444
|
+
const msg = data;
|
|
1445
|
+
switch (msg.t) {
|
|
1446
|
+
case "hello":
|
|
1447
|
+
helloResolve?.(msg.protocolVersion);
|
|
1448
|
+
break;
|
|
1449
|
+
case "ready":
|
|
1450
|
+
if (!isStopped()) onStatus?.({ phase: "transferring", message: "Receiver accepted. Starting transfer..." });
|
|
1451
|
+
readyResolve?.();
|
|
1452
|
+
break;
|
|
1453
|
+
case "chunk_ack":
|
|
1454
|
+
handleChunkAck(msg);
|
|
1455
|
+
break;
|
|
1456
|
+
case "file_end_ack":
|
|
1457
|
+
fileEndAckResolve?.(msg);
|
|
1458
|
+
break;
|
|
1459
|
+
case "end_ack":
|
|
1460
|
+
endAckResolve?.(msg);
|
|
1461
|
+
break;
|
|
1462
|
+
case "pong":
|
|
1463
|
+
break;
|
|
1464
|
+
case "error":
|
|
1465
|
+
safeError(new DropgateNetworkError(msg.message || "Receiver reported an error."));
|
|
1466
|
+
break;
|
|
1467
|
+
case "cancelled":
|
|
1468
|
+
if (state === "cancelled" || state === "closed" || state === "completed") return;
|
|
1469
|
+
transitionTo("cancelled");
|
|
1470
|
+
onCancel?.({ cancelledBy: "receiver", message: msg.reason });
|
|
1471
|
+
cleanup();
|
|
1472
|
+
break;
|
|
1473
|
+
}
|
|
1474
|
+
});
|
|
1475
|
+
conn.on("open", async () => {
|
|
1476
|
+
try {
|
|
1477
|
+
if (isStopped()) return;
|
|
1478
|
+
startHealthMonitoring(conn);
|
|
1479
|
+
conn.send({
|
|
1480
|
+
t: "hello",
|
|
1481
|
+
protocolVersion: P2P_PROTOCOL_VERSION,
|
|
1482
|
+
sessionId
|
|
1483
|
+
});
|
|
1484
|
+
const receiverVersion = await Promise.race([
|
|
1485
|
+
helloPromise,
|
|
1486
|
+
sleep(1e4).then(() => null)
|
|
1487
|
+
]);
|
|
1488
|
+
if (isStopped()) return;
|
|
1489
|
+
if (receiverVersion === null) {
|
|
1490
|
+
throw new DropgateNetworkError("Receiver did not respond to handshake.");
|
|
1491
|
+
} else if (receiverVersion !== P2P_PROTOCOL_VERSION) {
|
|
1492
|
+
throw new DropgateNetworkError(
|
|
1493
|
+
`Protocol version mismatch: sender v${P2P_PROTOCOL_VERSION}, receiver v${receiverVersion}`
|
|
705
1494
|
);
|
|
706
1495
|
}
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
throw effectiveSignal.reason || new DropgateAbortError();
|
|
716
|
-
}
|
|
717
|
-
const start = i * this.chunkSize;
|
|
718
|
-
const end = Math.min(start + this.chunkSize, file.size);
|
|
719
|
-
let chunkBlob = file.slice(start, end);
|
|
720
|
-
const percentComplete = i / totalChunks * 100;
|
|
721
|
-
const processedBytes = i * this.chunkSize;
|
|
722
|
-
progress({
|
|
723
|
-
phase: "chunk",
|
|
724
|
-
text: `Uploading chunk ${i + 1} of ${totalChunks}...`,
|
|
725
|
-
percent: percentComplete,
|
|
726
|
-
processedBytes,
|
|
727
|
-
totalBytes: fileSizeBytes,
|
|
728
|
-
chunkIndex: i,
|
|
729
|
-
totalChunks
|
|
1496
|
+
transitionTo("negotiating");
|
|
1497
|
+
if (!isStopped()) onStatus?.({ phase: "waiting", message: "Connected. Waiting for receiver to accept..." });
|
|
1498
|
+
if (isMultiFile) {
|
|
1499
|
+
conn.send({
|
|
1500
|
+
t: "file_list",
|
|
1501
|
+
fileCount: files.length,
|
|
1502
|
+
files: files.map((f) => ({ name: f.name, size: f.size, mime: f.type || "application/octet-stream" })),
|
|
1503
|
+
totalSize
|
|
730
1504
|
});
|
|
731
|
-
const chunkBuffer = await chunkBlob.arrayBuffer();
|
|
732
|
-
let uploadBlob;
|
|
733
|
-
if (effectiveEncrypt && cryptoKey) {
|
|
734
|
-
uploadBlob = await encryptToBlob(this.cryptoObj, chunkBuffer, cryptoKey);
|
|
735
|
-
} else {
|
|
736
|
-
uploadBlob = new Blob([chunkBuffer]);
|
|
737
|
-
}
|
|
738
|
-
if (uploadBlob.size > DEFAULT_CHUNK_SIZE + 1024) {
|
|
739
|
-
throw new DropgateValidationError(
|
|
740
|
-
"Chunk too large (client-side). Check chunk size settings."
|
|
741
|
-
);
|
|
742
|
-
}
|
|
743
|
-
const toHash = await uploadBlob.arrayBuffer();
|
|
744
|
-
const hashHex = await sha256Hex(this.cryptoObj, toHash);
|
|
745
|
-
const headers = {
|
|
746
|
-
"Content-Type": "application/octet-stream",
|
|
747
|
-
"X-Upload-ID": uploadId,
|
|
748
|
-
"X-Chunk-Index": String(i),
|
|
749
|
-
"X-Chunk-Hash": hashHex
|
|
750
|
-
};
|
|
751
|
-
const chunkUrl = `${baseUrl}/upload/chunk`;
|
|
752
|
-
await this.attemptChunkUpload(
|
|
753
|
-
chunkUrl,
|
|
754
|
-
{
|
|
755
|
-
method: "POST",
|
|
756
|
-
headers,
|
|
757
|
-
body: uploadBlob
|
|
758
|
-
},
|
|
759
|
-
{
|
|
760
|
-
retries,
|
|
761
|
-
backoffMs: baseBackoffMs,
|
|
762
|
-
maxBackoffMs,
|
|
763
|
-
timeoutMs: timeouts.chunkMs ?? 6e4,
|
|
764
|
-
signal: effectiveSignal,
|
|
765
|
-
progress,
|
|
766
|
-
chunkIndex: i,
|
|
767
|
-
totalChunks,
|
|
768
|
-
chunkSize: this.chunkSize,
|
|
769
|
-
fileSizeBytes
|
|
770
|
-
}
|
|
771
|
-
);
|
|
772
1505
|
}
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
body: JSON.stringify({ uploadId })
|
|
1506
|
+
conn.send({
|
|
1507
|
+
t: "meta",
|
|
1508
|
+
sessionId,
|
|
1509
|
+
name: files[0].name,
|
|
1510
|
+
size: files[0].size,
|
|
1511
|
+
mime: files[0].type || "application/octet-stream",
|
|
1512
|
+
...isMultiFile ? { fileIndex: 0 } : {}
|
|
1513
|
+
});
|
|
1514
|
+
const dc = conn._dc;
|
|
1515
|
+
if (dc && Number.isFinite(bufferLowWaterMark)) {
|
|
1516
|
+
try {
|
|
1517
|
+
dc.bufferedAmountLowThreshold = bufferLowWaterMark;
|
|
1518
|
+
} catch {
|
|
787
1519
|
}
|
|
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
|
-
});
|
|
795
1520
|
}
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
if (
|
|
799
|
-
|
|
800
|
-
"
|
|
801
|
-
|
|
1521
|
+
await readyPromise;
|
|
1522
|
+
if (isStopped()) return;
|
|
1523
|
+
if (heartbeatIntervalMs > 0) {
|
|
1524
|
+
heartbeatTimer = setInterval(() => {
|
|
1525
|
+
if (state === "transferring" || state === "finishing" || state === "awaiting_ack") {
|
|
1526
|
+
try {
|
|
1527
|
+
conn.send({ t: "ping", timestamp: Date.now() });
|
|
1528
|
+
} catch {
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
}, heartbeatIntervalMs);
|
|
802
1532
|
}
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
1533
|
+
transitionTo("transferring");
|
|
1534
|
+
let overallSentBytes = 0;
|
|
1535
|
+
for (let fi = 0; fi < files.length; fi++) {
|
|
1536
|
+
const currentFile = files[fi];
|
|
1537
|
+
if (isMultiFile && fi > 0) {
|
|
1538
|
+
conn.send({
|
|
1539
|
+
t: "meta",
|
|
1540
|
+
sessionId,
|
|
1541
|
+
name: currentFile.name,
|
|
1542
|
+
size: currentFile.size,
|
|
1543
|
+
mime: currentFile.type || "application/octet-stream",
|
|
1544
|
+
fileIndex: fi
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
for (let offset = 0; offset < currentFile.size; offset += chunkSize) {
|
|
1548
|
+
if (isStopped()) return;
|
|
1549
|
+
const slice = currentFile.slice(offset, offset + chunkSize);
|
|
1550
|
+
const buf = await slice.arrayBuffer();
|
|
1551
|
+
if (isStopped()) return;
|
|
1552
|
+
await sendChunk(conn, buf, offset, currentFile.size);
|
|
1553
|
+
overallSentBytes += buf.byteLength;
|
|
1554
|
+
reportProgress({ received: overallSentBytes, total: totalSize });
|
|
1555
|
+
}
|
|
1556
|
+
if (isStopped()) return;
|
|
1557
|
+
if (isMultiFile) {
|
|
1558
|
+
const fileEndAckPromise = new Promise((resolve) => {
|
|
1559
|
+
fileEndAckResolve = resolve;
|
|
1560
|
+
});
|
|
1561
|
+
conn.send({ t: "file_end", fileIndex: fi });
|
|
1562
|
+
const feAck = await Promise.race([
|
|
1563
|
+
fileEndAckPromise,
|
|
1564
|
+
sleep(endAckTimeoutMs).then(() => null)
|
|
1565
|
+
]);
|
|
1566
|
+
if (isStopped()) return;
|
|
1567
|
+
if (!feAck) {
|
|
1568
|
+
throw new DropgateNetworkError(`Receiver did not confirm receipt of file ${fi + 1}/${files.length}.`);
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
806
1571
|
}
|
|
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";
|
|
1572
|
+
if (isStopped()) return;
|
|
1573
|
+
transitionTo("finishing");
|
|
1574
|
+
transitionTo("awaiting_ack");
|
|
1575
|
+
const ackResult = await waitForEndAck(conn, endAckPromise);
|
|
1576
|
+
if (isStopped()) return;
|
|
1577
|
+
const ackTotal = Number(ackResult.total) || totalSize;
|
|
1578
|
+
const ackReceived = Number(ackResult.received) || 0;
|
|
1579
|
+
if (ackTotal && ackReceived < ackTotal) {
|
|
1580
|
+
throw new DropgateNetworkError("Receiver reported an incomplete transfer.");
|
|
822
1581
|
}
|
|
823
|
-
|
|
1582
|
+
reportProgress({ received: ackReceived || ackTotal, total: ackTotal });
|
|
1583
|
+
safeComplete();
|
|
1584
|
+
} catch (err2) {
|
|
1585
|
+
safeError(err2);
|
|
824
1586
|
}
|
|
825
|
-
})
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
1587
|
+
});
|
|
1588
|
+
conn.on("error", (err2) => {
|
|
1589
|
+
safeError(err2);
|
|
1590
|
+
});
|
|
1591
|
+
conn.on("close", () => {
|
|
1592
|
+
if (state === "closed" || state === "completed" || state === "cancelled") {
|
|
1593
|
+
cleanup();
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
if (state === "awaiting_ack") {
|
|
1597
|
+
setTimeout(() => {
|
|
1598
|
+
if (state === "awaiting_ack") {
|
|
1599
|
+
safeError(new DropgateNetworkError("Connection closed while awaiting confirmation."));
|
|
1600
|
+
}
|
|
1601
|
+
}, P2P_CLOSE_GRACE_PERIOD_MS);
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1604
|
+
if (state === "transferring" || state === "finishing") {
|
|
1605
|
+
transitionTo("cancelled");
|
|
1606
|
+
onCancel?.({ cancelledBy: "receiver" });
|
|
1607
|
+
cleanup();
|
|
1608
|
+
} else {
|
|
1609
|
+
activeConn = null;
|
|
1610
|
+
state = "listening";
|
|
1611
|
+
sentBytes = 0;
|
|
1612
|
+
nextSeq = 0;
|
|
1613
|
+
unackedChunks.clear();
|
|
1614
|
+
onDisconnect?.();
|
|
838
1615
|
}
|
|
839
|
-
};
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
1616
|
+
});
|
|
1617
|
+
});
|
|
1618
|
+
return {
|
|
1619
|
+
peer,
|
|
1620
|
+
code,
|
|
1621
|
+
sessionId,
|
|
1622
|
+
stop,
|
|
1623
|
+
getStatus: () => state,
|
|
1624
|
+
getBytesSent: () => sentBytes,
|
|
1625
|
+
getConnectedPeerId: () => {
|
|
1626
|
+
if (!activeConn) return null;
|
|
1627
|
+
return activeConn.peer || null;
|
|
1628
|
+
}
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// src/p2p/receive.ts
|
|
1633
|
+
var ALLOWED_TRANSITIONS2 = {
|
|
1634
|
+
initializing: ["connecting", "closed"],
|
|
1635
|
+
connecting: ["handshaking", "closed", "cancelled"],
|
|
1636
|
+
handshaking: ["negotiating", "closed", "cancelled"],
|
|
1637
|
+
negotiating: ["transferring", "closed", "cancelled"],
|
|
1638
|
+
transferring: ["completed", "closed", "cancelled"],
|
|
1639
|
+
completed: ["closed"],
|
|
1640
|
+
cancelled: ["closed"],
|
|
1641
|
+
closed: []
|
|
1642
|
+
};
|
|
1643
|
+
async function startP2PReceive(opts) {
|
|
1644
|
+
const {
|
|
1645
|
+
code,
|
|
1646
|
+
Peer,
|
|
1647
|
+
serverInfo,
|
|
1648
|
+
host,
|
|
1649
|
+
port,
|
|
1650
|
+
peerjsPath,
|
|
1651
|
+
secure = false,
|
|
1652
|
+
iceServers,
|
|
1653
|
+
autoReady = true,
|
|
1654
|
+
watchdogTimeoutMs = 15e3,
|
|
1655
|
+
onStatus,
|
|
1656
|
+
onMeta,
|
|
1657
|
+
onData,
|
|
1658
|
+
onProgress,
|
|
1659
|
+
onFileStart,
|
|
1660
|
+
onFileEnd,
|
|
1661
|
+
onComplete,
|
|
1662
|
+
onError,
|
|
1663
|
+
onDisconnect,
|
|
1664
|
+
onCancel
|
|
1665
|
+
} = opts;
|
|
1666
|
+
if (!code) {
|
|
1667
|
+
throw new DropgateValidationError("No sharing code was provided.");
|
|
853
1668
|
}
|
|
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
|
-
|
|
1669
|
+
if (!Peer) {
|
|
1670
|
+
throw new DropgateValidationError(
|
|
1671
|
+
"PeerJS Peer constructor is required. Install peerjs and pass it as the Peer option."
|
|
1672
|
+
);
|
|
1673
|
+
}
|
|
1674
|
+
const p2pCaps = serverInfo?.capabilities?.p2p;
|
|
1675
|
+
if (serverInfo && !p2pCaps?.enabled) {
|
|
1676
|
+
throw new DropgateValidationError("Direct transfer is disabled on this server.");
|
|
1677
|
+
}
|
|
1678
|
+
const normalizedCode = String(code).trim().replace(/\s+/g, "").toUpperCase();
|
|
1679
|
+
if (!isP2PCodeLike(normalizedCode)) {
|
|
1680
|
+
throw new DropgateValidationError("Invalid direct transfer code.");
|
|
1681
|
+
}
|
|
1682
|
+
const { path: finalPath, iceServers: finalIceServers } = resolvePeerConfig(
|
|
1683
|
+
{ peerjsPath, iceServers },
|
|
1684
|
+
p2pCaps
|
|
1685
|
+
);
|
|
1686
|
+
const peerOpts = buildPeerOptions({
|
|
1687
|
+
host,
|
|
1688
|
+
port,
|
|
1689
|
+
peerjsPath: finalPath,
|
|
1690
|
+
secure,
|
|
1691
|
+
iceServers: finalIceServers
|
|
1692
|
+
});
|
|
1693
|
+
const peer = new Peer(void 0, peerOpts);
|
|
1694
|
+
let state = "initializing";
|
|
1695
|
+
let total = 0;
|
|
1696
|
+
let received = 0;
|
|
1697
|
+
let currentSessionId = null;
|
|
1698
|
+
let writeQueue = Promise.resolve();
|
|
1699
|
+
let watchdogTimer = null;
|
|
1700
|
+
let activeConn = null;
|
|
1701
|
+
let pendingChunk = null;
|
|
1702
|
+
let fileList = null;
|
|
1703
|
+
let currentFileReceived = 0;
|
|
1704
|
+
let totalReceivedAllFiles = 0;
|
|
1705
|
+
const transitionTo = (newState) => {
|
|
1706
|
+
if (!ALLOWED_TRANSITIONS2[state].includes(newState)) {
|
|
1707
|
+
console.warn(`[P2P Receive] Invalid state transition: ${state} -> ${newState}`);
|
|
1708
|
+
return false;
|
|
1709
|
+
}
|
|
1710
|
+
state = newState;
|
|
1711
|
+
return true;
|
|
1712
|
+
};
|
|
1713
|
+
const isStopped = () => state === "closed" || state === "cancelled";
|
|
1714
|
+
const resetWatchdog = () => {
|
|
1715
|
+
if (watchdogTimeoutMs <= 0) return;
|
|
1716
|
+
if (watchdogTimer) {
|
|
1717
|
+
clearTimeout(watchdogTimer);
|
|
1718
|
+
}
|
|
1719
|
+
watchdogTimer = setTimeout(() => {
|
|
1720
|
+
if (state === "transferring") {
|
|
1721
|
+
safeError(new DropgateNetworkError("Connection timed out (no data received)."));
|
|
886
1722
|
}
|
|
887
|
-
};
|
|
888
|
-
|
|
889
|
-
|
|
1723
|
+
}, watchdogTimeoutMs);
|
|
1724
|
+
};
|
|
1725
|
+
const clearWatchdog = () => {
|
|
1726
|
+
if (watchdogTimer) {
|
|
1727
|
+
clearTimeout(watchdogTimer);
|
|
1728
|
+
watchdogTimer = null;
|
|
890
1729
|
}
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
1730
|
+
};
|
|
1731
|
+
const safeError = (err2) => {
|
|
1732
|
+
if (state === "closed" || state === "completed" || state === "cancelled") return;
|
|
1733
|
+
transitionTo("closed");
|
|
1734
|
+
onError?.(err2);
|
|
1735
|
+
cleanup();
|
|
1736
|
+
};
|
|
1737
|
+
const safeComplete = (completeData) => {
|
|
1738
|
+
if (state !== "transferring") return;
|
|
1739
|
+
transitionTo("completed");
|
|
1740
|
+
onComplete?.(completeData);
|
|
1741
|
+
};
|
|
1742
|
+
const cleanup = () => {
|
|
1743
|
+
clearWatchdog();
|
|
1744
|
+
if (typeof window !== "undefined") {
|
|
1745
|
+
window.removeEventListener("beforeunload", handleUnload);
|
|
903
1746
|
}
|
|
904
|
-
progress({ phase: "metadata", text: "Fetching file info...", processedBytes: 0, totalBytes: 0, percent: 0 });
|
|
905
|
-
const { signal: metaSignal, cleanup: metaCleanup } = makeAbortSignal(signal, timeoutMs);
|
|
906
|
-
let metadata;
|
|
907
1747
|
try {
|
|
908
|
-
|
|
909
|
-
|
|
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}).`);
|
|
918
|
-
}
|
|
919
|
-
metadata = await metaRes.json();
|
|
920
|
-
} catch (err) {
|
|
921
|
-
if (err instanceof DropgateError) throw err;
|
|
922
|
-
if (err instanceof Error && err.name === "AbortError") {
|
|
923
|
-
throw new DropgateAbortError("Download cancelled.");
|
|
924
|
-
}
|
|
925
|
-
throw new DropgateNetworkError("Could not fetch file metadata.", { cause: err });
|
|
926
|
-
} finally {
|
|
927
|
-
metaCleanup();
|
|
1748
|
+
peer.destroy();
|
|
1749
|
+
} catch {
|
|
928
1750
|
}
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
throw new DropgateValidationError(
|
|
935
|
-
`File is too large (${sizeMB}MB) to download without streaming. Provide an onData callback to stream files larger than ${limitMB}MB.`
|
|
936
|
-
);
|
|
1751
|
+
};
|
|
1752
|
+
const handleUnload = () => {
|
|
1753
|
+
try {
|
|
1754
|
+
activeConn?.send({ t: "error", message: "Receiver closed the connection." });
|
|
1755
|
+
} catch {
|
|
937
1756
|
}
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
this.base64
|
|
955
|
-
);
|
|
956
|
-
} catch (err) {
|
|
957
|
-
throw new DropgateError("Failed to decrypt filename. Invalid key or corrupted data.", {
|
|
958
|
-
code: "DECRYPT_FILENAME_FAILED",
|
|
959
|
-
cause: err
|
|
960
|
-
});
|
|
1757
|
+
stop();
|
|
1758
|
+
};
|
|
1759
|
+
if (typeof window !== "undefined") {
|
|
1760
|
+
window.addEventListener("beforeunload", handleUnload);
|
|
1761
|
+
}
|
|
1762
|
+
const stop = () => {
|
|
1763
|
+
if (state === "closed" || state === "cancelled") return;
|
|
1764
|
+
if (state === "completed") {
|
|
1765
|
+
cleanup();
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
const wasActive = state === "transferring";
|
|
1769
|
+
transitionTo("cancelled");
|
|
1770
|
+
try {
|
|
1771
|
+
if (activeConn && activeConn.open) {
|
|
1772
|
+
activeConn.send({ t: "cancelled", reason: "Receiver cancelled the transfer." });
|
|
961
1773
|
}
|
|
962
|
-
}
|
|
963
|
-
filename = metadata.filename || "file";
|
|
1774
|
+
} catch {
|
|
964
1775
|
}
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1776
|
+
if (wasActive && onCancel) {
|
|
1777
|
+
onCancel({ cancelledBy: "receiver" });
|
|
1778
|
+
}
|
|
1779
|
+
cleanup();
|
|
1780
|
+
};
|
|
1781
|
+
const sendChunkAck = (conn, seq) => {
|
|
970
1782
|
try {
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1783
|
+
conn.send({ t: "chunk_ack", seq, received });
|
|
1784
|
+
} catch {
|
|
1785
|
+
}
|
|
1786
|
+
};
|
|
1787
|
+
peer.on("error", (err2) => {
|
|
1788
|
+
safeError(err2);
|
|
1789
|
+
});
|
|
1790
|
+
peer.on("open", () => {
|
|
1791
|
+
transitionTo("connecting");
|
|
1792
|
+
const conn = peer.connect(normalizedCode, { reliable: true });
|
|
1793
|
+
activeConn = conn;
|
|
1794
|
+
conn.on("open", () => {
|
|
1795
|
+
transitionTo("handshaking");
|
|
1796
|
+
onStatus?.({ phase: "connected", message: "Connected." });
|
|
1797
|
+
conn.send({
|
|
1798
|
+
t: "hello",
|
|
1799
|
+
protocolVersion: P2P_PROTOCOL_VERSION,
|
|
1800
|
+
sessionId: ""
|
|
974
1801
|
});
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
if (
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
offset += chunk.length;
|
|
999
|
-
}
|
|
1000
|
-
pendingChunks.length = 0;
|
|
1001
|
-
pendingLength = 0;
|
|
1002
|
-
return result;
|
|
1003
|
-
};
|
|
1004
|
-
while (true) {
|
|
1005
|
-
if (signal?.aborted) {
|
|
1006
|
-
throw new DropgateAbortError("Download cancelled.");
|
|
1007
|
-
}
|
|
1008
|
-
const { done, value } = await reader.read();
|
|
1009
|
-
if (done) break;
|
|
1010
|
-
pendingChunks.push(value);
|
|
1011
|
-
pendingLength += value.length;
|
|
1012
|
-
while (pendingLength >= ENCRYPTED_CHUNK_SIZE) {
|
|
1013
|
-
const buffer = flushPending();
|
|
1014
|
-
const encryptedChunk = buffer.subarray(0, ENCRYPTED_CHUNK_SIZE);
|
|
1015
|
-
if (buffer.length > ENCRYPTED_CHUNK_SIZE) {
|
|
1016
|
-
const remainder = buffer.subarray(ENCRYPTED_CHUNK_SIZE);
|
|
1017
|
-
pendingChunks.push(remainder);
|
|
1018
|
-
pendingLength = remainder.length;
|
|
1802
|
+
});
|
|
1803
|
+
conn.on("data", async (data) => {
|
|
1804
|
+
try {
|
|
1805
|
+
resetWatchdog();
|
|
1806
|
+
if (data instanceof ArrayBuffer || ArrayBuffer.isView(data) || typeof Blob !== "undefined" && data instanceof Blob) {
|
|
1807
|
+
let bufPromise;
|
|
1808
|
+
if (data instanceof ArrayBuffer) {
|
|
1809
|
+
bufPromise = Promise.resolve(new Uint8Array(data));
|
|
1810
|
+
} else if (ArrayBuffer.isView(data)) {
|
|
1811
|
+
bufPromise = Promise.resolve(
|
|
1812
|
+
new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
|
|
1813
|
+
);
|
|
1814
|
+
} else if (typeof Blob !== "undefined" && data instanceof Blob) {
|
|
1815
|
+
bufPromise = data.arrayBuffer().then((buffer) => new Uint8Array(buffer));
|
|
1816
|
+
} else {
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
const chunkSeq = pendingChunk?.seq ?? -1;
|
|
1820
|
+
pendingChunk = null;
|
|
1821
|
+
writeQueue = writeQueue.then(async () => {
|
|
1822
|
+
const buf = await bufPromise;
|
|
1823
|
+
if (onData) {
|
|
1824
|
+
await onData(buf);
|
|
1019
1825
|
}
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1826
|
+
received += buf.byteLength;
|
|
1827
|
+
currentFileReceived += buf.byteLength;
|
|
1828
|
+
const progressReceived = fileList ? totalReceivedAllFiles + currentFileReceived : received;
|
|
1829
|
+
const progressTotal = fileList ? fileList.totalSize : total;
|
|
1830
|
+
const percent = progressTotal ? Math.min(100, progressReceived / progressTotal * 100) : 0;
|
|
1831
|
+
if (!isStopped()) onProgress?.({ processedBytes: progressReceived, totalBytes: progressTotal, percent });
|
|
1832
|
+
if (chunkSeq >= 0) {
|
|
1833
|
+
sendChunkAck(conn, chunkSeq);
|
|
1026
1834
|
}
|
|
1027
|
-
}
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1835
|
+
}).catch((err2) => {
|
|
1836
|
+
try {
|
|
1837
|
+
conn.send({
|
|
1838
|
+
t: "error",
|
|
1839
|
+
message: err2?.message || "Receiver write failed."
|
|
1840
|
+
});
|
|
1841
|
+
} catch {
|
|
1842
|
+
}
|
|
1843
|
+
safeError(err2);
|
|
1036
1844
|
});
|
|
1845
|
+
return;
|
|
1037
1846
|
}
|
|
1038
|
-
if (
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1847
|
+
if (!isP2PMessage(data)) return;
|
|
1848
|
+
const msg = data;
|
|
1849
|
+
switch (msg.t) {
|
|
1850
|
+
case "hello":
|
|
1851
|
+
currentSessionId = msg.sessionId || null;
|
|
1852
|
+
transitionTo("negotiating");
|
|
1853
|
+
onStatus?.({ phase: "waiting", message: "Waiting for file details..." });
|
|
1854
|
+
break;
|
|
1855
|
+
case "file_list":
|
|
1856
|
+
fileList = msg;
|
|
1857
|
+
total = fileList.totalSize;
|
|
1858
|
+
break;
|
|
1859
|
+
case "meta": {
|
|
1860
|
+
if (state !== "negotiating" && !(state === "transferring" && fileList)) {
|
|
1861
|
+
return;
|
|
1862
|
+
}
|
|
1863
|
+
if (currentSessionId && msg.sessionId && msg.sessionId !== currentSessionId) {
|
|
1864
|
+
try {
|
|
1865
|
+
conn.send({ t: "error", message: "Busy with another session." });
|
|
1866
|
+
} catch {
|
|
1867
|
+
}
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
if (msg.sessionId) {
|
|
1871
|
+
currentSessionId = msg.sessionId;
|
|
1872
|
+
}
|
|
1873
|
+
const name = String(msg.name || "file");
|
|
1874
|
+
const fileSize = Number(msg.size) || 0;
|
|
1875
|
+
const fi = msg.fileIndex;
|
|
1876
|
+
if (fileList && typeof fi === "number" && fi > 0) {
|
|
1877
|
+
currentFileReceived = 0;
|
|
1878
|
+
onFileStart?.({ fileIndex: fi, name, size: fileSize });
|
|
1879
|
+
break;
|
|
1880
|
+
}
|
|
1881
|
+
received = 0;
|
|
1882
|
+
currentFileReceived = 0;
|
|
1883
|
+
totalReceivedAllFiles = 0;
|
|
1884
|
+
if (!fileList) {
|
|
1885
|
+
total = fileSize;
|
|
1886
|
+
}
|
|
1887
|
+
writeQueue = Promise.resolve();
|
|
1888
|
+
const sendReady = () => {
|
|
1889
|
+
transitionTo("transferring");
|
|
1890
|
+
resetWatchdog();
|
|
1891
|
+
if (fileList) {
|
|
1892
|
+
onFileStart?.({ fileIndex: 0, name, size: fileSize });
|
|
1893
|
+
}
|
|
1894
|
+
try {
|
|
1895
|
+
conn.send({ t: "ready" });
|
|
1896
|
+
} catch {
|
|
1897
|
+
}
|
|
1898
|
+
};
|
|
1899
|
+
const metaEvt = { name, total };
|
|
1900
|
+
if (fileList) {
|
|
1901
|
+
metaEvt.fileCount = fileList.fileCount;
|
|
1902
|
+
metaEvt.files = fileList.files.map((f) => ({ name: f.name, size: f.size }));
|
|
1903
|
+
metaEvt.totalSize = fileList.totalSize;
|
|
1904
|
+
}
|
|
1905
|
+
if (autoReady) {
|
|
1906
|
+
if (!isStopped()) {
|
|
1907
|
+
onMeta?.(metaEvt);
|
|
1908
|
+
onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
|
|
1909
|
+
}
|
|
1910
|
+
sendReady();
|
|
1911
|
+
} else {
|
|
1912
|
+
metaEvt.sendReady = sendReady;
|
|
1913
|
+
if (!isStopped()) {
|
|
1914
|
+
onMeta?.(metaEvt);
|
|
1915
|
+
onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
break;
|
|
1052
1919
|
}
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1920
|
+
case "chunk":
|
|
1921
|
+
pendingChunk = msg;
|
|
1922
|
+
break;
|
|
1923
|
+
case "ping":
|
|
1924
|
+
try {
|
|
1925
|
+
conn.send({ t: "pong", timestamp: Date.now() });
|
|
1926
|
+
} catch {
|
|
1927
|
+
}
|
|
1928
|
+
break;
|
|
1929
|
+
case "file_end": {
|
|
1930
|
+
clearWatchdog();
|
|
1931
|
+
await writeQueue;
|
|
1932
|
+
const feIdx = msg.fileIndex;
|
|
1933
|
+
onFileEnd?.({ fileIndex: feIdx, receivedBytes: currentFileReceived });
|
|
1934
|
+
try {
|
|
1935
|
+
conn.send({ t: "file_end_ack", fileIndex: feIdx, received: currentFileReceived, size: currentFileReceived });
|
|
1936
|
+
} catch {
|
|
1937
|
+
}
|
|
1938
|
+
totalReceivedAllFiles += currentFileReceived;
|
|
1939
|
+
currentFileReceived = 0;
|
|
1940
|
+
resetWatchdog();
|
|
1941
|
+
break;
|
|
1059
1942
|
}
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1943
|
+
case "end":
|
|
1944
|
+
clearWatchdog();
|
|
1945
|
+
await writeQueue;
|
|
1946
|
+
const finalReceived = fileList ? totalReceivedAllFiles + currentFileReceived : received;
|
|
1947
|
+
const finalTotal = fileList ? fileList.totalSize : total;
|
|
1948
|
+
if (finalTotal && finalReceived < finalTotal) {
|
|
1949
|
+
const err2 = new DropgateNetworkError(
|
|
1950
|
+
"Transfer ended before all data was received."
|
|
1951
|
+
);
|
|
1952
|
+
try {
|
|
1953
|
+
conn.send({ t: "error", message: err2.message });
|
|
1954
|
+
} catch {
|
|
1955
|
+
}
|
|
1956
|
+
throw err2;
|
|
1957
|
+
}
|
|
1958
|
+
try {
|
|
1959
|
+
conn.send({ t: "end_ack", received: finalReceived, total: finalTotal });
|
|
1960
|
+
} catch {
|
|
1961
|
+
}
|
|
1962
|
+
safeComplete({ received: finalReceived, total: finalTotal });
|
|
1963
|
+
(async () => {
|
|
1964
|
+
for (let i = 0; i < 2; i++) {
|
|
1965
|
+
await sleep(P2P_END_ACK_RETRY_DELAY_MS);
|
|
1966
|
+
try {
|
|
1967
|
+
conn.send({ t: "end_ack", received: finalReceived, total: finalTotal });
|
|
1968
|
+
} catch {
|
|
1969
|
+
break;
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
})().catch(() => {
|
|
1973
|
+
});
|
|
1974
|
+
break;
|
|
1975
|
+
case "error":
|
|
1976
|
+
throw new DropgateNetworkError(msg.message || "Sender reported an error.");
|
|
1977
|
+
case "cancelled":
|
|
1978
|
+
if (state === "cancelled" || state === "closed" || state === "completed") return;
|
|
1979
|
+
transitionTo("cancelled");
|
|
1980
|
+
onCancel?.({ cancelledBy: "sender", message: msg.reason });
|
|
1981
|
+
cleanup();
|
|
1982
|
+
break;
|
|
1069
1983
|
}
|
|
1984
|
+
} catch (err2) {
|
|
1985
|
+
safeError(err2);
|
|
1070
1986
|
}
|
|
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();
|
|
1987
|
+
});
|
|
1988
|
+
conn.on("close", () => {
|
|
1989
|
+
if (state === "closed" || state === "completed" || state === "cancelled") {
|
|
1990
|
+
cleanup();
|
|
1991
|
+
return;
|
|
1117
1992
|
}
|
|
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) {
|
|
1993
|
+
if (state === "transferring") {
|
|
1994
|
+
transitionTo("cancelled");
|
|
1995
|
+
onCancel?.({ cancelledBy: "sender" });
|
|
1131
1996
|
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 {
|
|
1997
|
+
} else if (state === "negotiating") {
|
|
1998
|
+
transitionTo("closed");
|
|
1173
1999
|
cleanup();
|
|
2000
|
+
onDisconnect?.();
|
|
2001
|
+
} else {
|
|
2002
|
+
safeError(new DropgateNetworkError("Sender disconnected before file details were received."));
|
|
1174
2003
|
}
|
|
1175
|
-
}
|
|
1176
|
-
}
|
|
1177
|
-
|
|
2004
|
+
});
|
|
2005
|
+
});
|
|
2006
|
+
return {
|
|
2007
|
+
peer,
|
|
2008
|
+
stop,
|
|
2009
|
+
getStatus: () => state,
|
|
2010
|
+
getBytesReceived: () => received,
|
|
2011
|
+
getTotalBytes: () => total,
|
|
2012
|
+
getSessionId: () => currentSessionId
|
|
2013
|
+
};
|
|
2014
|
+
}
|
|
1178
2015
|
|
|
1179
|
-
// src/
|
|
1180
|
-
function
|
|
1181
|
-
|
|
1182
|
-
|
|
2016
|
+
// src/client/DropgateClient.ts
|
|
2017
|
+
function resolveServerToBaseUrl(server) {
|
|
2018
|
+
if (typeof server === "string") {
|
|
2019
|
+
return buildBaseUrl(parseServerUrl(server));
|
|
2020
|
+
}
|
|
2021
|
+
return buildBaseUrl(server);
|
|
1183
2022
|
}
|
|
1184
|
-
function
|
|
1185
|
-
|
|
2023
|
+
function estimateTotalUploadSizeBytes(fileSizeBytes, totalChunks, isEncrypted) {
|
|
2024
|
+
const base = Number(fileSizeBytes) || 0;
|
|
2025
|
+
if (!isEncrypted) return base;
|
|
2026
|
+
return base + (Number(totalChunks) || 0) * ENCRYPTION_OVERHEAD_PER_CHUNK;
|
|
1186
2027
|
}
|
|
1187
|
-
function
|
|
1188
|
-
const
|
|
1189
|
-
const
|
|
1190
|
-
if (
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
2028
|
+
async function getServerInfo(opts) {
|
|
2029
|
+
const { server, timeoutMs = 5e3, signal, fetchFn: customFetch } = opts;
|
|
2030
|
+
const fetchFn = customFetch || getDefaultFetch();
|
|
2031
|
+
if (!fetchFn) {
|
|
2032
|
+
throw new DropgateValidationError("No fetch() implementation found.");
|
|
2033
|
+
}
|
|
2034
|
+
const baseUrl = resolveServerToBaseUrl(server);
|
|
2035
|
+
try {
|
|
2036
|
+
const { res, json } = await fetchJson(
|
|
2037
|
+
fetchFn,
|
|
2038
|
+
`${baseUrl}/api/info`,
|
|
2039
|
+
{
|
|
2040
|
+
method: "GET",
|
|
2041
|
+
timeoutMs,
|
|
2042
|
+
signal,
|
|
2043
|
+
headers: { Accept: "application/json" }
|
|
2044
|
+
}
|
|
2045
|
+
);
|
|
2046
|
+
if (res.ok && json && typeof json === "object" && "version" in json) {
|
|
2047
|
+
return { baseUrl, serverInfo: json };
|
|
2048
|
+
}
|
|
2049
|
+
throw new DropgateProtocolError(
|
|
2050
|
+
`Server info request failed (status ${res.status}).`
|
|
2051
|
+
);
|
|
2052
|
+
} catch (err2) {
|
|
2053
|
+
if (err2 instanceof DropgateError) throw err2;
|
|
2054
|
+
throw new DropgateNetworkError("Could not reach server /api/info.", {
|
|
2055
|
+
cause: err2
|
|
2056
|
+
});
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
var DropgateClient = class {
|
|
2060
|
+
/**
|
|
2061
|
+
* Create a new DropgateClient instance.
|
|
2062
|
+
* @param opts - Client configuration options including server URL.
|
|
2063
|
+
* @throws {DropgateValidationError} If clientVersion or server is missing or invalid.
|
|
2064
|
+
*/
|
|
2065
|
+
constructor(opts) {
|
|
2066
|
+
/** Client version string for compatibility checking. */
|
|
2067
|
+
__publicField(this, "clientVersion");
|
|
2068
|
+
/** Chunk size in bytes for upload splitting. */
|
|
2069
|
+
__publicField(this, "chunkSize");
|
|
2070
|
+
/** Fetch implementation used for HTTP requests. */
|
|
2071
|
+
__publicField(this, "fetchFn");
|
|
2072
|
+
/** Crypto implementation for encryption operations. */
|
|
2073
|
+
__publicField(this, "cryptoObj");
|
|
2074
|
+
/** Base64 encoder/decoder for binary data. */
|
|
2075
|
+
__publicField(this, "base64");
|
|
2076
|
+
/** Resolved base URL (e.g. 'https://dropgate.link'). May change during HTTP fallback. */
|
|
2077
|
+
__publicField(this, "baseUrl");
|
|
2078
|
+
/** Whether to automatically retry with HTTP when HTTPS fails. */
|
|
2079
|
+
__publicField(this, "_fallbackToHttp");
|
|
2080
|
+
/** Cached compatibility result (null until first connect()). */
|
|
2081
|
+
__publicField(this, "_compat", null);
|
|
2082
|
+
/** In-flight connect promise to deduplicate concurrent calls. */
|
|
2083
|
+
__publicField(this, "_connectPromise", null);
|
|
2084
|
+
if (!opts || typeof opts.clientVersion !== "string") {
|
|
2085
|
+
throw new DropgateValidationError(
|
|
2086
|
+
"DropgateClient requires clientVersion (string)."
|
|
2087
|
+
);
|
|
2088
|
+
}
|
|
2089
|
+
if (!opts.server) {
|
|
2090
|
+
throw new DropgateValidationError(
|
|
2091
|
+
"DropgateClient requires server (URL string or ServerTarget object)."
|
|
2092
|
+
);
|
|
1196
2093
|
}
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
2094
|
+
this.clientVersion = opts.clientVersion;
|
|
2095
|
+
this.chunkSize = Number.isFinite(opts.chunkSize) ? opts.chunkSize : DEFAULT_CHUNK_SIZE;
|
|
2096
|
+
const fetchFn = opts.fetchFn || getDefaultFetch();
|
|
2097
|
+
if (!fetchFn) {
|
|
2098
|
+
throw new DropgateValidationError("No fetch() implementation found.");
|
|
1200
2099
|
}
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
2100
|
+
this.fetchFn = fetchFn;
|
|
2101
|
+
const cryptoObj = opts.cryptoObj || getDefaultCrypto();
|
|
2102
|
+
if (!cryptoObj) {
|
|
2103
|
+
throw new DropgateValidationError("No crypto implementation found.");
|
|
2104
|
+
}
|
|
2105
|
+
this.cryptoObj = cryptoObj;
|
|
2106
|
+
this.base64 = opts.base64 || getDefaultBase64();
|
|
2107
|
+
this._fallbackToHttp = Boolean(opts.fallbackToHttp);
|
|
2108
|
+
this.baseUrl = resolveServerToBaseUrl(opts.server);
|
|
1206
2109
|
}
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
2110
|
+
/**
|
|
2111
|
+
* Get the server target (host, port, secure) derived from the current baseUrl.
|
|
2112
|
+
* Useful for passing to standalone functions that still need a ServerTarget.
|
|
2113
|
+
*/
|
|
2114
|
+
get serverTarget() {
|
|
2115
|
+
const url = new URL(this.baseUrl);
|
|
2116
|
+
return {
|
|
2117
|
+
host: url.hostname,
|
|
2118
|
+
port: url.port ? Number(url.port) : void 0,
|
|
2119
|
+
secure: url.protocol === "https:"
|
|
2120
|
+
};
|
|
1210
2121
|
}
|
|
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;
|
|
2122
|
+
/**
|
|
2123
|
+
* Connect to the server: fetch server info and check version compatibility.
|
|
2124
|
+
* Results are cached — subsequent calls return instantly without network requests.
|
|
2125
|
+
* Concurrent calls are deduplicated.
|
|
2126
|
+
*
|
|
2127
|
+
* @param opts - Optional timeout and abort signal.
|
|
2128
|
+
* @returns Compatibility result with server info.
|
|
2129
|
+
* @throws {DropgateNetworkError} If the server cannot be reached.
|
|
2130
|
+
* @throws {DropgateProtocolError} If the server returns an invalid response.
|
|
2131
|
+
*/
|
|
2132
|
+
async connect(opts) {
|
|
2133
|
+
if (this._compat) return this._compat;
|
|
2134
|
+
if (!this._connectPromise) {
|
|
2135
|
+
this._connectPromise = this._fetchAndCheckCompat(opts).finally(() => {
|
|
2136
|
+
this._connectPromise = null;
|
|
2137
|
+
});
|
|
2138
|
+
}
|
|
2139
|
+
return this._connectPromise;
|
|
1235
2140
|
}
|
|
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);
|
|
2141
|
+
async _fetchAndCheckCompat(opts) {
|
|
2142
|
+
const { timeoutMs = 5e3, signal } = opts ?? {};
|
|
2143
|
+
let baseUrl = this.baseUrl;
|
|
2144
|
+
let serverInfo;
|
|
1245
2145
|
try {
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
instance.destroy();
|
|
1252
|
-
} catch {
|
|
1253
|
-
}
|
|
1254
|
-
reject(err);
|
|
1255
|
-
});
|
|
2146
|
+
const result = await getServerInfo({
|
|
2147
|
+
server: baseUrl,
|
|
2148
|
+
timeoutMs,
|
|
2149
|
+
signal,
|
|
2150
|
+
fetchFn: this.fetchFn
|
|
1256
2151
|
});
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
2152
|
+
baseUrl = result.baseUrl;
|
|
2153
|
+
serverInfo = result.serverInfo;
|
|
2154
|
+
} catch (err2) {
|
|
2155
|
+
if (this._fallbackToHttp && this.baseUrl.startsWith("https://")) {
|
|
2156
|
+
const httpBaseUrl = this.baseUrl.replace("https://", "http://");
|
|
2157
|
+
try {
|
|
2158
|
+
const result = await getServerInfo({
|
|
2159
|
+
server: httpBaseUrl,
|
|
2160
|
+
timeoutMs,
|
|
2161
|
+
signal,
|
|
2162
|
+
fetchFn: this.fetchFn
|
|
2163
|
+
});
|
|
2164
|
+
this.baseUrl = httpBaseUrl;
|
|
2165
|
+
baseUrl = result.baseUrl;
|
|
2166
|
+
serverInfo = result.serverInfo;
|
|
2167
|
+
} catch {
|
|
2168
|
+
if (err2 instanceof DropgateError) throw err2;
|
|
2169
|
+
throw new DropgateNetworkError("Could not connect to the server.", { cause: err2 });
|
|
2170
|
+
}
|
|
2171
|
+
} else {
|
|
2172
|
+
if (err2 instanceof DropgateError) throw err2;
|
|
2173
|
+
throw new DropgateNetworkError("Could not connect to the server.", { cause: err2 });
|
|
2174
|
+
}
|
|
1261
2175
|
}
|
|
2176
|
+
const compat = this._checkVersionCompat(serverInfo);
|
|
2177
|
+
this._compat = { ...compat, serverInfo, baseUrl };
|
|
2178
|
+
return this._compat;
|
|
1262
2179
|
}
|
|
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.");
|
|
2180
|
+
/**
|
|
2181
|
+
* Pure version compatibility check (no network calls).
|
|
2182
|
+
*/
|
|
2183
|
+
_checkVersionCompat(serverInfo) {
|
|
2184
|
+
const serverVersion = String(serverInfo?.version || "0.0.0");
|
|
2185
|
+
const clientVersion = String(this.clientVersion || "0.0.0");
|
|
2186
|
+
const c = parseSemverMajorMinor(clientVersion);
|
|
2187
|
+
const s = parseSemverMajorMinor(serverVersion);
|
|
2188
|
+
if (c.major !== s.major) {
|
|
2189
|
+
return {
|
|
2190
|
+
compatible: false,
|
|
2191
|
+
clientVersion,
|
|
2192
|
+
serverVersion,
|
|
2193
|
+
message: `Incompatible versions. Client v${clientVersion}, Server v${serverVersion}${serverInfo?.name ? ` (${serverInfo.name})` : ""}.`
|
|
2194
|
+
};
|
|
2195
|
+
}
|
|
2196
|
+
if (c.minor > s.minor) {
|
|
2197
|
+
return {
|
|
2198
|
+
compatible: true,
|
|
2199
|
+
clientVersion,
|
|
2200
|
+
serverVersion,
|
|
2201
|
+
message: `Client (v${clientVersion}) is newer than Server (v${serverVersion})${serverInfo?.name ? ` (${serverInfo.name})` : ""}. Some features may not work.`
|
|
2202
|
+
};
|
|
2203
|
+
}
|
|
2204
|
+
return {
|
|
2205
|
+
compatible: true,
|
|
2206
|
+
clientVersion,
|
|
2207
|
+
serverVersion,
|
|
2208
|
+
message: `Server: v${serverVersion}, Client: v${clientVersion}${serverInfo?.name ? ` (${serverInfo.name})` : ""}.`
|
|
2209
|
+
};
|
|
1301
2210
|
}
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
2211
|
+
/**
|
|
2212
|
+
* Resolve a user-entered sharing code or URL via the server.
|
|
2213
|
+
* @param value - The sharing code or URL to resolve.
|
|
2214
|
+
* @param opts - Optional timeout and abort signal.
|
|
2215
|
+
* @returns The resolved share target information.
|
|
2216
|
+
* @throws {DropgateProtocolError} If the share lookup fails.
|
|
2217
|
+
*/
|
|
2218
|
+
async resolveShareTarget(value, opts) {
|
|
2219
|
+
const { timeoutMs = 5e3, signal } = opts ?? {};
|
|
2220
|
+
const compat = await this.connect(opts);
|
|
2221
|
+
if (!compat.compatible) {
|
|
2222
|
+
throw new DropgateValidationError(compat.message);
|
|
2223
|
+
}
|
|
2224
|
+
const { baseUrl } = compat;
|
|
2225
|
+
const { res, json } = await fetchJson(
|
|
2226
|
+
this.fetchFn,
|
|
2227
|
+
`${baseUrl}/api/resolve`,
|
|
2228
|
+
{
|
|
2229
|
+
method: "POST",
|
|
2230
|
+
timeoutMs,
|
|
2231
|
+
signal,
|
|
2232
|
+
headers: {
|
|
2233
|
+
"Content-Type": "application/json",
|
|
2234
|
+
Accept: "application/json"
|
|
2235
|
+
},
|
|
2236
|
+
body: JSON.stringify({ value })
|
|
2237
|
+
}
|
|
1305
2238
|
);
|
|
2239
|
+
if (!res.ok) {
|
|
2240
|
+
const msg = (json && typeof json === "object" && "error" in json ? json.error : null) || `Share lookup failed (status ${res.status}).`;
|
|
2241
|
+
throw new DropgateProtocolError(msg, { details: json });
|
|
2242
|
+
}
|
|
2243
|
+
return json || { valid: false, reason: "Unknown response." };
|
|
1306
2244
|
}
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
2245
|
+
/**
|
|
2246
|
+
* Fetch metadata for a single file from the server.
|
|
2247
|
+
* @param fileId - The file ID to fetch metadata for.
|
|
2248
|
+
* @param opts - Optional connection options (timeout, signal).
|
|
2249
|
+
* @returns File metadata including size, filename, and encryption status.
|
|
2250
|
+
* @throws {DropgateNetworkError} If the server cannot be reached.
|
|
2251
|
+
* @throws {DropgateProtocolError} If the file is not found or server returns an error.
|
|
2252
|
+
*/
|
|
2253
|
+
async getFileMetadata(fileId, opts) {
|
|
2254
|
+
if (!fileId || typeof fileId !== "string") {
|
|
2255
|
+
throw new DropgateValidationError("File ID is required.");
|
|
2256
|
+
}
|
|
2257
|
+
const { timeoutMs = 5e3, signal } = opts ?? {};
|
|
2258
|
+
const url = `${this.baseUrl}/api/file/${encodeURIComponent(fileId)}/meta`;
|
|
2259
|
+
const { res, json } = await fetchJson(this.fetchFn, url, {
|
|
2260
|
+
method: "GET",
|
|
2261
|
+
timeoutMs,
|
|
2262
|
+
signal
|
|
2263
|
+
});
|
|
2264
|
+
if (!res.ok) {
|
|
2265
|
+
const msg = (json && typeof json === "object" && "error" in json ? json.error : null) || `Failed to fetch file metadata (status ${res.status}).`;
|
|
2266
|
+
throw new DropgateProtocolError(msg, { details: json });
|
|
2267
|
+
}
|
|
2268
|
+
return json;
|
|
1310
2269
|
}
|
|
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;
|
|
2270
|
+
/**
|
|
2271
|
+
* Fetch metadata for a bundle from the server and derive computed fields.
|
|
2272
|
+
* For sealed bundles, decrypts the manifest to extract file list.
|
|
2273
|
+
* Automatically derives totalSizeBytes and fileCount from the files array.
|
|
2274
|
+
* @param bundleId - The bundle ID to fetch metadata for.
|
|
2275
|
+
* @param keyB64 - Base64-encoded decryption key (required for encrypted bundles).
|
|
2276
|
+
* @param opts - Optional connection options (timeout, signal).
|
|
2277
|
+
* @returns Complete bundle metadata with all files and computed fields.
|
|
2278
|
+
* @throws {DropgateNetworkError} If the server cannot be reached.
|
|
2279
|
+
* @throws {DropgateProtocolError} If the bundle is not found or server returns an error.
|
|
2280
|
+
* @throws {DropgateValidationError} If decryption key is missing for encrypted bundle.
|
|
2281
|
+
*/
|
|
2282
|
+
async getBundleMetadata(bundleId, keyB64, opts) {
|
|
2283
|
+
if (!bundleId || typeof bundleId !== "string") {
|
|
2284
|
+
throw new DropgateValidationError("Bundle ID is required.");
|
|
1358
2285
|
}
|
|
1359
|
-
|
|
1360
|
-
|
|
2286
|
+
const { timeoutMs = 5e3, signal } = opts ?? {};
|
|
2287
|
+
const url = `${this.baseUrl}/api/bundle/${encodeURIComponent(bundleId)}/meta`;
|
|
2288
|
+
const { res, json } = await fetchJson(this.fetchFn, url, {
|
|
2289
|
+
method: "GET",
|
|
2290
|
+
timeoutMs,
|
|
2291
|
+
signal
|
|
2292
|
+
});
|
|
2293
|
+
if (!res.ok) {
|
|
2294
|
+
const msg = (json && typeof json === "object" && "error" in json ? json.error : null) || `Failed to fetch bundle metadata (status ${res.status}).`;
|
|
2295
|
+
throw new DropgateProtocolError(msg, { details: json });
|
|
2296
|
+
}
|
|
2297
|
+
const serverMeta = json;
|
|
2298
|
+
let files = [];
|
|
2299
|
+
if (serverMeta.sealed && serverMeta.encryptedManifest) {
|
|
2300
|
+
if (!keyB64) {
|
|
2301
|
+
throw new DropgateValidationError(
|
|
2302
|
+
"Decryption key (keyB64) is required for encrypted sealed bundles."
|
|
2303
|
+
);
|
|
2304
|
+
}
|
|
2305
|
+
const key = await importKeyFromBase64(this.cryptoObj, keyB64);
|
|
2306
|
+
const encryptedBytes = this.base64.decode(serverMeta.encryptedManifest);
|
|
2307
|
+
const decryptedBuffer = await decryptChunk(this.cryptoObj, encryptedBytes, key);
|
|
2308
|
+
const manifestJson = new TextDecoder().decode(decryptedBuffer);
|
|
2309
|
+
const manifest = JSON.parse(manifestJson);
|
|
2310
|
+
files = manifest.files.map((f) => ({
|
|
2311
|
+
fileId: f.fileId,
|
|
2312
|
+
sizeBytes: f.sizeBytes,
|
|
2313
|
+
filename: f.name
|
|
2314
|
+
}));
|
|
2315
|
+
} else if (serverMeta.files) {
|
|
2316
|
+
files = serverMeta.files;
|
|
2317
|
+
} else {
|
|
2318
|
+
throw new DropgateProtocolError("Invalid bundle metadata: missing files or manifest.");
|
|
2319
|
+
}
|
|
2320
|
+
const totalSizeBytes = files.reduce((sum, f) => sum + (f.sizeBytes || 0), 0);
|
|
2321
|
+
const fileCount = files.length;
|
|
2322
|
+
return {
|
|
2323
|
+
isEncrypted: serverMeta.isEncrypted,
|
|
2324
|
+
sealed: serverMeta.sealed,
|
|
2325
|
+
encryptedManifest: serverMeta.encryptedManifest,
|
|
2326
|
+
files,
|
|
2327
|
+
totalSizeBytes,
|
|
2328
|
+
fileCount
|
|
2329
|
+
};
|
|
2330
|
+
}
|
|
2331
|
+
/**
|
|
2332
|
+
* Validate file and upload settings against server capabilities.
|
|
2333
|
+
* @param opts - Validation options containing file, settings, and server info.
|
|
2334
|
+
* @returns True if validation passes.
|
|
2335
|
+
* @throws {DropgateValidationError} If any validation check fails.
|
|
2336
|
+
*/
|
|
2337
|
+
validateUploadInputs(opts) {
|
|
2338
|
+
const { files: rawFiles, lifetimeMs, encrypt, serverInfo } = opts;
|
|
2339
|
+
const caps = serverInfo?.capabilities?.upload;
|
|
2340
|
+
if (!caps || !caps.enabled) {
|
|
2341
|
+
throw new DropgateValidationError("Server does not support file uploads.");
|
|
1361
2342
|
}
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
2343
|
+
const files = Array.isArray(rawFiles) ? rawFiles : [rawFiles];
|
|
2344
|
+
if (files.length === 0) {
|
|
2345
|
+
throw new DropgateValidationError("At least one file is required.");
|
|
1365
2346
|
}
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
2347
|
+
for (let i = 0; i < files.length; i++) {
|
|
2348
|
+
const file = files[i];
|
|
2349
|
+
const fileSize = Number(file?.size || 0);
|
|
2350
|
+
if (!file || !Number.isFinite(fileSize) || fileSize <= 0) {
|
|
2351
|
+
throw new DropgateValidationError(`File at index ${i} is missing or invalid.`);
|
|
2352
|
+
}
|
|
2353
|
+
const maxMB = Number(caps.maxSizeMB);
|
|
2354
|
+
if (Number.isFinite(maxMB) && maxMB > 0) {
|
|
2355
|
+
const limitBytes = maxMB * 1e3 * 1e3;
|
|
2356
|
+
const validationChunkSize = Number.isFinite(caps.chunkSize) && caps.chunkSize > 0 ? caps.chunkSize : this.chunkSize;
|
|
2357
|
+
const totalChunks = Math.ceil(fileSize / validationChunkSize);
|
|
2358
|
+
const estimatedBytes = estimateTotalUploadSizeBytes(
|
|
2359
|
+
fileSize,
|
|
2360
|
+
totalChunks,
|
|
2361
|
+
Boolean(encrypt)
|
|
2362
|
+
);
|
|
2363
|
+
if (estimatedBytes > limitBytes) {
|
|
2364
|
+
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.`;
|
|
2365
|
+
throw new DropgateValidationError(msg);
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
1369
2368
|
}
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
2369
|
+
const maxHours = Number(caps.maxLifetimeHours);
|
|
2370
|
+
const lt = Number(lifetimeMs);
|
|
2371
|
+
if (!Number.isFinite(lt) || lt < 0 || !Number.isInteger(lt)) {
|
|
2372
|
+
throw new DropgateValidationError(
|
|
2373
|
+
"Invalid lifetime. Must be a non-negative integer (milliseconds)."
|
|
2374
|
+
);
|
|
1375
2375
|
}
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
activeConn.send({ t: "cancelled", message: "Sender cancelled the transfer." });
|
|
2376
|
+
if (Number.isFinite(maxHours) && maxHours > 0) {
|
|
2377
|
+
const limitMs = Math.round(maxHours * 60 * 60 * 1e3);
|
|
2378
|
+
if (lt === 0) {
|
|
2379
|
+
throw new DropgateValidationError(
|
|
2380
|
+
`Server does not allow unlimited file lifetime. Max: ${maxHours} hours.`
|
|
2381
|
+
);
|
|
2382
|
+
}
|
|
2383
|
+
if (lt > limitMs) {
|
|
2384
|
+
throw new DropgateValidationError(
|
|
2385
|
+
`File lifetime too long. Server limit: ${maxHours} hours.`
|
|
2386
|
+
);
|
|
1388
2387
|
}
|
|
1389
|
-
} catch {
|
|
1390
2388
|
}
|
|
1391
|
-
if (
|
|
1392
|
-
|
|
2389
|
+
if (encrypt && !caps.e2ee) {
|
|
2390
|
+
throw new DropgateValidationError(
|
|
2391
|
+
"End-to-end encryption is not supported on this server."
|
|
2392
|
+
);
|
|
1393
2393
|
}
|
|
1394
|
-
|
|
1395
|
-
}
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
2394
|
+
return true;
|
|
2395
|
+
}
|
|
2396
|
+
/**
|
|
2397
|
+
* Upload one or more files to the server with optional encryption.
|
|
2398
|
+
* Single files use the standard upload protocol.
|
|
2399
|
+
* Multiple files use the bundle protocol, grouping files under a single download link.
|
|
2400
|
+
*
|
|
2401
|
+
* @param opts - Upload options including file(s) and settings.
|
|
2402
|
+
* @returns Upload session with result promise and cancellation support.
|
|
2403
|
+
*/
|
|
2404
|
+
async uploadFiles(opts) {
|
|
2405
|
+
const {
|
|
2406
|
+
files: rawFiles,
|
|
2407
|
+
lifetimeMs,
|
|
2408
|
+
encrypt,
|
|
2409
|
+
maxDownloads,
|
|
2410
|
+
filenameOverrides,
|
|
2411
|
+
onProgress,
|
|
2412
|
+
onCancel,
|
|
2413
|
+
signal,
|
|
2414
|
+
timeouts = {},
|
|
2415
|
+
retry = {}
|
|
2416
|
+
} = opts;
|
|
2417
|
+
const files = Array.isArray(rawFiles) ? rawFiles : [rawFiles];
|
|
2418
|
+
if (files.length === 0) {
|
|
2419
|
+
throw new DropgateValidationError("At least one file is required.");
|
|
2420
|
+
}
|
|
2421
|
+
const internalController = signal ? null : new AbortController();
|
|
2422
|
+
const effectiveSignal = signal || internalController?.signal;
|
|
2423
|
+
let uploadState = "initializing";
|
|
2424
|
+
const currentUploadIds = [];
|
|
2425
|
+
const totalSizeBytes = files.reduce((sum, f) => sum + f.size, 0);
|
|
2426
|
+
const uploadPromise = (async () => {
|
|
2427
|
+
try {
|
|
2428
|
+
const progress = (evt) => {
|
|
2429
|
+
try {
|
|
2430
|
+
if (onProgress) onProgress(evt);
|
|
2431
|
+
} catch {
|
|
2432
|
+
}
|
|
2433
|
+
};
|
|
2434
|
+
progress({ phase: "server-info", text: "Checking server...", percent: 0, processedBytes: 0, totalBytes: totalSizeBytes });
|
|
2435
|
+
const compat = await this.connect({
|
|
2436
|
+
timeoutMs: timeouts.serverInfoMs ?? 5e3,
|
|
2437
|
+
signal: effectiveSignal
|
|
2438
|
+
});
|
|
2439
|
+
const { baseUrl, serverInfo } = compat;
|
|
2440
|
+
progress({ phase: "server-compat", text: compat.message, percent: 0, processedBytes: 0, totalBytes: totalSizeBytes });
|
|
2441
|
+
if (!compat.compatible) {
|
|
2442
|
+
throw new DropgateValidationError(compat.message);
|
|
1405
2443
|
}
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
2444
|
+
const filenames = files.map((f, i) => filenameOverrides?.[i] ?? f.name ?? "file");
|
|
2445
|
+
const serverSupportsE2EE = Boolean(serverInfo?.capabilities?.upload?.e2ee);
|
|
2446
|
+
const effectiveEncrypt = encrypt ?? serverSupportsE2EE;
|
|
2447
|
+
if (!effectiveEncrypt) {
|
|
2448
|
+
for (const name of filenames) validatePlainFilename(name);
|
|
1409
2449
|
}
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
2450
|
+
this.validateUploadInputs({ files, lifetimeMs, encrypt: effectiveEncrypt, serverInfo });
|
|
2451
|
+
let cryptoKey = null;
|
|
2452
|
+
let keyB64 = null;
|
|
2453
|
+
const transmittedFilenames = [];
|
|
2454
|
+
if (effectiveEncrypt) {
|
|
2455
|
+
if (!this.cryptoObj?.subtle) {
|
|
2456
|
+
throw new DropgateValidationError(
|
|
2457
|
+
"Web Crypto API not available (crypto.subtle). Encryption requires a secure context (HTTPS or localhost)."
|
|
2458
|
+
);
|
|
2459
|
+
}
|
|
2460
|
+
progress({ phase: "crypto", text: "Generating encryption key...", percent: 0, processedBytes: 0, totalBytes: totalSizeBytes });
|
|
2461
|
+
try {
|
|
2462
|
+
cryptoKey = await generateAesGcmKey(this.cryptoObj);
|
|
2463
|
+
keyB64 = await exportKeyBase64(this.cryptoObj, cryptoKey);
|
|
2464
|
+
for (const name of filenames) {
|
|
2465
|
+
transmittedFilenames.push(
|
|
2466
|
+
await encryptFilenameToBase64(this.cryptoObj, name, cryptoKey)
|
|
2467
|
+
);
|
|
2468
|
+
}
|
|
2469
|
+
} catch (err2) {
|
|
2470
|
+
throw new DropgateError("Failed to prepare encryption.", { code: "CRYPTO_PREP_FAILED", cause: err2 });
|
|
2471
|
+
}
|
|
2472
|
+
} else {
|
|
2473
|
+
transmittedFilenames.push(...filenames);
|
|
1415
2474
|
}
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
2475
|
+
const serverChunkSize = serverInfo?.capabilities?.upload?.chunkSize;
|
|
2476
|
+
const effectiveChunkSize = Number.isFinite(serverChunkSize) && serverChunkSize > 0 ? serverChunkSize : this.chunkSize;
|
|
2477
|
+
const retries = Number.isFinite(retry.retries) ? retry.retries : 5;
|
|
2478
|
+
const baseBackoffMs = Number.isFinite(retry.backoffMs) ? retry.backoffMs : 1e3;
|
|
2479
|
+
const maxBackoffMs = Number.isFinite(retry.maxBackoffMs) ? retry.maxBackoffMs : 3e4;
|
|
2480
|
+
if (files.length === 1) {
|
|
2481
|
+
const file = files[0];
|
|
2482
|
+
const totalChunks = Math.ceil(file.size / effectiveChunkSize);
|
|
2483
|
+
const totalUploadSize = estimateTotalUploadSizeBytes(file.size, totalChunks, effectiveEncrypt);
|
|
2484
|
+
progress({ phase: "init", text: "Reserving server storage...", percent: 0, processedBytes: 0, totalBytes: file.size });
|
|
2485
|
+
const initRes = await fetchJson(this.fetchFn, `${baseUrl}/upload/init`, {
|
|
2486
|
+
method: "POST",
|
|
2487
|
+
timeoutMs: timeouts.initMs ?? 15e3,
|
|
2488
|
+
signal: effectiveSignal,
|
|
2489
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
2490
|
+
body: JSON.stringify({
|
|
2491
|
+
filename: transmittedFilenames[0],
|
|
2492
|
+
lifetime: lifetimeMs,
|
|
2493
|
+
isEncrypted: effectiveEncrypt,
|
|
2494
|
+
totalSize: totalUploadSize,
|
|
2495
|
+
totalChunks,
|
|
2496
|
+
...maxDownloads !== void 0 ? { maxDownloads } : {}
|
|
2497
|
+
})
|
|
2498
|
+
});
|
|
2499
|
+
if (!initRes.res.ok) {
|
|
2500
|
+
const errorJson = initRes.json;
|
|
2501
|
+
throw new DropgateProtocolError(errorJson?.error || `Server initialisation failed: ${initRes.res.status}`, { details: initRes.json || initRes.text });
|
|
2502
|
+
}
|
|
2503
|
+
const uploadId = initRes.json?.uploadId;
|
|
2504
|
+
if (!uploadId) throw new DropgateProtocolError("Server did not return a valid uploadId.");
|
|
2505
|
+
currentUploadIds.push(uploadId);
|
|
2506
|
+
uploadState = "uploading";
|
|
2507
|
+
await this._uploadFileChunks({
|
|
2508
|
+
file,
|
|
2509
|
+
uploadId,
|
|
2510
|
+
cryptoKey,
|
|
2511
|
+
effectiveChunkSize,
|
|
2512
|
+
totalChunks,
|
|
2513
|
+
totalUploadSize,
|
|
2514
|
+
baseOffset: 0,
|
|
2515
|
+
totalBytesAllFiles: file.size,
|
|
2516
|
+
progress,
|
|
2517
|
+
signal: effectiveSignal,
|
|
2518
|
+
baseUrl,
|
|
2519
|
+
retries,
|
|
2520
|
+
backoffMs: baseBackoffMs,
|
|
2521
|
+
maxBackoffMs,
|
|
2522
|
+
chunkTimeoutMs: timeouts.chunkMs ?? 6e4
|
|
2523
|
+
});
|
|
2524
|
+
progress({ phase: "complete", text: "Finalising upload...", percent: 100, processedBytes: file.size, totalBytes: file.size });
|
|
2525
|
+
uploadState = "completing";
|
|
2526
|
+
const completeRes = await fetchJson(this.fetchFn, `${baseUrl}/upload/complete`, {
|
|
2527
|
+
method: "POST",
|
|
2528
|
+
timeoutMs: timeouts.completeMs ?? 3e4,
|
|
2529
|
+
signal: effectiveSignal,
|
|
2530
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
2531
|
+
body: JSON.stringify({ uploadId })
|
|
2532
|
+
});
|
|
2533
|
+
if (!completeRes.res.ok) {
|
|
2534
|
+
const errorJson = completeRes.json;
|
|
2535
|
+
throw new DropgateProtocolError(errorJson?.error || "Finalisation failed.", { details: completeRes.json || completeRes.text });
|
|
2536
|
+
}
|
|
2537
|
+
const fileId = completeRes.json?.id;
|
|
2538
|
+
if (!fileId) throw new DropgateProtocolError("Server did not return a valid file id.");
|
|
2539
|
+
let downloadUrl2 = `${baseUrl}/${fileId}`;
|
|
2540
|
+
if (effectiveEncrypt && keyB64) downloadUrl2 += `#${keyB64}`;
|
|
2541
|
+
progress({ phase: "done", text: "Upload successful!", percent: 100, processedBytes: file.size, totalBytes: file.size });
|
|
2542
|
+
uploadState = "completed";
|
|
2543
|
+
return {
|
|
2544
|
+
downloadUrl: downloadUrl2,
|
|
2545
|
+
fileId,
|
|
2546
|
+
uploadId,
|
|
2547
|
+
baseUrl,
|
|
2548
|
+
...effectiveEncrypt && keyB64 ? { keyB64 } : {}
|
|
2549
|
+
};
|
|
1423
2550
|
}
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
2551
|
+
const fileManifest = files.map((f, i) => {
|
|
2552
|
+
const totalChunks = Math.ceil(f.size / effectiveChunkSize);
|
|
2553
|
+
const totalUploadSize = estimateTotalUploadSizeBytes(f.size, totalChunks, effectiveEncrypt);
|
|
2554
|
+
return { filename: transmittedFilenames[i], totalSize: totalUploadSize, totalChunks };
|
|
2555
|
+
});
|
|
2556
|
+
progress({ phase: "init", text: `Reserving server storage for ${files.length} files...`, percent: 0, processedBytes: 0, totalBytes: totalSizeBytes, totalFiles: files.length });
|
|
2557
|
+
const initBundleRes = await fetchJson(this.fetchFn, `${baseUrl}/upload/init-bundle`, {
|
|
2558
|
+
method: "POST",
|
|
2559
|
+
timeoutMs: timeouts.initMs ?? 15e3,
|
|
2560
|
+
signal: effectiveSignal,
|
|
2561
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
2562
|
+
body: JSON.stringify({
|
|
2563
|
+
fileCount: files.length,
|
|
2564
|
+
files: fileManifest,
|
|
2565
|
+
lifetime: lifetimeMs,
|
|
2566
|
+
isEncrypted: effectiveEncrypt,
|
|
2567
|
+
...maxDownloads !== void 0 ? { maxDownloads } : {}
|
|
2568
|
+
})
|
|
2569
|
+
});
|
|
2570
|
+
if (!initBundleRes.res.ok) {
|
|
2571
|
+
const errorJson = initBundleRes.json;
|
|
2572
|
+
throw new DropgateProtocolError(errorJson?.error || `Bundle initialisation failed: ${initBundleRes.res.status}`, { details: initBundleRes.json || initBundleRes.text });
|
|
1427
2573
|
}
|
|
1428
|
-
|
|
2574
|
+
const bundleInitJson = initBundleRes.json;
|
|
2575
|
+
const bundleUploadId = bundleInitJson?.bundleUploadId;
|
|
2576
|
+
const fileUploadIds = bundleInitJson?.fileUploadIds;
|
|
2577
|
+
if (!bundleUploadId || !fileUploadIds || fileUploadIds.length !== files.length) {
|
|
2578
|
+
throw new DropgateProtocolError("Server did not return valid bundle upload IDs.");
|
|
2579
|
+
}
|
|
2580
|
+
currentUploadIds.push(...fileUploadIds);
|
|
2581
|
+
uploadState = "uploading";
|
|
2582
|
+
const fileResults = [];
|
|
2583
|
+
let cumulativeBytes = 0;
|
|
2584
|
+
for (let fi = 0; fi < files.length; fi++) {
|
|
2585
|
+
const file = files[fi];
|
|
2586
|
+
const uploadId = fileUploadIds[fi];
|
|
2587
|
+
const totalChunks = fileManifest[fi].totalChunks;
|
|
2588
|
+
const totalUploadSize = fileManifest[fi].totalSize;
|
|
2589
|
+
progress({
|
|
2590
|
+
phase: "file-start",
|
|
2591
|
+
text: `Uploading file ${fi + 1} of ${files.length}: ${filenames[fi]}`,
|
|
2592
|
+
percent: totalSizeBytes > 0 ? cumulativeBytes / totalSizeBytes * 100 : 0,
|
|
2593
|
+
processedBytes: cumulativeBytes,
|
|
2594
|
+
totalBytes: totalSizeBytes,
|
|
2595
|
+
fileIndex: fi,
|
|
2596
|
+
totalFiles: files.length,
|
|
2597
|
+
currentFileName: filenames[fi]
|
|
2598
|
+
});
|
|
2599
|
+
await this._uploadFileChunks({
|
|
2600
|
+
file,
|
|
2601
|
+
uploadId,
|
|
2602
|
+
cryptoKey,
|
|
2603
|
+
effectiveChunkSize,
|
|
2604
|
+
totalChunks,
|
|
2605
|
+
totalUploadSize,
|
|
2606
|
+
baseOffset: cumulativeBytes,
|
|
2607
|
+
totalBytesAllFiles: totalSizeBytes,
|
|
2608
|
+
progress,
|
|
2609
|
+
signal: effectiveSignal,
|
|
2610
|
+
baseUrl,
|
|
2611
|
+
retries,
|
|
2612
|
+
backoffMs: baseBackoffMs,
|
|
2613
|
+
maxBackoffMs,
|
|
2614
|
+
chunkTimeoutMs: timeouts.chunkMs ?? 6e4,
|
|
2615
|
+
fileIndex: fi,
|
|
2616
|
+
totalFiles: files.length,
|
|
2617
|
+
currentFileName: filenames[fi]
|
|
2618
|
+
});
|
|
2619
|
+
const completeRes = await fetchJson(this.fetchFn, `${baseUrl}/upload/complete`, {
|
|
2620
|
+
method: "POST",
|
|
2621
|
+
timeoutMs: timeouts.completeMs ?? 3e4,
|
|
2622
|
+
signal: effectiveSignal,
|
|
2623
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
2624
|
+
body: JSON.stringify({ uploadId })
|
|
2625
|
+
});
|
|
2626
|
+
if (!completeRes.res.ok) {
|
|
2627
|
+
const errorJson = completeRes.json;
|
|
2628
|
+
throw new DropgateProtocolError(errorJson?.error || `File ${fi + 1} finalisation failed.`, { details: completeRes.json || completeRes.text });
|
|
2629
|
+
}
|
|
2630
|
+
const fileId = completeRes.json?.id;
|
|
2631
|
+
if (!fileId) throw new DropgateProtocolError(`Server did not return a valid file id for file ${fi + 1}.`);
|
|
2632
|
+
fileResults.push({ fileId, name: filenames[fi], size: file.size });
|
|
2633
|
+
cumulativeBytes += file.size;
|
|
2634
|
+
progress({
|
|
2635
|
+
phase: "file-complete",
|
|
2636
|
+
text: `File ${fi + 1} of ${files.length} uploaded.`,
|
|
2637
|
+
percent: totalSizeBytes > 0 ? cumulativeBytes / totalSizeBytes * 100 : 0,
|
|
2638
|
+
processedBytes: cumulativeBytes,
|
|
2639
|
+
totalBytes: totalSizeBytes,
|
|
2640
|
+
fileIndex: fi,
|
|
2641
|
+
totalFiles: files.length,
|
|
2642
|
+
currentFileName: filenames[fi]
|
|
2643
|
+
});
|
|
2644
|
+
}
|
|
2645
|
+
progress({ phase: "complete", text: "Finalising bundle...", percent: 100, processedBytes: totalSizeBytes, totalBytes: totalSizeBytes });
|
|
2646
|
+
uploadState = "completing";
|
|
2647
|
+
let encryptedManifestB64;
|
|
2648
|
+
if (effectiveEncrypt && cryptoKey) {
|
|
2649
|
+
const manifest = JSON.stringify({
|
|
2650
|
+
files: fileResults.map((r) => ({
|
|
2651
|
+
fileId: r.fileId,
|
|
2652
|
+
name: r.name,
|
|
2653
|
+
sizeBytes: r.size
|
|
2654
|
+
}))
|
|
2655
|
+
});
|
|
2656
|
+
const manifestBytes = new TextEncoder().encode(manifest);
|
|
2657
|
+
const encryptedBlob = await encryptToBlob(this.cryptoObj, manifestBytes.buffer, cryptoKey);
|
|
2658
|
+
const encryptedBuffer = new Uint8Array(await encryptedBlob.arrayBuffer());
|
|
2659
|
+
encryptedManifestB64 = this.base64.encode(encryptedBuffer);
|
|
2660
|
+
}
|
|
2661
|
+
const completeBundleRes = await fetchJson(this.fetchFn, `${baseUrl}/upload/complete-bundle`, {
|
|
2662
|
+
method: "POST",
|
|
2663
|
+
timeoutMs: timeouts.completeMs ?? 3e4,
|
|
2664
|
+
signal: effectiveSignal,
|
|
2665
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
2666
|
+
body: JSON.stringify({
|
|
2667
|
+
bundleUploadId,
|
|
2668
|
+
...encryptedManifestB64 ? { encryptedManifest: encryptedManifestB64 } : {}
|
|
2669
|
+
})
|
|
2670
|
+
});
|
|
2671
|
+
if (!completeBundleRes.res.ok) {
|
|
2672
|
+
const errorJson = completeBundleRes.json;
|
|
2673
|
+
throw new DropgateProtocolError(errorJson?.error || "Bundle finalisation failed.", { details: completeBundleRes.json || completeBundleRes.text });
|
|
2674
|
+
}
|
|
2675
|
+
const bundleId = completeBundleRes.json?.bundleId;
|
|
2676
|
+
if (!bundleId) throw new DropgateProtocolError("Server did not return a valid bundle id.");
|
|
2677
|
+
let downloadUrl = `${baseUrl}/b/${bundleId}`;
|
|
2678
|
+
if (effectiveEncrypt && keyB64) downloadUrl += `#${keyB64}`;
|
|
2679
|
+
progress({ phase: "done", text: "Upload successful!", percent: 100, processedBytes: totalSizeBytes, totalBytes: totalSizeBytes });
|
|
2680
|
+
uploadState = "completed";
|
|
2681
|
+
return {
|
|
2682
|
+
downloadUrl,
|
|
2683
|
+
bundleId,
|
|
2684
|
+
baseUrl,
|
|
2685
|
+
files: fileResults,
|
|
2686
|
+
...effectiveEncrypt && keyB64 ? { keyB64 } : {}
|
|
2687
|
+
};
|
|
2688
|
+
} catch (err2) {
|
|
2689
|
+
if (err2 instanceof Error && (err2.name === "AbortError" || err2.message?.includes("abort"))) {
|
|
2690
|
+
uploadState = "cancelled";
|
|
2691
|
+
onCancel?.();
|
|
2692
|
+
} else {
|
|
2693
|
+
uploadState = "error";
|
|
2694
|
+
}
|
|
2695
|
+
throw err2;
|
|
1429
2696
|
}
|
|
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;
|
|
2697
|
+
})();
|
|
2698
|
+
const callCancelEndpoint = async (uploadId) => {
|
|
2699
|
+
try {
|
|
2700
|
+
await fetchJson(this.fetchFn, `${this.baseUrl}/upload/cancel`, {
|
|
2701
|
+
method: "POST",
|
|
2702
|
+
timeoutMs: 5e3,
|
|
2703
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
2704
|
+
body: JSON.stringify({ uploadId })
|
|
2705
|
+
});
|
|
2706
|
+
} catch {
|
|
1445
2707
|
}
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
2708
|
+
};
|
|
2709
|
+
return {
|
|
2710
|
+
result: uploadPromise,
|
|
2711
|
+
cancel: (reason) => {
|
|
2712
|
+
if (uploadState === "completed" || uploadState === "cancelled") return;
|
|
2713
|
+
uploadState = "cancelled";
|
|
2714
|
+
for (const id of currentUploadIds) {
|
|
2715
|
+
callCancelEndpoint(id).catch(() => {
|
|
2716
|
+
});
|
|
2717
|
+
}
|
|
2718
|
+
internalController?.abort(new DropgateAbortError(reason || "Upload cancelled by user."));
|
|
2719
|
+
},
|
|
2720
|
+
getStatus: () => uploadState
|
|
2721
|
+
};
|
|
2722
|
+
}
|
|
2723
|
+
/**
|
|
2724
|
+
* Upload a single file's chunks to the server. Used internally by uploadFiles().
|
|
2725
|
+
*/
|
|
2726
|
+
async _uploadFileChunks(params) {
|
|
2727
|
+
const {
|
|
2728
|
+
file,
|
|
2729
|
+
uploadId,
|
|
2730
|
+
cryptoKey,
|
|
2731
|
+
effectiveChunkSize,
|
|
2732
|
+
totalChunks,
|
|
2733
|
+
baseOffset,
|
|
2734
|
+
totalBytesAllFiles,
|
|
2735
|
+
progress,
|
|
2736
|
+
signal,
|
|
2737
|
+
baseUrl,
|
|
2738
|
+
retries,
|
|
2739
|
+
backoffMs,
|
|
2740
|
+
maxBackoffMs,
|
|
2741
|
+
chunkTimeoutMs,
|
|
2742
|
+
fileIndex,
|
|
2743
|
+
totalFiles,
|
|
2744
|
+
currentFileName
|
|
2745
|
+
} = params;
|
|
2746
|
+
for (let i = 0; i < totalChunks; i++) {
|
|
2747
|
+
if (signal?.aborted) {
|
|
2748
|
+
throw signal.reason || new DropgateAbortError();
|
|
1452
2749
|
}
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
2750
|
+
const start = i * effectiveChunkSize;
|
|
2751
|
+
const end = Math.min(start + effectiveChunkSize, file.size);
|
|
2752
|
+
const chunkSlice = file.slice(start, end);
|
|
2753
|
+
const processedBytes = baseOffset + start;
|
|
2754
|
+
const percent = totalBytesAllFiles > 0 ? processedBytes / totalBytesAllFiles * 100 : 0;
|
|
2755
|
+
progress({
|
|
2756
|
+
phase: "chunk",
|
|
2757
|
+
text: `Uploading chunk ${i + 1} of ${totalChunks}...`,
|
|
2758
|
+
percent,
|
|
2759
|
+
processedBytes,
|
|
2760
|
+
totalBytes: totalBytesAllFiles,
|
|
2761
|
+
chunkIndex: i,
|
|
2762
|
+
totalChunks,
|
|
2763
|
+
...fileIndex !== void 0 ? { fileIndex, totalFiles, currentFileName } : {}
|
|
2764
|
+
});
|
|
2765
|
+
const chunkBuffer = await chunkSlice.arrayBuffer();
|
|
2766
|
+
let uploadBlob;
|
|
2767
|
+
if (cryptoKey) {
|
|
2768
|
+
uploadBlob = await encryptToBlob(this.cryptoObj, chunkBuffer, cryptoKey);
|
|
2769
|
+
} else {
|
|
2770
|
+
uploadBlob = new Blob([chunkBuffer]);
|
|
1456
2771
|
}
|
|
1457
|
-
if (
|
|
1458
|
-
|
|
1459
|
-
|
|
2772
|
+
if (uploadBlob.size > effectiveChunkSize + 1024) {
|
|
2773
|
+
throw new DropgateValidationError("Chunk too large (client-side). Check chunk size settings.");
|
|
2774
|
+
}
|
|
2775
|
+
const toHash = await uploadBlob.arrayBuffer();
|
|
2776
|
+
const hashHex = await sha256Hex(this.cryptoObj, toHash);
|
|
2777
|
+
await this._attemptChunkUpload(
|
|
2778
|
+
`${baseUrl}/upload/chunk`,
|
|
2779
|
+
{ method: "POST", headers: { "Content-Type": "application/octet-stream", "X-Upload-ID": uploadId, "X-Chunk-Index": String(i), "X-Chunk-Hash": hashHex }, body: uploadBlob },
|
|
2780
|
+
{ retries, backoffMs, maxBackoffMs, timeoutMs: chunkTimeoutMs, signal, progress, chunkIndex: i, totalChunks, chunkSize: effectiveChunkSize, fileSizeBytes: totalBytesAllFiles }
|
|
2781
|
+
);
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
/**
|
|
2785
|
+
* Download one or more files from the server with optional decryption.
|
|
2786
|
+
*
|
|
2787
|
+
* For single files, use `fileId`. For bundles, use `bundleId`.
|
|
2788
|
+
* With `asZip: true` on bundles, streams a ZIP archive via `onData`.
|
|
2789
|
+
* Without `asZip`, delivers files individually via `onFileStart`/`onFileData`/`onFileEnd`.
|
|
2790
|
+
*
|
|
2791
|
+
* @param opts - Download options including file/bundle ID and optional key.
|
|
2792
|
+
* @returns Download result containing filename(s) and received bytes.
|
|
2793
|
+
*/
|
|
2794
|
+
async downloadFiles(opts) {
|
|
2795
|
+
const {
|
|
2796
|
+
fileId,
|
|
2797
|
+
bundleId,
|
|
2798
|
+
keyB64,
|
|
2799
|
+
asZip,
|
|
2800
|
+
zipFilename: _zipFilename,
|
|
2801
|
+
onProgress,
|
|
2802
|
+
onData,
|
|
2803
|
+
onFileStart,
|
|
2804
|
+
onFileData,
|
|
2805
|
+
onFileEnd,
|
|
2806
|
+
signal,
|
|
2807
|
+
timeoutMs = 6e4
|
|
2808
|
+
} = opts;
|
|
2809
|
+
const progress = (evt) => {
|
|
2810
|
+
try {
|
|
2811
|
+
if (onProgress) onProgress(evt);
|
|
2812
|
+
} catch {
|
|
1460
2813
|
}
|
|
1461
|
-
|
|
1462
|
-
|
|
2814
|
+
};
|
|
2815
|
+
if (!fileId && !bundleId) {
|
|
2816
|
+
throw new DropgateValidationError("Either fileId or bundleId is required.");
|
|
2817
|
+
}
|
|
2818
|
+
progress({ phase: "server-info", text: "Checking server...", processedBytes: 0, totalBytes: 0, percent: 0 });
|
|
2819
|
+
const compat = await this.connect({ timeoutMs, signal });
|
|
2820
|
+
const { baseUrl } = compat;
|
|
2821
|
+
progress({ phase: "server-compat", text: compat.message, processedBytes: 0, totalBytes: 0, percent: 0 });
|
|
2822
|
+
if (!compat.compatible) throw new DropgateValidationError(compat.message);
|
|
2823
|
+
if (fileId) {
|
|
2824
|
+
return this._downloadSingleFile({ fileId, keyB64, onProgress, onData, signal, timeoutMs, baseUrl, compat });
|
|
2825
|
+
}
|
|
2826
|
+
progress({ phase: "metadata", text: "Fetching bundle info...", processedBytes: 0, totalBytes: 0, percent: 0 });
|
|
2827
|
+
let bundleMeta;
|
|
2828
|
+
try {
|
|
2829
|
+
bundleMeta = await this.getBundleMetadata(bundleId, keyB64, { timeoutMs, signal });
|
|
2830
|
+
} catch (err2) {
|
|
2831
|
+
if (err2 instanceof DropgateError) throw err2;
|
|
2832
|
+
if (err2 instanceof Error && err2.name === "AbortError") throw new DropgateAbortError("Download cancelled.");
|
|
2833
|
+
throw new DropgateNetworkError("Could not fetch bundle metadata.", { cause: err2 });
|
|
2834
|
+
}
|
|
2835
|
+
const isEncrypted = Boolean(bundleMeta.isEncrypted);
|
|
2836
|
+
const totalBytes = bundleMeta.totalSizeBytes || 0;
|
|
2837
|
+
let cryptoKey;
|
|
2838
|
+
const filenames = [];
|
|
2839
|
+
if (isEncrypted) {
|
|
2840
|
+
if (!keyB64) throw new DropgateValidationError("Decryption key is required for encrypted bundles.");
|
|
2841
|
+
if (!this.cryptoObj?.subtle) throw new DropgateValidationError("Web Crypto API not available for decryption.");
|
|
2842
|
+
try {
|
|
2843
|
+
cryptoKey = await importKeyFromBase64(this.cryptoObj, keyB64, this.base64);
|
|
2844
|
+
if (bundleMeta.sealed && bundleMeta.encryptedManifest) {
|
|
2845
|
+
const encryptedBytes = this.base64.decode(bundleMeta.encryptedManifest);
|
|
2846
|
+
const decryptedBuffer = await decryptChunk(this.cryptoObj, encryptedBytes, cryptoKey);
|
|
2847
|
+
const manifestJson = new TextDecoder().decode(decryptedBuffer);
|
|
2848
|
+
const manifest = JSON.parse(manifestJson);
|
|
2849
|
+
bundleMeta.files = manifest.files.map((f) => ({
|
|
2850
|
+
fileId: f.fileId,
|
|
2851
|
+
sizeBytes: f.sizeBytes,
|
|
2852
|
+
filename: f.name
|
|
2853
|
+
}));
|
|
2854
|
+
bundleMeta.fileCount = bundleMeta.files.length;
|
|
2855
|
+
for (const f of bundleMeta.files) {
|
|
2856
|
+
filenames.push(f.filename || "file");
|
|
2857
|
+
}
|
|
2858
|
+
} else {
|
|
2859
|
+
for (const f of bundleMeta.files) {
|
|
2860
|
+
filenames.push(await decryptFilenameFromBase64(this.cryptoObj, f.encryptedFilename, cryptoKey, this.base64));
|
|
2861
|
+
}
|
|
2862
|
+
}
|
|
2863
|
+
} catch (err2) {
|
|
2864
|
+
throw new DropgateError("Failed to decrypt bundle manifest.", { code: "DECRYPT_MANIFEST_FAILED", cause: err2 });
|
|
1463
2865
|
}
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
2866
|
+
} else {
|
|
2867
|
+
for (const f of bundleMeta.files) {
|
|
2868
|
+
filenames.push(f.filename || "file");
|
|
1467
2869
|
}
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
2870
|
+
}
|
|
2871
|
+
let totalReceivedBytes = 0;
|
|
2872
|
+
if (asZip && onData) {
|
|
2873
|
+
const zipWriter = new StreamingZipWriter(onData);
|
|
2874
|
+
for (let fi = 0; fi < bundleMeta.files.length; fi++) {
|
|
2875
|
+
const fileMeta = bundleMeta.files[fi];
|
|
2876
|
+
const name = filenames[fi];
|
|
2877
|
+
progress({
|
|
2878
|
+
phase: "zipping",
|
|
2879
|
+
text: `Downloading ${name}...`,
|
|
2880
|
+
percent: totalBytes > 0 ? totalReceivedBytes / totalBytes * 100 : 0,
|
|
2881
|
+
processedBytes: totalReceivedBytes,
|
|
2882
|
+
totalBytes,
|
|
2883
|
+
fileIndex: fi,
|
|
2884
|
+
totalFiles: bundleMeta.files.length,
|
|
2885
|
+
currentFileName: name
|
|
2886
|
+
});
|
|
2887
|
+
zipWriter.startFile(name);
|
|
2888
|
+
const baseReceivedBytes = totalReceivedBytes;
|
|
2889
|
+
const bytesReceived = await this._streamFileIntoCallback(
|
|
2890
|
+
baseUrl,
|
|
2891
|
+
fileMeta.fileId,
|
|
2892
|
+
isEncrypted,
|
|
2893
|
+
cryptoKey,
|
|
2894
|
+
compat,
|
|
2895
|
+
signal,
|
|
2896
|
+
timeoutMs,
|
|
2897
|
+
(chunk) => {
|
|
2898
|
+
zipWriter.writeChunk(chunk);
|
|
2899
|
+
},
|
|
2900
|
+
(fileBytes) => {
|
|
2901
|
+
const current = baseReceivedBytes + fileBytes;
|
|
2902
|
+
progress({
|
|
2903
|
+
phase: "zipping",
|
|
2904
|
+
text: `Downloading ${name}...`,
|
|
2905
|
+
percent: totalBytes > 0 ? current / totalBytes * 100 : 0,
|
|
2906
|
+
processedBytes: current,
|
|
2907
|
+
totalBytes,
|
|
2908
|
+
fileIndex: fi,
|
|
2909
|
+
totalFiles: bundleMeta.files.length,
|
|
2910
|
+
currentFileName: name
|
|
2911
|
+
});
|
|
2912
|
+
}
|
|
2913
|
+
);
|
|
2914
|
+
zipWriter.endFile();
|
|
2915
|
+
totalReceivedBytes += bytesReceived;
|
|
1473
2916
|
}
|
|
1474
|
-
|
|
1475
|
-
conn.on("open", async () => {
|
|
2917
|
+
await zipWriter.finalize();
|
|
1476
2918
|
try {
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
size: file.size,
|
|
1483
|
-
mime: file.type || "application/octet-stream"
|
|
2919
|
+
await fetchJson(this.fetchFn, `${baseUrl}/api/bundle/${bundleId}/downloaded`, {
|
|
2920
|
+
method: "POST",
|
|
2921
|
+
timeoutMs: 5e3,
|
|
2922
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
2923
|
+
body: "{}"
|
|
1484
2924
|
});
|
|
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
|
-
}
|
|
2925
|
+
} catch {
|
|
2926
|
+
}
|
|
2927
|
+
progress({ phase: "complete", text: "Download complete!", percent: 100, processedBytes: totalReceivedBytes, totalBytes });
|
|
2928
|
+
return { filenames, receivedBytes: totalReceivedBytes, wasEncrypted: isEncrypted };
|
|
2929
|
+
} else {
|
|
2930
|
+
const dataCallback = onFileData || onData;
|
|
2931
|
+
for (let fi = 0; fi < bundleMeta.files.length; fi++) {
|
|
2932
|
+
const fileMeta = bundleMeta.files[fi];
|
|
2933
|
+
const name = filenames[fi];
|
|
2934
|
+
progress({
|
|
2935
|
+
phase: "downloading",
|
|
2936
|
+
text: `Downloading ${name}...`,
|
|
2937
|
+
percent: totalBytes > 0 ? totalReceivedBytes / totalBytes * 100 : 0,
|
|
2938
|
+
processedBytes: totalReceivedBytes,
|
|
2939
|
+
totalBytes,
|
|
2940
|
+
fileIndex: fi,
|
|
2941
|
+
totalFiles: bundleMeta.files.length,
|
|
2942
|
+
currentFileName: name
|
|
2943
|
+
});
|
|
2944
|
+
onFileStart?.({ name, size: fileMeta.sizeBytes, index: fi });
|
|
2945
|
+
const baseReceivedBytes = totalReceivedBytes;
|
|
2946
|
+
const bytesReceived = await this._streamFileIntoCallback(
|
|
2947
|
+
baseUrl,
|
|
2948
|
+
fileMeta.fileId,
|
|
2949
|
+
isEncrypted,
|
|
2950
|
+
cryptoKey,
|
|
2951
|
+
compat,
|
|
2952
|
+
signal,
|
|
2953
|
+
timeoutMs,
|
|
2954
|
+
dataCallback ? (chunk) => dataCallback(chunk) : void 0,
|
|
2955
|
+
(fileBytes) => {
|
|
2956
|
+
const current = baseReceivedBytes + fileBytes;
|
|
2957
|
+
progress({
|
|
2958
|
+
phase: "downloading",
|
|
2959
|
+
text: `Downloading ${name}...`,
|
|
2960
|
+
percent: totalBytes > 0 ? current / totalBytes * 100 : 0,
|
|
2961
|
+
processedBytes: current,
|
|
2962
|
+
totalBytes,
|
|
2963
|
+
fileIndex: fi,
|
|
2964
|
+
totalFiles: bundleMeta.files.length,
|
|
2965
|
+
currentFileName: name
|
|
2966
|
+
});
|
|
1530
2967
|
}
|
|
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);
|
|
2968
|
+
);
|
|
2969
|
+
onFileEnd?.({ name, index: fi });
|
|
2970
|
+
totalReceivedBytes += bytesReceived;
|
|
1554
2971
|
}
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
2972
|
+
progress({ phase: "complete", text: "Download complete!", percent: 100, processedBytes: totalReceivedBytes, totalBytes });
|
|
2973
|
+
return { filenames, receivedBytes: totalReceivedBytes, wasEncrypted: isEncrypted };
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
/**
|
|
2977
|
+
* Download a single file, handling encryption/decryption internally.
|
|
2978
|
+
* Preserves the original downloadFile() behavior.
|
|
2979
|
+
*/
|
|
2980
|
+
async _downloadSingleFile(params) {
|
|
2981
|
+
const { fileId, keyB64, onProgress, onData, signal, timeoutMs, baseUrl, compat } = params;
|
|
2982
|
+
const progress = (evt) => {
|
|
2983
|
+
try {
|
|
2984
|
+
if (onProgress) onProgress(evt);
|
|
2985
|
+
} catch {
|
|
1563
2986
|
}
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
2987
|
+
};
|
|
2988
|
+
progress({ phase: "metadata", text: "Fetching file info...", processedBytes: 0, totalBytes: 0, percent: 0 });
|
|
2989
|
+
let metadata;
|
|
2990
|
+
try {
|
|
2991
|
+
metadata = await this.getFileMetadata(fileId, { timeoutMs, signal });
|
|
2992
|
+
} catch (err2) {
|
|
2993
|
+
if (err2 instanceof DropgateError) throw err2;
|
|
2994
|
+
if (err2 instanceof Error && err2.name === "AbortError") throw new DropgateAbortError("Download cancelled.");
|
|
2995
|
+
throw new DropgateNetworkError("Could not fetch file metadata.", { cause: err2 });
|
|
2996
|
+
}
|
|
2997
|
+
const isEncrypted = Boolean(metadata.isEncrypted);
|
|
2998
|
+
const totalBytes = metadata.sizeBytes || 0;
|
|
2999
|
+
if (!onData && totalBytes > MAX_IN_MEMORY_DOWNLOAD_BYTES) {
|
|
3000
|
+
const sizeMB = Math.round(totalBytes / (1024 * 1024));
|
|
3001
|
+
const limitMB = Math.round(MAX_IN_MEMORY_DOWNLOAD_BYTES / (1024 * 1024));
|
|
3002
|
+
throw new DropgateValidationError(
|
|
3003
|
+
`File is too large (${sizeMB}MB) to download without streaming. Provide an onData callback to stream files larger than ${limitMB}MB.`
|
|
3004
|
+
);
|
|
3005
|
+
}
|
|
3006
|
+
let filename;
|
|
3007
|
+
let cryptoKey;
|
|
3008
|
+
if (isEncrypted) {
|
|
3009
|
+
if (!keyB64) throw new DropgateValidationError("Decryption key is required for encrypted files.");
|
|
3010
|
+
if (!this.cryptoObj?.subtle) throw new DropgateValidationError("Web Crypto API not available for decryption.");
|
|
3011
|
+
progress({ phase: "decrypting", text: "Preparing decryption...", processedBytes: 0, totalBytes: 0, percent: 0 });
|
|
3012
|
+
try {
|
|
3013
|
+
cryptoKey = await importKeyFromBase64(this.cryptoObj, keyB64, this.base64);
|
|
3014
|
+
filename = await decryptFilenameFromBase64(this.cryptoObj, metadata.encryptedFilename, cryptoKey, this.base64);
|
|
3015
|
+
} catch (err2) {
|
|
3016
|
+
throw new DropgateError("Failed to decrypt filename.", { code: "DECRYPT_FILENAME_FAILED", cause: err2 });
|
|
1573
3017
|
}
|
|
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;
|
|
3018
|
+
} else {
|
|
3019
|
+
filename = metadata.filename || "file";
|
|
1586
3020
|
}
|
|
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."
|
|
3021
|
+
progress({ phase: "downloading", text: "Starting download...", percent: 0, processedBytes: 0, totalBytes });
|
|
3022
|
+
const dataChunks = [];
|
|
3023
|
+
const collectData = !onData;
|
|
3024
|
+
const receivedBytes = await this._streamFileIntoCallback(
|
|
3025
|
+
baseUrl,
|
|
3026
|
+
fileId,
|
|
3027
|
+
isEncrypted,
|
|
3028
|
+
cryptoKey,
|
|
3029
|
+
compat,
|
|
3030
|
+
signal,
|
|
3031
|
+
timeoutMs,
|
|
3032
|
+
async (chunk) => {
|
|
3033
|
+
if (collectData) {
|
|
3034
|
+
dataChunks.push(chunk);
|
|
3035
|
+
} else {
|
|
3036
|
+
await onData(chunk);
|
|
3037
|
+
}
|
|
3038
|
+
},
|
|
3039
|
+
(bytes) => {
|
|
3040
|
+
progress({
|
|
3041
|
+
phase: "downloading",
|
|
3042
|
+
text: "Downloading...",
|
|
3043
|
+
percent: totalBytes > 0 ? bytes / totalBytes * 100 : 0,
|
|
3044
|
+
processedBytes: bytes,
|
|
3045
|
+
totalBytes
|
|
3046
|
+
});
|
|
3047
|
+
}
|
|
1618
3048
|
);
|
|
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);
|
|
3049
|
+
progress({ phase: "complete", text: "Download complete!", percent: 100, processedBytes: receivedBytes, totalBytes });
|
|
3050
|
+
let data;
|
|
3051
|
+
if (collectData && dataChunks.length > 0) {
|
|
3052
|
+
const totalLength = dataChunks.reduce((sum, c) => sum + c.length, 0);
|
|
3053
|
+
data = new Uint8Array(totalLength);
|
|
3054
|
+
let offset = 0;
|
|
3055
|
+
for (const c of dataChunks) {
|
|
3056
|
+
data.set(c, offset);
|
|
3057
|
+
offset += c.length;
|
|
3058
|
+
}
|
|
1653
3059
|
}
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
3060
|
+
return {
|
|
3061
|
+
filename,
|
|
3062
|
+
receivedBytes,
|
|
3063
|
+
wasEncrypted: isEncrypted,
|
|
3064
|
+
...data ? { data } : {}
|
|
3065
|
+
};
|
|
3066
|
+
}
|
|
3067
|
+
/**
|
|
3068
|
+
* Stream a single file's content into a callback, handling decryption if needed.
|
|
3069
|
+
* Returns total bytes received from the network (encrypted size).
|
|
3070
|
+
*/
|
|
3071
|
+
async _streamFileIntoCallback(baseUrl, fileId, isEncrypted, cryptoKey, compat, signal, timeoutMs, onChunk, onBytesReceived) {
|
|
3072
|
+
const { signal: downloadSignal, cleanup: downloadCleanup } = makeAbortSignal(signal, timeoutMs);
|
|
3073
|
+
let receivedBytes = 0;
|
|
3074
|
+
try {
|
|
3075
|
+
const downloadRes = await this.fetchFn(`${baseUrl}/api/file/${fileId}`, {
|
|
3076
|
+
method: "GET",
|
|
3077
|
+
signal: downloadSignal
|
|
3078
|
+
});
|
|
3079
|
+
if (!downloadRes.ok) throw new DropgateProtocolError(`Download failed (status ${downloadRes.status}).`);
|
|
3080
|
+
if (!downloadRes.body) throw new DropgateProtocolError("Streaming response not available.");
|
|
3081
|
+
const reader = downloadRes.body.getReader();
|
|
3082
|
+
if (isEncrypted && cryptoKey) {
|
|
3083
|
+
const downloadChunkSize = Number.isFinite(compat.serverInfo?.capabilities?.upload?.chunkSize) && compat.serverInfo.capabilities.upload.chunkSize > 0 ? compat.serverInfo.capabilities.upload.chunkSize : this.chunkSize;
|
|
3084
|
+
const ENCRYPTED_CHUNK_SIZE = downloadChunkSize + ENCRYPTION_OVERHEAD_PER_CHUNK;
|
|
3085
|
+
const pendingChunks = [];
|
|
3086
|
+
let pendingLength = 0;
|
|
3087
|
+
const flushPending = () => {
|
|
3088
|
+
if (pendingChunks.length === 0) return new Uint8Array(0);
|
|
3089
|
+
if (pendingChunks.length === 1) {
|
|
3090
|
+
const result2 = pendingChunks[0];
|
|
3091
|
+
pendingChunks.length = 0;
|
|
3092
|
+
pendingLength = 0;
|
|
3093
|
+
return result2;
|
|
3094
|
+
}
|
|
3095
|
+
const result = new Uint8Array(pendingLength);
|
|
3096
|
+
let offset = 0;
|
|
3097
|
+
for (const chunk of pendingChunks) {
|
|
3098
|
+
result.set(chunk, offset);
|
|
3099
|
+
offset += chunk.length;
|
|
3100
|
+
}
|
|
3101
|
+
pendingChunks.length = 0;
|
|
3102
|
+
pendingLength = 0;
|
|
3103
|
+
return result;
|
|
3104
|
+
};
|
|
3105
|
+
while (true) {
|
|
3106
|
+
if (signal?.aborted) throw new DropgateAbortError("Download cancelled.");
|
|
3107
|
+
const { done, value } = await reader.read();
|
|
3108
|
+
if (done) break;
|
|
3109
|
+
pendingChunks.push(value);
|
|
3110
|
+
pendingLength += value.length;
|
|
3111
|
+
receivedBytes += value.length;
|
|
3112
|
+
if (onBytesReceived) onBytesReceived(receivedBytes);
|
|
3113
|
+
while (pendingLength >= ENCRYPTED_CHUNK_SIZE) {
|
|
3114
|
+
const buffer = flushPending();
|
|
3115
|
+
const encryptedChunk = buffer.subarray(0, ENCRYPTED_CHUNK_SIZE);
|
|
3116
|
+
if (buffer.length > ENCRYPTED_CHUNK_SIZE) {
|
|
3117
|
+
pendingChunks.push(buffer.subarray(ENCRYPTED_CHUNK_SIZE));
|
|
3118
|
+
pendingLength = buffer.length - ENCRYPTED_CHUNK_SIZE;
|
|
3119
|
+
}
|
|
3120
|
+
const decryptedBuffer = await decryptChunk(this.cryptoObj, encryptedChunk, cryptoKey);
|
|
3121
|
+
if (onChunk) await onChunk(new Uint8Array(decryptedBuffer));
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
if (pendingLength > 0) {
|
|
3125
|
+
const buffer = flushPending();
|
|
3126
|
+
const decryptedBuffer = await decryptChunk(this.cryptoObj, buffer, cryptoKey);
|
|
3127
|
+
if (onChunk) await onChunk(new Uint8Array(decryptedBuffer));
|
|
3128
|
+
}
|
|
3129
|
+
} else {
|
|
3130
|
+
while (true) {
|
|
3131
|
+
if (signal?.aborted) throw new DropgateAbortError("Download cancelled.");
|
|
3132
|
+
const { done, value } = await reader.read();
|
|
3133
|
+
if (done) break;
|
|
3134
|
+
receivedBytes += value.length;
|
|
3135
|
+
if (onBytesReceived) onBytesReceived(receivedBytes);
|
|
3136
|
+
if (onChunk) await onChunk(value);
|
|
3137
|
+
}
|
|
1657
3138
|
}
|
|
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);
|
|
3139
|
+
} catch (err2) {
|
|
3140
|
+
if (err2 instanceof DropgateError) throw err2;
|
|
3141
|
+
if (err2 instanceof Error && err2.name === "AbortError") throw new DropgateAbortError("Download cancelled.");
|
|
3142
|
+
throw new DropgateNetworkError("Download failed.", { cause: err2 });
|
|
3143
|
+
} finally {
|
|
3144
|
+
downloadCleanup();
|
|
1682
3145
|
}
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
3146
|
+
return receivedBytes;
|
|
3147
|
+
}
|
|
3148
|
+
/**
|
|
3149
|
+
* Start a P2P send session. Connects to the signalling server and waits for a receiver.
|
|
3150
|
+
*
|
|
3151
|
+
* Server info, peerjsPath, iceServers, and cryptoObj are provided automatically
|
|
3152
|
+
* from the client's cached server info and configuration.
|
|
3153
|
+
*
|
|
3154
|
+
* @param opts - P2P send options (file, Peer constructor, callbacks, tuning).
|
|
3155
|
+
* @returns P2P send session with control methods.
|
|
3156
|
+
* @throws {DropgateValidationError} If P2P is not enabled on the server.
|
|
3157
|
+
* @throws {DropgateNetworkError} If the signalling server cannot be reached.
|
|
3158
|
+
*/
|
|
3159
|
+
async p2pSend(opts) {
|
|
3160
|
+
const compat = await this.connect();
|
|
3161
|
+
if (!compat.compatible) {
|
|
3162
|
+
throw new DropgateValidationError(compat.message);
|
|
1686
3163
|
}
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
} catch {
|
|
3164
|
+
const { serverInfo } = compat;
|
|
3165
|
+
const p2pCaps = serverInfo?.capabilities?.p2p;
|
|
3166
|
+
if (!p2pCaps?.enabled) {
|
|
3167
|
+
throw new DropgateValidationError("Direct transfer is disabled on this server.");
|
|
1692
3168
|
}
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
3169
|
+
const { host, port, secure } = this.serverTarget;
|
|
3170
|
+
const { path: peerjsPath, iceServers } = resolvePeerConfig({}, p2pCaps);
|
|
3171
|
+
return startP2PSend({
|
|
3172
|
+
...opts,
|
|
3173
|
+
host,
|
|
3174
|
+
port,
|
|
3175
|
+
secure,
|
|
3176
|
+
peerjsPath,
|
|
3177
|
+
iceServers,
|
|
3178
|
+
serverInfo,
|
|
3179
|
+
cryptoObj: this.cryptoObj
|
|
3180
|
+
});
|
|
1697
3181
|
}
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
3182
|
+
/**
|
|
3183
|
+
* Start a P2P receive session. Connects to a sender via their sharing code.
|
|
3184
|
+
*
|
|
3185
|
+
* Server info, peerjsPath, and iceServers are provided automatically
|
|
3186
|
+
* from the client's cached server info.
|
|
3187
|
+
*
|
|
3188
|
+
* @param opts - P2P receive options (code, Peer constructor, callbacks, tuning).
|
|
3189
|
+
* @returns P2P receive session with control methods.
|
|
3190
|
+
* @throws {DropgateValidationError} If P2P is not enabled on the server.
|
|
3191
|
+
* @throws {DropgateNetworkError} If the signalling server cannot be reached.
|
|
3192
|
+
*/
|
|
3193
|
+
async p2pReceive(opts) {
|
|
3194
|
+
const compat = await this.connect();
|
|
3195
|
+
if (!compat.compatible) {
|
|
3196
|
+
throw new DropgateValidationError(compat.message);
|
|
1707
3197
|
}
|
|
1708
|
-
|
|
1709
|
-
|
|
3198
|
+
const { serverInfo } = compat;
|
|
3199
|
+
const p2pCaps = serverInfo?.capabilities?.p2p;
|
|
3200
|
+
if (!p2pCaps?.enabled) {
|
|
3201
|
+
throw new DropgateValidationError("Direct transfer is disabled on this server.");
|
|
1710
3202
|
}
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
state = "negotiating";
|
|
1722
|
-
onStatus?.({ phase: "connected", message: "Waiting for file details..." });
|
|
3203
|
+
const { host, port, secure } = this.serverTarget;
|
|
3204
|
+
const { path: peerjsPath, iceServers } = resolvePeerConfig({}, p2pCaps);
|
|
3205
|
+
return startP2PReceive({
|
|
3206
|
+
...opts,
|
|
3207
|
+
host,
|
|
3208
|
+
port,
|
|
3209
|
+
secure,
|
|
3210
|
+
peerjsPath,
|
|
3211
|
+
iceServers,
|
|
3212
|
+
serverInfo
|
|
1723
3213
|
});
|
|
1724
|
-
|
|
3214
|
+
}
|
|
3215
|
+
async _attemptChunkUpload(url, fetchOptions, opts) {
|
|
3216
|
+
const {
|
|
3217
|
+
retries,
|
|
3218
|
+
backoffMs,
|
|
3219
|
+
maxBackoffMs,
|
|
3220
|
+
timeoutMs,
|
|
3221
|
+
signal,
|
|
3222
|
+
progress,
|
|
3223
|
+
chunkIndex,
|
|
3224
|
+
totalChunks,
|
|
3225
|
+
chunkSize,
|
|
3226
|
+
fileSizeBytes
|
|
3227
|
+
} = opts;
|
|
3228
|
+
let attemptsLeft = retries;
|
|
3229
|
+
let currentBackoff = backoffMs;
|
|
3230
|
+
const maxRetries = retries;
|
|
3231
|
+
while (true) {
|
|
3232
|
+
if (signal?.aborted) {
|
|
3233
|
+
throw signal.reason || new DropgateAbortError();
|
|
3234
|
+
}
|
|
3235
|
+
const { signal: s, cleanup } = makeAbortSignal(signal, timeoutMs);
|
|
1725
3236
|
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;
|
|
3237
|
+
const res = await this.fetchFn(url, { ...fetchOptions, signal: s });
|
|
3238
|
+
if (res.ok) return;
|
|
3239
|
+
const text = await res.text().catch(() => "");
|
|
3240
|
+
const err2 = new DropgateProtocolError(
|
|
3241
|
+
`Chunk ${chunkIndex + 1} failed (HTTP ${res.status}).`,
|
|
3242
|
+
{
|
|
3243
|
+
details: { status: res.status, bodySnippet: text.slice(0, 120) }
|
|
1798
3244
|
}
|
|
1799
|
-
|
|
3245
|
+
);
|
|
3246
|
+
throw err2;
|
|
3247
|
+
} catch (err2) {
|
|
3248
|
+
cleanup();
|
|
3249
|
+
if (err2 instanceof Error && (err2.name === "AbortError" || err2.code === "ABORT_ERR")) {
|
|
3250
|
+
throw err2;
|
|
1800
3251
|
}
|
|
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;
|
|
3252
|
+
if (signal?.aborted) {
|
|
3253
|
+
throw signal.reason || new DropgateAbortError();
|
|
1812
3254
|
}
|
|
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
|
-
|
|
3255
|
+
if (attemptsLeft <= 0) {
|
|
3256
|
+
throw err2 instanceof DropgateError ? err2 : new DropgateNetworkError("Chunk upload failed.", { cause: err2 });
|
|
3257
|
+
}
|
|
3258
|
+
const attemptNumber = maxRetries - attemptsLeft + 1;
|
|
3259
|
+
const processedBytes = chunkIndex * chunkSize;
|
|
3260
|
+
const percent = chunkIndex / totalChunks * 100;
|
|
3261
|
+
let remaining = currentBackoff;
|
|
3262
|
+
const tick = 100;
|
|
3263
|
+
while (remaining > 0) {
|
|
3264
|
+
const secondsLeft = (remaining / 1e3).toFixed(1);
|
|
3265
|
+
progress({
|
|
3266
|
+
phase: "retry-wait",
|
|
3267
|
+
text: `Chunk upload failed. Retrying in ${secondsLeft}s... (${attemptNumber}/${maxRetries})`,
|
|
3268
|
+
percent,
|
|
3269
|
+
processedBytes,
|
|
3270
|
+
totalBytes: fileSizeBytes,
|
|
3271
|
+
chunkIndex,
|
|
3272
|
+
totalChunks
|
|
3273
|
+
});
|
|
3274
|
+
await sleep(Math.min(tick, remaining), signal);
|
|
3275
|
+
remaining -= tick;
|
|
3276
|
+
}
|
|
3277
|
+
progress({
|
|
3278
|
+
phase: "retry",
|
|
3279
|
+
text: `Chunk upload failed. Retrying now... (${attemptNumber}/${maxRetries})`,
|
|
3280
|
+
percent,
|
|
3281
|
+
processedBytes,
|
|
3282
|
+
totalBytes: fileSizeBytes,
|
|
3283
|
+
chunkIndex,
|
|
3284
|
+
totalChunks
|
|
1838
3285
|
});
|
|
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";
|
|
3286
|
+
attemptsLeft -= 1;
|
|
3287
|
+
currentBackoff = Math.min(currentBackoff * 2, maxBackoffMs);
|
|
3288
|
+
continue;
|
|
3289
|
+
} finally {
|
|
1854
3290
|
cleanup();
|
|
1855
|
-
onDisconnect?.();
|
|
1856
|
-
} else {
|
|
1857
|
-
safeError(new DropgateNetworkError("Sender disconnected before file details were received."));
|
|
1858
3291
|
}
|
|
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
|
-
});
|
|
3292
|
+
}
|
|
3293
|
+
}
|
|
3294
|
+
};
|
|
1917
3295
|
//# sourceMappingURL=index.cjs.map
|