@telecrypt-io/storage 0.5.28 → 0.5.30
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 +17 -7
- package/dist/TeleCryptIOStorage.d.ts +8 -4
- package/dist/TeleCryptIOStorage.js +244 -183
- package/dist/core/errors.d.ts +9 -9
- package/dist/core/errors.js +18 -18
- package/dist/core/http.d.ts +20 -14
- package/dist/core/http.js +165 -91
- package/dist/core/oidc.d.ts +15 -0
- package/dist/core/oidc.js +533 -215
- package/dist/core/operations.js +151 -194
- package/dist/core/poll.d.ts +3 -0
- package/dist/core/poll.js +7 -1
- package/package.json +1 -1
|
@@ -5,7 +5,8 @@ import { encryptAttachment, decryptAttachment } from "matrix-encrypt-attachment"
|
|
|
5
5
|
import { decodeRecoveryKey } from "matrix-js-sdk/lib/crypto-api/recovery-key.js";
|
|
6
6
|
import { FileTooLargeError, RecoveryAlreadyConfiguredError, RecoveryRestoreError, RecoverySetupAmbiguousError, RecoverySetupError, RecoveryRestoreAmbiguousError, MutationOutcomeUnknownError, RoomCreationAmbiguousError, RoomCleanupIncompleteError, StorageError, UndecryptableFileError, } from "./core/errors.js";
|
|
7
7
|
import { MAX_MEDIA_FILE_BYTES, MAX_MATRIX_IDENTIFIER_LENGTH, validateCanonicalMatrixUserId, validateMatrixDeviceId, validateMatrixEventId, validateMatrixRoomId, } from "./core/constants.js";
|
|
8
|
-
import { raceWithAbort,
|
|
8
|
+
import { cancelResponseBody, raceWithAbort, readMediaResponseBody, readResponseBody, ResponseBodyReadError, } from "./core/http.js";
|
|
9
|
+
import { sanitizeDiagnosticText } from "./core/oidc.js";
|
|
9
10
|
import { validateName } from "./core/validation.js";
|
|
10
11
|
import { isTreeDeleted } from "./deletion-markers.js";
|
|
11
12
|
const recoveryMutationQueues = new WeakMap();
|
|
@@ -18,11 +19,6 @@ const RECOVERY_CRYPTO_TIMEOUT_MS = 60000;
|
|
|
18
19
|
const MAX_MEDIA_REDIRECTS = 5;
|
|
19
20
|
const MAX_MATRIX_TOKEN_LENGTH = 8192;
|
|
20
21
|
const MAX_MIMETYPE_LENGTH = 255;
|
|
21
|
-
const MAX_MATRIX_STATE_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
22
|
-
const MAX_MATRIX_RESPONSE_BYTES = 16 * 1024 * 1024;
|
|
23
|
-
const MAX_MATRIX_STATE_EVENTS = 10000;
|
|
24
|
-
const MAX_MATRIX_MEMBERS = 10000;
|
|
25
|
-
const MAX_MATRIX_JOINED_ROOMS = 4096;
|
|
26
22
|
function cloneCryptoCallbacks(callbacks) {
|
|
27
23
|
// MatrixClient retains this object for the lifetime of the client. Keep the
|
|
28
24
|
// caller's object immutable: recovery temporarily installs key callbacks on
|
|
@@ -64,26 +60,21 @@ export function withMatrixMutationAbort(_client, operation, signal, operationNam
|
|
|
64
60
|
pending = operation();
|
|
65
61
|
}
|
|
66
62
|
catch (error) {
|
|
67
|
-
finish(() => reject(cancelled ? new MutationOutcomeUnknownError(operationName) : error));
|
|
63
|
+
finish(() => reject(cancelled ? new MutationOutcomeUnknownError(operationName, { cause: error }) : error));
|
|
68
64
|
return;
|
|
69
65
|
}
|
|
70
|
-
pending.then((value) => finish(() => (cancelled ? reject(new MutationOutcomeUnknownError(operationName)) : resolve(value))), (error) => finish(() => reject(cancelled ? new MutationOutcomeUnknownError(operationName) : error)));
|
|
66
|
+
pending.then((value) => finish(() => (cancelled ? reject(new MutationOutcomeUnknownError(operationName)) : resolve(value))), (error) => finish(() => reject(cancelled ? new MutationOutcomeUnknownError(operationName, { cause: error }) : error)));
|
|
71
67
|
});
|
|
72
68
|
}
|
|
73
|
-
async function
|
|
74
|
-
const body = await
|
|
69
|
+
async function readMatrixResponse(response, signal, abortError) {
|
|
70
|
+
const body = await readResponseBody(response, signal, {
|
|
75
71
|
abortError,
|
|
76
|
-
deferReaderRelease: true,
|
|
77
72
|
});
|
|
78
|
-
if (body.truncated)
|
|
79
|
-
throw new Error("Matrix response body is too large");
|
|
80
73
|
if (!response.body)
|
|
81
74
|
return response;
|
|
82
75
|
// Fetch forbids a response body for 204, 205, and 304 responses. Some
|
|
83
76
|
// browser implementations nevertheless expose an empty response stream for
|
|
84
|
-
// a 204 from a proxy.
|
|
85
|
-
// Uint8Array then throws before matrix-js-sdk can accept the successful
|
|
86
|
-
// response. Preserve the bodyless status after boundedly consuming the
|
|
77
|
+
// a 204 from a proxy. Preserve the bodyless status after consuming the
|
|
87
78
|
// stream so callers such as FetchHttpApi can still read an empty Blob.
|
|
88
79
|
const bodylessStatus = response.status === 204 || response.status === 205 || response.status === 304;
|
|
89
80
|
if (bodylessStatus) {
|
|
@@ -102,14 +93,6 @@ async function readBoundedMatrixResponse(response, maxBytes, signal, abortError)
|
|
|
102
93
|
headers: response.headers,
|
|
103
94
|
});
|
|
104
95
|
}
|
|
105
|
-
function isMatrixStateOrMembershipUrl(input) {
|
|
106
|
-
const url = typeof input === "string"
|
|
107
|
-
? new URL(input, "https://matrix.invalid")
|
|
108
|
-
: input instanceof URL
|
|
109
|
-
? input
|
|
110
|
-
: new URL(input.url, "https://matrix.invalid");
|
|
111
|
-
return /\/rooms\/[^/]+\/(?:state|members)(?:\/|$)/.test(url.pathname);
|
|
112
|
-
}
|
|
113
96
|
export function boundedMatrixFetch(fetchFn) {
|
|
114
97
|
const wrapped = ((input, init) => {
|
|
115
98
|
const pending = (async () => {
|
|
@@ -126,25 +109,21 @@ export function boundedMatrixFetch(fetchFn) {
|
|
|
126
109
|
else
|
|
127
110
|
externalSignal?.addEventListener("abort", abortExternal, { once: true });
|
|
128
111
|
try {
|
|
129
|
-
const response = await raceWithAbort(fetchFn(input, { ...init, redirect: "manual", signal: controller.signal }), controller.signal, () =>
|
|
112
|
+
const response = await raceWithAbort(fetchFn(input, { ...init, redirect: "manual", signal: controller.signal }), controller.signal, () => (timedOut ? new Error("Matrix request timed out") : new DOMException("The operation was aborted", "AbortError")));
|
|
130
113
|
if (response.redirected ||
|
|
131
114
|
response.type === "opaqueredirect" ||
|
|
132
115
|
(response.status >= 300 && response.status < 400)) {
|
|
133
|
-
|
|
134
|
-
|
|
116
|
+
const failure = new Error("Matrix redirect rejected");
|
|
117
|
+
await cancelResponseBody(response, "Matrix redirect", failure);
|
|
118
|
+
throw failure;
|
|
135
119
|
}
|
|
136
|
-
return await
|
|
137
|
-
? MAX_MATRIX_STATE_RESPONSE_BYTES
|
|
138
|
-
: MAX_MATRIX_RESPONSE_BYTES, controller.signal, () => (timedOut ? new Error("Matrix request timed out") : new DOMException("The operation was aborted", "AbortError")));
|
|
120
|
+
return await readMatrixResponse(response, controller.signal, () => (timedOut ? new Error("Matrix request timed out") : new DOMException("The operation was aborted", "AbortError")));
|
|
139
121
|
}
|
|
140
122
|
finally {
|
|
141
123
|
clearTimeout(timeout);
|
|
142
124
|
externalSignal?.removeEventListener("abort", abortExternal);
|
|
143
125
|
}
|
|
144
126
|
})();
|
|
145
|
-
// Preserve fetch-style rejection for callers while observing an immediate
|
|
146
|
-
// abort until the caller has had a chance to attach its own handler.
|
|
147
|
-
void pending.catch(() => undefined);
|
|
148
127
|
return pending;
|
|
149
128
|
});
|
|
150
129
|
return wrapped;
|
|
@@ -181,8 +160,8 @@ function validateHomeserverUrl(value) {
|
|
|
181
160
|
try {
|
|
182
161
|
url = new URL(value);
|
|
183
162
|
}
|
|
184
|
-
catch {
|
|
185
|
-
throw new Error("invalid Matrix homeserver URL");
|
|
163
|
+
catch (error) {
|
|
164
|
+
throw new Error("invalid Matrix homeserver URL", { cause: error });
|
|
186
165
|
}
|
|
187
166
|
if ((url.protocol !== "http:" && url.protocol !== "https:") ||
|
|
188
167
|
(url.protocol === "http:" && !isLoopbackHostname(url.hostname)) ||
|
|
@@ -224,9 +203,50 @@ function validateMimetype(value) {
|
|
|
224
203
|
throw new Error("invalid MIME type");
|
|
225
204
|
}
|
|
226
205
|
}
|
|
206
|
+
/** Reads the event-owned metadata shared by details and download operations. */
|
|
207
|
+
export function readFileEventMetadata(content) {
|
|
208
|
+
if (!isRecord(content))
|
|
209
|
+
throw new StorageError("media metadata is invalid");
|
|
210
|
+
const rawInfo = content["info"];
|
|
211
|
+
if (rawInfo !== undefined && !isRecord(rawInfo)) {
|
|
212
|
+
throw new StorageError("media metadata is invalid");
|
|
213
|
+
}
|
|
214
|
+
const info = isRecord(rawInfo) ? rawInfo : undefined;
|
|
215
|
+
const rawSize = info?.["size"];
|
|
216
|
+
if (rawSize !== undefined) {
|
|
217
|
+
if (typeof rawSize !== "number" || !Number.isSafeInteger(rawSize) || rawSize < 0) {
|
|
218
|
+
throw new StorageError("media metadata is invalid");
|
|
219
|
+
}
|
|
220
|
+
if (rawSize > MAX_MEDIA_FILE_BYTES)
|
|
221
|
+
throw new FileTooLargeError();
|
|
222
|
+
}
|
|
223
|
+
const rawMimetype = info?.["mimetype"];
|
|
224
|
+
if (rawMimetype !== undefined && typeof rawMimetype !== "string") {
|
|
225
|
+
throw new StorageError("media metadata is invalid");
|
|
226
|
+
}
|
|
227
|
+
const mimetype = rawMimetype ?? null;
|
|
228
|
+
if (mimetype !== null) {
|
|
229
|
+
try {
|
|
230
|
+
validateMimetype(mimetype);
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
throw new StorageError("media metadata is invalid", { cause: error });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
mimetype,
|
|
238
|
+
size: typeof rawSize === "number" ? rawSize : null,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
227
241
|
function isRecord(value) {
|
|
228
242
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
229
243
|
}
|
|
244
|
+
async function isUndecryptableFilePlaceholder(branch) {
|
|
245
|
+
// matrix-js-sdk replaces failed encrypted event content with m.bad.encrypted;
|
|
246
|
+
// its public failure flag is the authoritative state and avoids guessing
|
|
247
|
+
// from that display content (or misclassifying malformed plaintext events).
|
|
248
|
+
return (await branch.getFileEvent()).isDecryptionFailure();
|
|
249
|
+
}
|
|
230
250
|
function validateSecretStorageStatus(value) {
|
|
231
251
|
if (!isRecord(value))
|
|
232
252
|
throw new RecoverySetupAmbiguousError();
|
|
@@ -295,7 +315,7 @@ async function withRecoveryCryptoDeadline(operation, signal, operationName) {
|
|
|
295
315
|
signal?.addEventListener("abort", abortExternal, { once: true });
|
|
296
316
|
const pending = Promise.resolve().then(operation);
|
|
297
317
|
try {
|
|
298
|
-
return await raceWithAbort(pending, controller.signal, () =>
|
|
318
|
+
return await raceWithAbort(pending, controller.signal, () => timedOut
|
|
299
319
|
? new RecoveryCryptoTimeoutError(operationName)
|
|
300
320
|
: new StorageError("operation cancelled"));
|
|
301
321
|
}
|
|
@@ -308,9 +328,6 @@ function parseMatrixMembersResponse(value) {
|
|
|
308
328
|
if (!isRecord(value) || !Array.isArray(value.chunk)) {
|
|
309
329
|
throw new Error("invalid Matrix members response");
|
|
310
330
|
}
|
|
311
|
-
if (value.chunk.length > MAX_MATRIX_MEMBERS) {
|
|
312
|
-
throw new Error("Matrix members response is too large");
|
|
313
|
-
}
|
|
314
331
|
return value.chunk.map((entry) => {
|
|
315
332
|
if (!isRecord(entry) ||
|
|
316
333
|
typeof entry.state_key !== "string" ||
|
|
@@ -329,9 +346,6 @@ function parseMatrixMembersResponse(value) {
|
|
|
329
346
|
function parseMatrixStateResponse(value) {
|
|
330
347
|
if (!Array.isArray(value))
|
|
331
348
|
throw new Error("invalid Matrix room state response");
|
|
332
|
-
if (value.length > MAX_MATRIX_STATE_EVENTS) {
|
|
333
|
-
throw new Error("Matrix room state response is too large");
|
|
334
|
-
}
|
|
335
349
|
return value.map((event) => {
|
|
336
350
|
if (!isRecord(event) ||
|
|
337
351
|
typeof event.type !== "string" ||
|
|
@@ -362,9 +376,6 @@ function parseMatrixPowerLevels(value) {
|
|
|
362
376
|
continue;
|
|
363
377
|
if (!isRecord(field))
|
|
364
378
|
throw new Error("invalid Matrix power-level response");
|
|
365
|
-
if (Object.keys(field).length > MAX_MATRIX_MEMBERS) {
|
|
366
|
-
throw new Error("Matrix power-level response is too large");
|
|
367
|
-
}
|
|
368
379
|
for (const key of Object.keys(field))
|
|
369
380
|
validateMatrixIdentifier(key, `${name} key`);
|
|
370
381
|
for (const level of Object.values(field)) {
|
|
@@ -379,8 +390,11 @@ function isGoneRoomError(error) {
|
|
|
379
390
|
return (error instanceof MatrixError &&
|
|
380
391
|
(error.errcode === "M_NOT_FOUND" || error.errcode === "M_UNKNOWN"));
|
|
381
392
|
}
|
|
382
|
-
function throwWithCleanupDetail(error, roomId) {
|
|
383
|
-
const
|
|
393
|
+
function throwWithCleanupDetail(error, roomId, cleanupError) {
|
|
394
|
+
const cleanupCause = cleanupError === undefined
|
|
395
|
+
? error
|
|
396
|
+
: new AggregateError([error, cleanupError], "operation and room cleanup failed", { cause: error });
|
|
397
|
+
const cleanup = new RoomCleanupIncompleteError(roomId, cleanupCause);
|
|
384
398
|
if (error instanceof Error) {
|
|
385
399
|
try {
|
|
386
400
|
Object.defineProperty(error, "cleanupIncomplete", {
|
|
@@ -393,9 +407,10 @@ function throwWithCleanupDetail(error, roomId) {
|
|
|
393
407
|
});
|
|
394
408
|
}
|
|
395
409
|
catch {
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
410
|
+
// A non-extensible provider error cannot carry the cleanup marker. Throw
|
|
411
|
+
// the existing typed cleanup error and retain the provider error as cause
|
|
412
|
+
// so callers cannot mistake the mutation for a safe retry.
|
|
413
|
+
throw cleanup;
|
|
399
414
|
}
|
|
400
415
|
throw error;
|
|
401
416
|
}
|
|
@@ -465,9 +480,6 @@ export class TeleCryptIOStorage {
|
|
|
465
480
|
for (const [stateKey, event] of byStateKey) {
|
|
466
481
|
if (present.has(`${eventType}\u0000${stateKey}`))
|
|
467
482
|
continue;
|
|
468
|
-
if (refreshed.length >= MAX_MATRIX_STATE_EVENTS) {
|
|
469
|
-
throw new Error("Matrix room state response is too large");
|
|
470
|
-
}
|
|
471
483
|
const original = event.getEffectiveEvent();
|
|
472
484
|
refreshed.push(new MatrixEvent({
|
|
473
485
|
...original,
|
|
@@ -567,8 +579,10 @@ export class TeleCryptIOStorage {
|
|
|
567
579
|
try {
|
|
568
580
|
client.stopClient();
|
|
569
581
|
}
|
|
570
|
-
catch {
|
|
571
|
-
|
|
582
|
+
catch (cleanupError) {
|
|
583
|
+
throw new AggregateError([error, cleanupError], "storage bootstrap and client cleanup failed", {
|
|
584
|
+
cause: error,
|
|
585
|
+
});
|
|
572
586
|
}
|
|
573
587
|
throw error;
|
|
574
588
|
}
|
|
@@ -769,7 +783,7 @@ export class TeleCryptIOStorage {
|
|
|
769
783
|
if (signal?.aborted && error instanceof StorageError) {
|
|
770
784
|
throw error;
|
|
771
785
|
}
|
|
772
|
-
throw new RecoverySetupAmbiguousError();
|
|
786
|
+
throw new RecoverySetupAmbiguousError(error);
|
|
773
787
|
}
|
|
774
788
|
if (status.defaultKeyId || status.ready || backupVersion !== null) {
|
|
775
789
|
throw new RecoveryAlreadyConfiguredError();
|
|
@@ -789,11 +803,11 @@ export class TeleCryptIOStorage {
|
|
|
789
803
|
authUploadDeviceSigningKeys: async () => undefined,
|
|
790
804
|
}), signal, "cross-signing bootstrap");
|
|
791
805
|
}
|
|
792
|
-
catch {
|
|
806
|
+
catch (error) {
|
|
793
807
|
// Cross-signing setup can commit server-side state before a transport
|
|
794
808
|
// failure is observed. Do not turn that uncertainty into a retry that
|
|
795
809
|
// could replace recovery state.
|
|
796
|
-
throw new RecoverySetupAmbiguousError();
|
|
810
|
+
throw new RecoverySetupAmbiguousError(error);
|
|
797
811
|
}
|
|
798
812
|
// CryptoApi mutations do not accept an AbortSignal. Once one has settled,
|
|
799
813
|
// do not cross the next one-way mutation boundary: the caller may already
|
|
@@ -804,8 +818,8 @@ export class TeleCryptIOStorage {
|
|
|
804
818
|
try {
|
|
805
819
|
generated = await withRecoveryCryptoDeadline(() => crypto.createRecoveryKeyFromPassphrase(), signal, "recovery-key generation");
|
|
806
820
|
}
|
|
807
|
-
catch {
|
|
808
|
-
throw new RecoverySetupAmbiguousError();
|
|
821
|
+
catch (error) {
|
|
822
|
+
throw new RecoverySetupAmbiguousError(error);
|
|
809
823
|
}
|
|
810
824
|
if (!generated.encodedPrivateKey) {
|
|
811
825
|
throw new RecoverySetupAmbiguousError();
|
|
@@ -858,15 +872,22 @@ export class TeleCryptIOStorage {
|
|
|
858
872
|
throw new RecoverySetupAmbiguousError();
|
|
859
873
|
}
|
|
860
874
|
}
|
|
861
|
-
catch (
|
|
862
|
-
if (
|
|
863
|
-
throw error
|
|
875
|
+
catch (statusError) {
|
|
876
|
+
if (statusError instanceof RecoverySetupAmbiguousError) {
|
|
877
|
+
throw new RecoverySetupAmbiguousError(new AggregateError([error, statusError], "recovery setup and status verification were both inconclusive", {
|
|
878
|
+
cause: error,
|
|
879
|
+
}));
|
|
880
|
+
}
|
|
881
|
+
if (statusError instanceof RecoverySetupError)
|
|
882
|
+
throw statusError;
|
|
864
883
|
// A failed status probe cannot prove that the one-way operation did
|
|
865
884
|
// not commit. Never turn that uncertainty into an apparently safe
|
|
866
885
|
// retry that could replace account recovery state.
|
|
867
|
-
throw new RecoverySetupAmbiguousError(
|
|
886
|
+
throw new RecoverySetupAmbiguousError(new AggregateError([error, statusError], "recovery setup and status verification failed", {
|
|
887
|
+
cause: error,
|
|
888
|
+
}));
|
|
868
889
|
}
|
|
869
|
-
throw new RecoverySetupError();
|
|
890
|
+
throw new RecoverySetupError(error);
|
|
870
891
|
}
|
|
871
892
|
try {
|
|
872
893
|
const [rawAfterStatus, rawAfterBackup] = await Promise.all([
|
|
@@ -881,7 +902,7 @@ export class TeleCryptIOStorage {
|
|
|
881
902
|
catch (error) {
|
|
882
903
|
if (error instanceof RecoverySetupAmbiguousError)
|
|
883
904
|
throw error;
|
|
884
|
-
throw new RecoverySetupAmbiguousError();
|
|
905
|
+
throw new RecoverySetupAmbiguousError(error);
|
|
885
906
|
}
|
|
886
907
|
// This is the last boundary before exposing the only recovery credential
|
|
887
908
|
// to the caller. Cancellation here must not return a key that the caller
|
|
@@ -911,8 +932,8 @@ export class TeleCryptIOStorage {
|
|
|
911
932
|
const backupVersion = validateBackupVersion(rawBackupVersion);
|
|
912
933
|
return storageStatus.ready && backupVersion !== null;
|
|
913
934
|
}
|
|
914
|
-
catch {
|
|
915
|
-
throw new RecoverySetupAmbiguousError();
|
|
935
|
+
catch (error) {
|
|
936
|
+
throw new RecoverySetupAmbiguousError(error);
|
|
916
937
|
}
|
|
917
938
|
}
|
|
918
939
|
/** Returns one consistent recovery topology snapshot without exposing raw crypto callbacks. */
|
|
@@ -952,8 +973,8 @@ export class TeleCryptIOStorage {
|
|
|
952
973
|
backupVersion,
|
|
953
974
|
};
|
|
954
975
|
}
|
|
955
|
-
catch {
|
|
956
|
-
throw new RecoverySetupAmbiguousError();
|
|
976
|
+
catch (error) {
|
|
977
|
+
throw new RecoverySetupAmbiguousError(error);
|
|
957
978
|
}
|
|
958
979
|
}, signal);
|
|
959
980
|
}
|
|
@@ -983,8 +1004,8 @@ export class TeleCryptIOStorage {
|
|
|
983
1004
|
try {
|
|
984
1005
|
privateKey = decodeRecoveryKey(recoveryKey);
|
|
985
1006
|
}
|
|
986
|
-
catch {
|
|
987
|
-
throw new RecoveryRestoreError();
|
|
1007
|
+
catch (error) {
|
|
1008
|
+
throw new RecoveryRestoreError(error);
|
|
988
1009
|
}
|
|
989
1010
|
if (signal?.aborted)
|
|
990
1011
|
throw new StorageError("operation cancelled");
|
|
@@ -997,7 +1018,7 @@ export class TeleCryptIOStorage {
|
|
|
997
1018
|
catch (error) {
|
|
998
1019
|
if (error instanceof RecoveryRestoreAmbiguousError)
|
|
999
1020
|
throw error;
|
|
1000
|
-
throw new RecoveryRestoreError();
|
|
1021
|
+
throw new RecoveryRestoreError(error);
|
|
1001
1022
|
}
|
|
1002
1023
|
try {
|
|
1003
1024
|
const result = await withRecoveryCryptoDeadline(() => crypto.restoreKeyBackup(), signal, "key-backup restore");
|
|
@@ -1011,7 +1032,9 @@ export class TeleCryptIOStorage {
|
|
|
1011
1032
|
catch (error) {
|
|
1012
1033
|
if (error instanceof RecoveryRestoreAmbiguousError)
|
|
1013
1034
|
throw error;
|
|
1014
|
-
|
|
1035
|
+
if (error instanceof RecoveryRestoreError)
|
|
1036
|
+
throw error;
|
|
1037
|
+
throw new RecoveryRestoreError(error);
|
|
1015
1038
|
}
|
|
1016
1039
|
}, signal);
|
|
1017
1040
|
}
|
|
@@ -1075,6 +1098,7 @@ export class TeleCryptIOStorage {
|
|
|
1075
1098
|
}
|
|
1076
1099
|
async waitForTreeSpace(roomId, operation, signal) {
|
|
1077
1100
|
const deadline = Date.now() + TREE_SYNC_TIMEOUT_MS;
|
|
1101
|
+
let lastError;
|
|
1078
1102
|
for (;;) {
|
|
1079
1103
|
if (signal?.aborted)
|
|
1080
1104
|
throw new StorageError("operation cancelled");
|
|
@@ -1083,12 +1107,15 @@ export class TeleCryptIOStorage {
|
|
|
1083
1107
|
if (tree)
|
|
1084
1108
|
return this.decorateTreeSpace(tree);
|
|
1085
1109
|
}
|
|
1086
|
-
catch {
|
|
1110
|
+
catch (error) {
|
|
1111
|
+
lastError = error;
|
|
1087
1112
|
// The room can be present before its complete state has been applied
|
|
1088
1113
|
// to this client's local store. Keep polling the exact returned ID.
|
|
1089
1114
|
}
|
|
1090
1115
|
if (Date.now() >= deadline) {
|
|
1091
|
-
throw new Error(`${operation}: created room did not become a file tree space in local sync state
|
|
1116
|
+
throw new Error(`${operation}: created room did not become a file tree space in local sync state`, {
|
|
1117
|
+
cause: lastError,
|
|
1118
|
+
});
|
|
1092
1119
|
}
|
|
1093
1120
|
await new Promise((resolve, reject) => {
|
|
1094
1121
|
let timer;
|
|
@@ -1129,12 +1156,14 @@ export class TeleCryptIOStorage {
|
|
|
1129
1156
|
const cleanupSignal = new AbortController().signal;
|
|
1130
1157
|
let incomplete = false;
|
|
1131
1158
|
let safeToForget = true;
|
|
1159
|
+
const failures = [];
|
|
1132
1160
|
try {
|
|
1133
1161
|
await TeleCryptIOStorage.withTimeout(withMatrixMutationAbort(this.client, () => this.client.leave(roomId), cleanupSignal), CLEANUP_TIMEOUT_MS, "room leave cleanup", cleanupSignal);
|
|
1134
1162
|
}
|
|
1135
1163
|
catch (error) {
|
|
1136
1164
|
if (!isGoneRoomError(error)) {
|
|
1137
1165
|
incomplete = true;
|
|
1166
|
+
failures.push(error);
|
|
1138
1167
|
// A timed-out or failed leave may still be in flight. Do not race a
|
|
1139
1168
|
// forget request against it; the caller must retry cleanup later.
|
|
1140
1169
|
safeToForget = false;
|
|
@@ -1145,12 +1174,17 @@ export class TeleCryptIOStorage {
|
|
|
1145
1174
|
await TeleCryptIOStorage.withTimeout(withMatrixMutationAbort(this.client, () => this.client.forget(roomId), cleanupSignal), CLEANUP_TIMEOUT_MS, "room forget cleanup", cleanupSignal);
|
|
1146
1175
|
}
|
|
1147
1176
|
catch (error) {
|
|
1148
|
-
if (!isGoneRoomError(error))
|
|
1177
|
+
if (!isGoneRoomError(error)) {
|
|
1149
1178
|
incomplete = true;
|
|
1179
|
+
failures.push(error);
|
|
1180
|
+
}
|
|
1150
1181
|
}
|
|
1151
1182
|
}
|
|
1152
1183
|
if (incomplete) {
|
|
1153
|
-
|
|
1184
|
+
const cause = failures.length === 1
|
|
1185
|
+
? failures[0]
|
|
1186
|
+
: new AggregateError(failures, "room cleanup failed");
|
|
1187
|
+
throw new RoomCleanupIncompleteError(roomId, cause);
|
|
1154
1188
|
}
|
|
1155
1189
|
}
|
|
1156
1190
|
async removeRelationLink(roomId, eventType, stateKey, signal) {
|
|
@@ -1188,14 +1222,14 @@ export class TeleCryptIOStorage {
|
|
|
1188
1222
|
// retryable generic error that could orphan a second room.
|
|
1189
1223
|
if (error instanceof RoomCreationAmbiguousError)
|
|
1190
1224
|
throw error;
|
|
1191
|
-
throw new RoomCreationAmbiguousError(operation);
|
|
1225
|
+
throw new RoomCreationAmbiguousError(operation, error);
|
|
1192
1226
|
});
|
|
1193
1227
|
let roomId;
|
|
1194
1228
|
try {
|
|
1195
1229
|
roomId = validateMatrixRoomId(response?.room_id, "room creation response");
|
|
1196
1230
|
}
|
|
1197
|
-
catch {
|
|
1198
|
-
throw new RoomCreationAmbiguousError(operation);
|
|
1231
|
+
catch (error) {
|
|
1232
|
+
throw new RoomCreationAmbiguousError(operation, error);
|
|
1199
1233
|
}
|
|
1200
1234
|
try {
|
|
1201
1235
|
return await this.waitForTreeSpace(roomId, operation, signal);
|
|
@@ -1204,13 +1238,13 @@ export class TeleCryptIOStorage {
|
|
|
1204
1238
|
try {
|
|
1205
1239
|
await this.cleanupCreatedRoom(roomId, signal);
|
|
1206
1240
|
}
|
|
1207
|
-
catch {
|
|
1208
|
-
throwWithCleanupDetail(error, roomId);
|
|
1241
|
+
catch (cleanupError) {
|
|
1242
|
+
throwWithCleanupDetail(error, roomId, cleanupError);
|
|
1209
1243
|
}
|
|
1210
1244
|
// The room existed even if cleanup happened to succeed. Keep the
|
|
1211
1245
|
// caller on the reconciliation path: a retry based on a local timeout
|
|
1212
1246
|
// or malformed response must never assume that no room was created.
|
|
1213
|
-
throw new RoomCreationAmbiguousError(operation);
|
|
1247
|
+
throw new RoomCreationAmbiguousError(operation, error);
|
|
1214
1248
|
}
|
|
1215
1249
|
}
|
|
1216
1250
|
/**
|
|
@@ -1224,8 +1258,6 @@ export class TeleCryptIOStorage {
|
|
|
1224
1258
|
if (signal?.aborted)
|
|
1225
1259
|
throw new StorageError("operation cancelled");
|
|
1226
1260
|
const rooms = this.client.getRooms();
|
|
1227
|
-
if (rooms.length > MAX_MATRIX_JOINED_ROOMS)
|
|
1228
|
-
throw new StorageError("room list is too large");
|
|
1229
1261
|
const trees = [];
|
|
1230
1262
|
for (const room of rooms) {
|
|
1231
1263
|
if (signal?.aborted)
|
|
@@ -1280,6 +1312,7 @@ export class TeleCryptIOStorage {
|
|
|
1280
1312
|
// signal, then report incomplete cleanup if they cannot be verified.
|
|
1281
1313
|
const compensationSignal = new AbortController().signal;
|
|
1282
1314
|
let rollbackIncomplete = false;
|
|
1315
|
+
const cleanupFailures = [];
|
|
1283
1316
|
// A sendStateEvent rejection is ambiguous: the homeserver may have
|
|
1284
1317
|
// committed the event before the client observed a transport error.
|
|
1285
1318
|
// Refresh the parent before probing even when no event ID was
|
|
@@ -1303,6 +1336,7 @@ export class TeleCryptIOStorage {
|
|
|
1303
1336
|
Object.keys(content).length > 0);
|
|
1304
1337
|
})) {
|
|
1305
1338
|
rollbackIncomplete = true;
|
|
1339
|
+
cleanupFailures.push(new Error("parent link rollback could not be verified"));
|
|
1306
1340
|
}
|
|
1307
1341
|
}
|
|
1308
1342
|
else {
|
|
@@ -1310,12 +1344,15 @@ export class TeleCryptIOStorage {
|
|
|
1310
1344
|
const removed = await this.removeChildLink(currentParent.id, tree.id, compensationSignal);
|
|
1311
1345
|
// Without an event ID, a missing link after one refresh does not
|
|
1312
1346
|
// prove that a request which timed out will never commit.
|
|
1313
|
-
if (!removed)
|
|
1347
|
+
if (!removed) {
|
|
1314
1348
|
rollbackIncomplete = true;
|
|
1349
|
+
cleanupFailures.push(new Error("parent link rollback could not be verified"));
|
|
1350
|
+
}
|
|
1315
1351
|
}
|
|
1316
1352
|
}
|
|
1317
|
-
catch {
|
|
1353
|
+
catch (cleanupError) {
|
|
1318
1354
|
rollbackIncomplete = true;
|
|
1355
|
+
cleanupFailures.push(cleanupError);
|
|
1319
1356
|
}
|
|
1320
1357
|
}
|
|
1321
1358
|
if (childLinkAttempted) {
|
|
@@ -1335,27 +1372,35 @@ export class TeleCryptIOStorage {
|
|
|
1335
1372
|
Object.keys(content).length > 0);
|
|
1336
1373
|
})) {
|
|
1337
1374
|
rollbackIncomplete = true;
|
|
1375
|
+
cleanupFailures.push(new Error("child link rollback could not be verified"));
|
|
1338
1376
|
}
|
|
1339
1377
|
}
|
|
1340
1378
|
else {
|
|
1341
1379
|
await this.refreshRoomState(tree.id, { signal: compensationSignal });
|
|
1342
1380
|
const removed = await this.removeParentLink(tree.id, currentParent.id, compensationSignal);
|
|
1343
|
-
if (!removed)
|
|
1381
|
+
if (!removed) {
|
|
1344
1382
|
rollbackIncomplete = true;
|
|
1383
|
+
cleanupFailures.push(new Error("child link rollback could not be verified"));
|
|
1384
|
+
}
|
|
1345
1385
|
}
|
|
1346
1386
|
}
|
|
1347
|
-
catch {
|
|
1387
|
+
catch (cleanupError) {
|
|
1348
1388
|
rollbackIncomplete = true;
|
|
1389
|
+
cleanupFailures.push(cleanupError);
|
|
1349
1390
|
}
|
|
1350
1391
|
}
|
|
1351
1392
|
try {
|
|
1352
1393
|
await this.cleanupCreatedRoom(tree.id, effectiveSignal);
|
|
1353
1394
|
}
|
|
1354
|
-
catch {
|
|
1355
|
-
|
|
1395
|
+
catch (cleanupError) {
|
|
1396
|
+
cleanupFailures.push(cleanupError);
|
|
1397
|
+
}
|
|
1398
|
+
if (rollbackIncomplete || cleanupFailures.length > 0) {
|
|
1399
|
+
const cleanupCause = cleanupFailures.length === 1
|
|
1400
|
+
? cleanupFailures[0]
|
|
1401
|
+
: new AggregateError(cleanupFailures, "tree creation cleanup failed");
|
|
1402
|
+
throwWithCleanupDetail(error, tree.id, cleanupCause);
|
|
1356
1403
|
}
|
|
1357
|
-
if (rollbackIncomplete)
|
|
1358
|
-
throwWithCleanupDetail(error, tree.id);
|
|
1359
1404
|
throw error;
|
|
1360
1405
|
}
|
|
1361
1406
|
}, signal);
|
|
@@ -1453,17 +1498,17 @@ export class TeleCryptIOStorage {
|
|
|
1453
1498
|
// mutation outcome is safe to propagate unchanged.
|
|
1454
1499
|
if (error instanceof MutationOutcomeUnknownError)
|
|
1455
1500
|
throw error;
|
|
1456
|
-
throw new MutationOutcomeUnknownError("file upload");
|
|
1501
|
+
throw new MutationOutcomeUnknownError("file upload", { cause: error });
|
|
1457
1502
|
}
|
|
1458
1503
|
if (signal?.aborted)
|
|
1459
1504
|
throw new StorageError("operation cancelled");
|
|
1460
1505
|
try {
|
|
1461
1506
|
return validateMatrixEventId(response?.event_id, "file upload response event ID");
|
|
1462
1507
|
}
|
|
1463
|
-
catch {
|
|
1508
|
+
catch (error) {
|
|
1464
1509
|
// An invalid response can still follow a committed event. Treat it as
|
|
1465
1510
|
// unknown rather than presenting a retryable generic upload failure.
|
|
1466
|
-
throw new MutationOutcomeUnknownError("file upload");
|
|
1511
|
+
throw new MutationOutcomeUnknownError("file upload", { cause: error });
|
|
1467
1512
|
}
|
|
1468
1513
|
}
|
|
1469
1514
|
async downloadFile(branch, signal) {
|
|
@@ -1473,26 +1518,38 @@ export class TeleCryptIOStorage {
|
|
|
1473
1518
|
try {
|
|
1474
1519
|
({ info } = await branch.getFileInfo());
|
|
1475
1520
|
}
|
|
1476
|
-
catch {
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
//
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1521
|
+
catch (error) {
|
|
1522
|
+
if (signal?.aborted)
|
|
1523
|
+
throw new StorageError("operation cancelled");
|
|
1524
|
+
if (error instanceof StorageError || error instanceof MatrixError)
|
|
1525
|
+
throw error;
|
|
1526
|
+
// matrix-js-sdk's file-info accessor reads an attachment absent from
|
|
1527
|
+
// failed-decryption events. Use its explicit decryption status, not a
|
|
1528
|
+
// guessed content shape; preserve other SDK/transport failures.
|
|
1529
|
+
let isPlaceholder = false;
|
|
1530
|
+
try {
|
|
1531
|
+
isPlaceholder = await isUndecryptableFilePlaceholder(branch);
|
|
1532
|
+
}
|
|
1533
|
+
catch (placeholderError) {
|
|
1534
|
+
throw new AggregateError([error, placeholderError], "file metadata lookup and placeholder inspection both failed", { cause: error });
|
|
1535
|
+
}
|
|
1536
|
+
if (isPlaceholder) {
|
|
1537
|
+
throw new UndecryptableFileError();
|
|
1538
|
+
}
|
|
1539
|
+
throw error;
|
|
1540
|
+
}
|
|
1541
|
+
// Missing metadata alone does not establish a decryption failure.
|
|
1485
1542
|
if (!info || typeof info.url !== "string") {
|
|
1486
|
-
throw new
|
|
1487
|
-
}
|
|
1488
|
-
const declaredSize = info.size;
|
|
1489
|
-
if (declaredSize !== undefined &&
|
|
1490
|
-
(typeof declaredSize !== "number" ||
|
|
1491
|
-
!Number.isSafeInteger(declaredSize) ||
|
|
1492
|
-
declaredSize < 0 ||
|
|
1493
|
-
declaredSize > MAX_MEDIA_FILE_BYTES)) {
|
|
1494
|
-
throw new FileTooLargeError();
|
|
1543
|
+
throw new StorageError("file metadata is missing a media URL");
|
|
1495
1544
|
}
|
|
1545
|
+
// `info` is the encrypted attachment descriptor. Its shape is owned by
|
|
1546
|
+
// matrix-encrypt-attachment and does not carry the plaintext size. The
|
|
1547
|
+
// event's `info` block is the sole source of user-facing file metadata.
|
|
1548
|
+
const eventMetadata = readFileEventMetadata((await branch.getFileEvent()).getContent());
|
|
1549
|
+
if (signal?.aborted)
|
|
1550
|
+
throw new StorageError("operation cancelled");
|
|
1551
|
+
const declaredSize = eventMetadata.size;
|
|
1552
|
+
const mimetype = eventMetadata.mimetype ?? "application/octet-stream";
|
|
1496
1553
|
const mxcUrl = info.url;
|
|
1497
1554
|
const clientAny = this.client;
|
|
1498
1555
|
const accessToken = clientAny.getAccessToken();
|
|
@@ -1501,8 +1558,8 @@ export class TeleCryptIOStorage {
|
|
|
1501
1558
|
try {
|
|
1502
1559
|
trustedOrigin = validateHomeserverUrl(clientAny.getHomeserverUrl()).origin;
|
|
1503
1560
|
}
|
|
1504
|
-
catch {
|
|
1505
|
-
throw new Error("failed to build media URL");
|
|
1561
|
+
catch (error) {
|
|
1562
|
+
throw new Error("failed to build media URL", { cause: error });
|
|
1506
1563
|
}
|
|
1507
1564
|
const downloadUrl = clientAny.mxcUrlToHttp(mxcUrl, undefined, undefined, undefined, false, true, true);
|
|
1508
1565
|
if (!downloadUrl)
|
|
@@ -1511,8 +1568,8 @@ export class TeleCryptIOStorage {
|
|
|
1511
1568
|
try {
|
|
1512
1569
|
currentUrl = new URL(downloadUrl);
|
|
1513
1570
|
}
|
|
1514
|
-
catch {
|
|
1515
|
-
throw new Error("failed to build media URL");
|
|
1571
|
+
catch (error) {
|
|
1572
|
+
throw new Error("failed to build media URL", { cause: error });
|
|
1516
1573
|
}
|
|
1517
1574
|
if ((currentUrl.protocol !== "http:" && currentUrl.protocol !== "https:") ||
|
|
1518
1575
|
currentUrl.username !== "" ||
|
|
@@ -1535,67 +1592,92 @@ export class TeleCryptIOStorage {
|
|
|
1535
1592
|
redirect: "manual",
|
|
1536
1593
|
signal: controller.signal,
|
|
1537
1594
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1538
|
-
}), controller.signal, () =>
|
|
1595
|
+
}), controller.signal, () => signal?.aborted
|
|
1539
1596
|
? new StorageError("operation cancelled")
|
|
1540
1597
|
: new Error("media download timed out"));
|
|
1541
1598
|
if ([301, 302, 303, 307, 308].includes(res.status)) {
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
// The redirect response is already being rejected or discarded.
|
|
1547
|
-
}
|
|
1548
|
-
if (redirect >= MAX_MEDIA_REDIRECTS)
|
|
1549
|
-
throw new Error("media download redirect limit exceeded");
|
|
1550
|
-
const location = res.headers.get("location");
|
|
1551
|
-
if (!location)
|
|
1552
|
-
throw new Error("media download redirect missing location");
|
|
1553
|
-
try {
|
|
1554
|
-
currentUrl = new URL(location, currentUrl);
|
|
1599
|
+
let redirectError;
|
|
1600
|
+
let nextUrl;
|
|
1601
|
+
if (redirect >= MAX_MEDIA_REDIRECTS) {
|
|
1602
|
+
redirectError = new Error("media download redirect limit exceeded");
|
|
1555
1603
|
}
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1604
|
+
else {
|
|
1605
|
+
const location = res.headers.get("location");
|
|
1606
|
+
if (!location) {
|
|
1607
|
+
redirectError = new Error("media download redirect missing location");
|
|
1608
|
+
}
|
|
1609
|
+
else {
|
|
1610
|
+
try {
|
|
1611
|
+
nextUrl = new URL(location, currentUrl);
|
|
1612
|
+
}
|
|
1613
|
+
catch (error) {
|
|
1614
|
+
redirectError = new Error("media download redirect is invalid", { cause: error });
|
|
1615
|
+
}
|
|
1616
|
+
if (nextUrl &&
|
|
1617
|
+
((nextUrl.protocol !== "http:" && nextUrl.protocol !== "https:") ||
|
|
1618
|
+
nextUrl.username !== "" ||
|
|
1619
|
+
nextUrl.password !== "")) {
|
|
1620
|
+
redirectError = new Error("media download redirect is invalid");
|
|
1621
|
+
}
|
|
1622
|
+
if (nextUrl && nextUrl.origin !== trustedOrigin) {
|
|
1623
|
+
redirectError = new Error("media download redirect crossed origin");
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1566
1626
|
}
|
|
1627
|
+
await cancelResponseBody(res, "media redirect", redirectError);
|
|
1628
|
+
if (redirectError)
|
|
1629
|
+
throw redirectError;
|
|
1630
|
+
currentUrl = nextUrl;
|
|
1567
1631
|
continue;
|
|
1568
1632
|
}
|
|
1569
1633
|
if (!res.ok) {
|
|
1634
|
+
const statusFailure = new Error(`media download failed: ${res.status}`);
|
|
1635
|
+
let detail = "";
|
|
1570
1636
|
try {
|
|
1571
|
-
|
|
1637
|
+
// Error responses are diagnostics, not product media. Consume the
|
|
1638
|
+
// complete body so no provider detail is discarded, then apply
|
|
1639
|
+
// the SDK's shared secret/control-character sanitizer before it
|
|
1640
|
+
// crosses the SDK boundary.
|
|
1641
|
+
const body = await readResponseBody(res, controller.signal);
|
|
1642
|
+
detail = sanitizeDiagnosticText(new TextDecoder().decode(body.bytes));
|
|
1572
1643
|
}
|
|
1573
|
-
catch {
|
|
1574
|
-
|
|
1644
|
+
catch (bodyError) {
|
|
1645
|
+
const partial = bodyError instanceof ResponseBodyReadError
|
|
1646
|
+
? sanitizeDiagnosticText(new TextDecoder().decode(bodyError.bytes))
|
|
1647
|
+
: "";
|
|
1648
|
+
const readFailure = bodyError instanceof ResponseBodyReadError
|
|
1649
|
+
? new Error(`media error response body could not be read${partial === "" ? "" : `: ${partial}`}`, { cause: bodyError })
|
|
1650
|
+
: bodyError;
|
|
1651
|
+
throw new AggregateError([statusFailure, readFailure], "media download failed while reading the response body", { cause: statusFailure });
|
|
1575
1652
|
}
|
|
1576
|
-
throw new Error(
|
|
1653
|
+
throw new Error(`${statusFailure.message}${detail === "" ? "" : `: ${detail}`}`);
|
|
1577
1654
|
}
|
|
1578
1655
|
if (!res.body) {
|
|
1579
|
-
|
|
1580
|
-
// Content-Length is only advisory and must not authorize an
|
|
1581
|
-
// unbounded arrayBuffer() allocation.
|
|
1582
|
-
throw new Error("media download has no bounded response body");
|
|
1656
|
+
throw new Error("media download has no response body");
|
|
1583
1657
|
}
|
|
1584
|
-
const body = await
|
|
1585
|
-
if (body.truncated)
|
|
1586
|
-
throw new FileTooLargeError();
|
|
1658
|
+
const body = await readMediaResponseBody(res, MAX_MEDIA_FILE_BYTES, controller.signal);
|
|
1587
1659
|
ciphertext = body.bytes.buffer;
|
|
1588
1660
|
break;
|
|
1589
1661
|
}
|
|
1590
1662
|
}
|
|
1591
1663
|
catch (error) {
|
|
1664
|
+
if (error instanceof AggregateError) {
|
|
1665
|
+
if (signal?.aborted)
|
|
1666
|
+
throw new StorageError("operation cancelled", { cause: error });
|
|
1667
|
+
if (controller.signal.aborted)
|
|
1668
|
+
throw new Error("media download timed out", { cause: error });
|
|
1669
|
+
throw error;
|
|
1670
|
+
}
|
|
1671
|
+
if (error instanceof ResponseBodyReadError) {
|
|
1672
|
+
throw new Error("media download failed", { cause: error.cause ?? new Error(error.message) });
|
|
1673
|
+
}
|
|
1592
1674
|
if (signal?.aborted)
|
|
1593
|
-
throw new StorageError("operation cancelled");
|
|
1675
|
+
throw new StorageError("operation cancelled", { cause: error });
|
|
1594
1676
|
if (controller.signal.aborted)
|
|
1595
|
-
throw new Error("media download timed out");
|
|
1677
|
+
throw new Error("media download timed out", { cause: error });
|
|
1596
1678
|
if (error instanceof FileTooLargeError)
|
|
1597
1679
|
throw error;
|
|
1598
|
-
throw new Error("media download failed");
|
|
1680
|
+
throw new Error("media download failed", { cause: error });
|
|
1599
1681
|
}
|
|
1600
1682
|
finally {
|
|
1601
1683
|
clearTimeout(timeout);
|
|
@@ -1603,41 +1685,20 @@ export class TeleCryptIOStorage {
|
|
|
1603
1685
|
}
|
|
1604
1686
|
let data;
|
|
1605
1687
|
try {
|
|
1606
|
-
// matrix-encrypt-attachment's supported AES-CTR v1/v2 formats are
|
|
1607
|
-
// size-preserving: decryption allocates exactly the ciphertext length.
|
|
1608
|
-
// The ciphertext reader above therefore bounds the allocation before
|
|
1609
|
-
// entering the non-streaming third-party decryptor; the authenticated
|
|
1610
|
-
// event size is also rejected before decryption when present.
|
|
1611
|
-
if (ciphertext.byteLength > MAX_MEDIA_FILE_BYTES)
|
|
1612
|
-
throw new FileTooLargeError();
|
|
1613
1688
|
data = await decryptAttachment(ciphertext, info);
|
|
1614
1689
|
}
|
|
1615
1690
|
catch (error) {
|
|
1616
1691
|
if (error instanceof FileTooLargeError)
|
|
1617
1692
|
throw error;
|
|
1618
|
-
throw new Error("media decryption failed");
|
|
1693
|
+
throw new Error("media decryption failed", { cause: error });
|
|
1619
1694
|
}
|
|
1695
|
+
if (data.byteLength > MAX_MEDIA_FILE_BYTES)
|
|
1696
|
+
throw new FileTooLargeError();
|
|
1620
1697
|
if (signal?.aborted)
|
|
1621
1698
|
throw new StorageError("operation cancelled");
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
throw new StorageError("operation cancelled");
|
|
1625
|
-
if (!isRecord(eventContent))
|
|
1626
|
-
throw new Error("media metadata is invalid");
|
|
1627
|
-
const infoBlock = isRecord(eventContent["info"])
|
|
1628
|
-
? eventContent["info"]
|
|
1629
|
-
: undefined;
|
|
1630
|
-
const mimetype = typeof infoBlock?.["mimetype"] === "string"
|
|
1631
|
-
? infoBlock["mimetype"]
|
|
1632
|
-
: "application/octet-stream";
|
|
1633
|
-
try {
|
|
1634
|
-
validateMimetype(mimetype);
|
|
1635
|
-
}
|
|
1636
|
-
catch {
|
|
1637
|
-
throw new Error("media metadata is invalid");
|
|
1699
|
+
if (declaredSize !== null && data.byteLength !== declaredSize) {
|
|
1700
|
+
throw new StorageError("media metadata size does not match decrypted file");
|
|
1638
1701
|
}
|
|
1639
|
-
if (data.byteLength > MAX_MEDIA_FILE_BYTES)
|
|
1640
|
-
throw new FileTooLargeError();
|
|
1641
1702
|
if (signal?.aborted)
|
|
1642
1703
|
throw new StorageError("operation cancelled");
|
|
1643
1704
|
return { data, mimetype };
|