@telecrypt-io/storage 0.5.27 → 0.5.29
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 +22 -8
- package/dist/TeleCryptIOStorage.d.ts +8 -10
- package/dist/TeleCryptIOStorage.js +244 -211
- 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/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/oidc.d.ts +15 -0
- package/dist/core/oidc.js +533 -215
- package/dist/core/operations.js +187 -267
- package/dist/core/poll.d.ts +3 -0
- package/dist/core/poll.js +7 -1
- package/dist/core/validation.d.ts +2 -1
- package/dist/core/validation.js +8 -5
- 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)) {
|
|
@@ -375,27 +386,15 @@ function parseMatrixPowerLevels(value) {
|
|
|
375
386
|
}
|
|
376
387
|
return value;
|
|
377
388
|
}
|
|
378
|
-
function parseJoinedRoomsResponse(value) {
|
|
379
|
-
if (!isRecord(value) ||
|
|
380
|
-
!Array.isArray(value.joined_rooms) ||
|
|
381
|
-
value.joined_rooms.length > MAX_MATRIX_JOINED_ROOMS) {
|
|
382
|
-
throw new Error("invalid Matrix joined-room response");
|
|
383
|
-
}
|
|
384
|
-
return value.joined_rooms.map((roomId) => {
|
|
385
|
-
try {
|
|
386
|
-
return validateMatrixRoomId(roomId, "joined-room response");
|
|
387
|
-
}
|
|
388
|
-
catch {
|
|
389
|
-
throw new Error("invalid Matrix joined-room response");
|
|
390
|
-
}
|
|
391
|
-
});
|
|
392
|
-
}
|
|
393
389
|
function isGoneRoomError(error) {
|
|
394
390
|
return (error instanceof MatrixError &&
|
|
395
391
|
(error.errcode === "M_NOT_FOUND" || error.errcode === "M_UNKNOWN"));
|
|
396
392
|
}
|
|
397
|
-
function throwWithCleanupDetail(error, roomId) {
|
|
398
|
-
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);
|
|
399
398
|
if (error instanceof Error) {
|
|
400
399
|
try {
|
|
401
400
|
Object.defineProperty(error, "cleanupIncomplete", {
|
|
@@ -408,9 +407,10 @@ function throwWithCleanupDetail(error, roomId) {
|
|
|
408
407
|
});
|
|
409
408
|
}
|
|
410
409
|
catch {
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
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;
|
|
414
414
|
}
|
|
415
415
|
throw error;
|
|
416
416
|
}
|
|
@@ -480,9 +480,6 @@ export class TeleCryptIOStorage {
|
|
|
480
480
|
for (const [stateKey, event] of byStateKey) {
|
|
481
481
|
if (present.has(`${eventType}\u0000${stateKey}`))
|
|
482
482
|
continue;
|
|
483
|
-
if (refreshed.length >= MAX_MATRIX_STATE_EVENTS) {
|
|
484
|
-
throw new Error("Matrix room state response is too large");
|
|
485
|
-
}
|
|
486
483
|
const original = event.getEffectiveEvent();
|
|
487
484
|
refreshed.push(new MatrixEvent({
|
|
488
485
|
...original,
|
|
@@ -582,8 +579,10 @@ export class TeleCryptIOStorage {
|
|
|
582
579
|
try {
|
|
583
580
|
client.stopClient();
|
|
584
581
|
}
|
|
585
|
-
catch {
|
|
586
|
-
|
|
582
|
+
catch (cleanupError) {
|
|
583
|
+
throw new AggregateError([error, cleanupError], "storage bootstrap and client cleanup failed", {
|
|
584
|
+
cause: error,
|
|
585
|
+
});
|
|
587
586
|
}
|
|
588
587
|
throw error;
|
|
589
588
|
}
|
|
@@ -784,7 +783,7 @@ export class TeleCryptIOStorage {
|
|
|
784
783
|
if (signal?.aborted && error instanceof StorageError) {
|
|
785
784
|
throw error;
|
|
786
785
|
}
|
|
787
|
-
throw new RecoverySetupAmbiguousError();
|
|
786
|
+
throw new RecoverySetupAmbiguousError(error);
|
|
788
787
|
}
|
|
789
788
|
if (status.defaultKeyId || status.ready || backupVersion !== null) {
|
|
790
789
|
throw new RecoveryAlreadyConfiguredError();
|
|
@@ -804,11 +803,11 @@ export class TeleCryptIOStorage {
|
|
|
804
803
|
authUploadDeviceSigningKeys: async () => undefined,
|
|
805
804
|
}), signal, "cross-signing bootstrap");
|
|
806
805
|
}
|
|
807
|
-
catch {
|
|
806
|
+
catch (error) {
|
|
808
807
|
// Cross-signing setup can commit server-side state before a transport
|
|
809
808
|
// failure is observed. Do not turn that uncertainty into a retry that
|
|
810
809
|
// could replace recovery state.
|
|
811
|
-
throw new RecoverySetupAmbiguousError();
|
|
810
|
+
throw new RecoverySetupAmbiguousError(error);
|
|
812
811
|
}
|
|
813
812
|
// CryptoApi mutations do not accept an AbortSignal. Once one has settled,
|
|
814
813
|
// do not cross the next one-way mutation boundary: the caller may already
|
|
@@ -819,8 +818,8 @@ export class TeleCryptIOStorage {
|
|
|
819
818
|
try {
|
|
820
819
|
generated = await withRecoveryCryptoDeadline(() => crypto.createRecoveryKeyFromPassphrase(), signal, "recovery-key generation");
|
|
821
820
|
}
|
|
822
|
-
catch {
|
|
823
|
-
throw new RecoverySetupAmbiguousError();
|
|
821
|
+
catch (error) {
|
|
822
|
+
throw new RecoverySetupAmbiguousError(error);
|
|
824
823
|
}
|
|
825
824
|
if (!generated.encodedPrivateKey) {
|
|
826
825
|
throw new RecoverySetupAmbiguousError();
|
|
@@ -873,15 +872,22 @@ export class TeleCryptIOStorage {
|
|
|
873
872
|
throw new RecoverySetupAmbiguousError();
|
|
874
873
|
}
|
|
875
874
|
}
|
|
876
|
-
catch (
|
|
877
|
-
if (
|
|
878
|
-
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;
|
|
879
883
|
// A failed status probe cannot prove that the one-way operation did
|
|
880
884
|
// not commit. Never turn that uncertainty into an apparently safe
|
|
881
885
|
// retry that could replace account recovery state.
|
|
882
|
-
throw new RecoverySetupAmbiguousError(
|
|
886
|
+
throw new RecoverySetupAmbiguousError(new AggregateError([error, statusError], "recovery setup and status verification failed", {
|
|
887
|
+
cause: error,
|
|
888
|
+
}));
|
|
883
889
|
}
|
|
884
|
-
throw new RecoverySetupError();
|
|
890
|
+
throw new RecoverySetupError(error);
|
|
885
891
|
}
|
|
886
892
|
try {
|
|
887
893
|
const [rawAfterStatus, rawAfterBackup] = await Promise.all([
|
|
@@ -896,7 +902,7 @@ export class TeleCryptIOStorage {
|
|
|
896
902
|
catch (error) {
|
|
897
903
|
if (error instanceof RecoverySetupAmbiguousError)
|
|
898
904
|
throw error;
|
|
899
|
-
throw new RecoverySetupAmbiguousError();
|
|
905
|
+
throw new RecoverySetupAmbiguousError(error);
|
|
900
906
|
}
|
|
901
907
|
// This is the last boundary before exposing the only recovery credential
|
|
902
908
|
// to the caller. Cancellation here must not return a key that the caller
|
|
@@ -926,8 +932,8 @@ export class TeleCryptIOStorage {
|
|
|
926
932
|
const backupVersion = validateBackupVersion(rawBackupVersion);
|
|
927
933
|
return storageStatus.ready && backupVersion !== null;
|
|
928
934
|
}
|
|
929
|
-
catch {
|
|
930
|
-
throw new RecoverySetupAmbiguousError();
|
|
935
|
+
catch (error) {
|
|
936
|
+
throw new RecoverySetupAmbiguousError(error);
|
|
931
937
|
}
|
|
932
938
|
}
|
|
933
939
|
/** Returns one consistent recovery topology snapshot without exposing raw crypto callbacks. */
|
|
@@ -967,8 +973,8 @@ export class TeleCryptIOStorage {
|
|
|
967
973
|
backupVersion,
|
|
968
974
|
};
|
|
969
975
|
}
|
|
970
|
-
catch {
|
|
971
|
-
throw new RecoverySetupAmbiguousError();
|
|
976
|
+
catch (error) {
|
|
977
|
+
throw new RecoverySetupAmbiguousError(error);
|
|
972
978
|
}
|
|
973
979
|
}, signal);
|
|
974
980
|
}
|
|
@@ -998,8 +1004,8 @@ export class TeleCryptIOStorage {
|
|
|
998
1004
|
try {
|
|
999
1005
|
privateKey = decodeRecoveryKey(recoveryKey);
|
|
1000
1006
|
}
|
|
1001
|
-
catch {
|
|
1002
|
-
throw new RecoveryRestoreError();
|
|
1007
|
+
catch (error) {
|
|
1008
|
+
throw new RecoveryRestoreError(error);
|
|
1003
1009
|
}
|
|
1004
1010
|
if (signal?.aborted)
|
|
1005
1011
|
throw new StorageError("operation cancelled");
|
|
@@ -1012,7 +1018,7 @@ export class TeleCryptIOStorage {
|
|
|
1012
1018
|
catch (error) {
|
|
1013
1019
|
if (error instanceof RecoveryRestoreAmbiguousError)
|
|
1014
1020
|
throw error;
|
|
1015
|
-
throw new RecoveryRestoreError();
|
|
1021
|
+
throw new RecoveryRestoreError(error);
|
|
1016
1022
|
}
|
|
1017
1023
|
try {
|
|
1018
1024
|
const result = await withRecoveryCryptoDeadline(() => crypto.restoreKeyBackup(), signal, "key-backup restore");
|
|
@@ -1026,7 +1032,9 @@ export class TeleCryptIOStorage {
|
|
|
1026
1032
|
catch (error) {
|
|
1027
1033
|
if (error instanceof RecoveryRestoreAmbiguousError)
|
|
1028
1034
|
throw error;
|
|
1029
|
-
|
|
1035
|
+
if (error instanceof RecoveryRestoreError)
|
|
1036
|
+
throw error;
|
|
1037
|
+
throw new RecoveryRestoreError(error);
|
|
1030
1038
|
}
|
|
1031
1039
|
}, signal);
|
|
1032
1040
|
}
|
|
@@ -1090,6 +1098,7 @@ export class TeleCryptIOStorage {
|
|
|
1090
1098
|
}
|
|
1091
1099
|
async waitForTreeSpace(roomId, operation, signal) {
|
|
1092
1100
|
const deadline = Date.now() + TREE_SYNC_TIMEOUT_MS;
|
|
1101
|
+
let lastError;
|
|
1093
1102
|
for (;;) {
|
|
1094
1103
|
if (signal?.aborted)
|
|
1095
1104
|
throw new StorageError("operation cancelled");
|
|
@@ -1098,12 +1107,15 @@ export class TeleCryptIOStorage {
|
|
|
1098
1107
|
if (tree)
|
|
1099
1108
|
return this.decorateTreeSpace(tree);
|
|
1100
1109
|
}
|
|
1101
|
-
catch {
|
|
1110
|
+
catch (error) {
|
|
1111
|
+
lastError = error;
|
|
1102
1112
|
// The room can be present before its complete state has been applied
|
|
1103
1113
|
// to this client's local store. Keep polling the exact returned ID.
|
|
1104
1114
|
}
|
|
1105
1115
|
if (Date.now() >= deadline) {
|
|
1106
|
-
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
|
+
});
|
|
1107
1119
|
}
|
|
1108
1120
|
await new Promise((resolve, reject) => {
|
|
1109
1121
|
let timer;
|
|
@@ -1144,12 +1156,14 @@ export class TeleCryptIOStorage {
|
|
|
1144
1156
|
const cleanupSignal = new AbortController().signal;
|
|
1145
1157
|
let incomplete = false;
|
|
1146
1158
|
let safeToForget = true;
|
|
1159
|
+
const failures = [];
|
|
1147
1160
|
try {
|
|
1148
1161
|
await TeleCryptIOStorage.withTimeout(withMatrixMutationAbort(this.client, () => this.client.leave(roomId), cleanupSignal), CLEANUP_TIMEOUT_MS, "room leave cleanup", cleanupSignal);
|
|
1149
1162
|
}
|
|
1150
1163
|
catch (error) {
|
|
1151
1164
|
if (!isGoneRoomError(error)) {
|
|
1152
1165
|
incomplete = true;
|
|
1166
|
+
failures.push(error);
|
|
1153
1167
|
// A timed-out or failed leave may still be in flight. Do not race a
|
|
1154
1168
|
// forget request against it; the caller must retry cleanup later.
|
|
1155
1169
|
safeToForget = false;
|
|
@@ -1160,12 +1174,17 @@ export class TeleCryptIOStorage {
|
|
|
1160
1174
|
await TeleCryptIOStorage.withTimeout(withMatrixMutationAbort(this.client, () => this.client.forget(roomId), cleanupSignal), CLEANUP_TIMEOUT_MS, "room forget cleanup", cleanupSignal);
|
|
1161
1175
|
}
|
|
1162
1176
|
catch (error) {
|
|
1163
|
-
if (!isGoneRoomError(error))
|
|
1177
|
+
if (!isGoneRoomError(error)) {
|
|
1164
1178
|
incomplete = true;
|
|
1179
|
+
failures.push(error);
|
|
1180
|
+
}
|
|
1165
1181
|
}
|
|
1166
1182
|
}
|
|
1167
1183
|
if (incomplete) {
|
|
1168
|
-
|
|
1184
|
+
const cause = failures.length === 1
|
|
1185
|
+
? failures[0]
|
|
1186
|
+
: new AggregateError(failures, "room cleanup failed");
|
|
1187
|
+
throw new RoomCleanupIncompleteError(roomId, cause);
|
|
1169
1188
|
}
|
|
1170
1189
|
}
|
|
1171
1190
|
async removeRelationLink(roomId, eventType, stateKey, signal) {
|
|
@@ -1203,14 +1222,14 @@ export class TeleCryptIOStorage {
|
|
|
1203
1222
|
// retryable generic error that could orphan a second room.
|
|
1204
1223
|
if (error instanceof RoomCreationAmbiguousError)
|
|
1205
1224
|
throw error;
|
|
1206
|
-
throw new RoomCreationAmbiguousError(operation);
|
|
1225
|
+
throw new RoomCreationAmbiguousError(operation, error);
|
|
1207
1226
|
});
|
|
1208
1227
|
let roomId;
|
|
1209
1228
|
try {
|
|
1210
1229
|
roomId = validateMatrixRoomId(response?.room_id, "room creation response");
|
|
1211
1230
|
}
|
|
1212
|
-
catch {
|
|
1213
|
-
throw new RoomCreationAmbiguousError(operation);
|
|
1231
|
+
catch (error) {
|
|
1232
|
+
throw new RoomCreationAmbiguousError(operation, error);
|
|
1214
1233
|
}
|
|
1215
1234
|
try {
|
|
1216
1235
|
return await this.waitForTreeSpace(roomId, operation, signal);
|
|
@@ -1219,13 +1238,13 @@ export class TeleCryptIOStorage {
|
|
|
1219
1238
|
try {
|
|
1220
1239
|
await this.cleanupCreatedRoom(roomId, signal);
|
|
1221
1240
|
}
|
|
1222
|
-
catch {
|
|
1223
|
-
throwWithCleanupDetail(error, roomId);
|
|
1241
|
+
catch (cleanupError) {
|
|
1242
|
+
throwWithCleanupDetail(error, roomId, cleanupError);
|
|
1224
1243
|
}
|
|
1225
1244
|
// The room existed even if cleanup happened to succeed. Keep the
|
|
1226
1245
|
// caller on the reconciliation path: a retry based on a local timeout
|
|
1227
1246
|
// or malformed response must never assume that no room was created.
|
|
1228
|
-
throw new RoomCreationAmbiguousError(operation);
|
|
1247
|
+
throw new RoomCreationAmbiguousError(operation, error);
|
|
1229
1248
|
}
|
|
1230
1249
|
}
|
|
1231
1250
|
/**
|
|
@@ -1239,8 +1258,6 @@ export class TeleCryptIOStorage {
|
|
|
1239
1258
|
if (signal?.aborted)
|
|
1240
1259
|
throw new StorageError("operation cancelled");
|
|
1241
1260
|
const rooms = this.client.getRooms();
|
|
1242
|
-
if (rooms.length > MAX_MATRIX_JOINED_ROOMS)
|
|
1243
|
-
throw new StorageError("room list is too large");
|
|
1244
1261
|
const trees = [];
|
|
1245
1262
|
for (const room of rooms) {
|
|
1246
1263
|
if (signal?.aborted)
|
|
@@ -1295,6 +1312,7 @@ export class TeleCryptIOStorage {
|
|
|
1295
1312
|
// signal, then report incomplete cleanup if they cannot be verified.
|
|
1296
1313
|
const compensationSignal = new AbortController().signal;
|
|
1297
1314
|
let rollbackIncomplete = false;
|
|
1315
|
+
const cleanupFailures = [];
|
|
1298
1316
|
// A sendStateEvent rejection is ambiguous: the homeserver may have
|
|
1299
1317
|
// committed the event before the client observed a transport error.
|
|
1300
1318
|
// Refresh the parent before probing even when no event ID was
|
|
@@ -1318,6 +1336,7 @@ export class TeleCryptIOStorage {
|
|
|
1318
1336
|
Object.keys(content).length > 0);
|
|
1319
1337
|
})) {
|
|
1320
1338
|
rollbackIncomplete = true;
|
|
1339
|
+
cleanupFailures.push(new Error("parent link rollback could not be verified"));
|
|
1321
1340
|
}
|
|
1322
1341
|
}
|
|
1323
1342
|
else {
|
|
@@ -1325,12 +1344,15 @@ export class TeleCryptIOStorage {
|
|
|
1325
1344
|
const removed = await this.removeChildLink(currentParent.id, tree.id, compensationSignal);
|
|
1326
1345
|
// Without an event ID, a missing link after one refresh does not
|
|
1327
1346
|
// prove that a request which timed out will never commit.
|
|
1328
|
-
if (!removed)
|
|
1347
|
+
if (!removed) {
|
|
1329
1348
|
rollbackIncomplete = true;
|
|
1349
|
+
cleanupFailures.push(new Error("parent link rollback could not be verified"));
|
|
1350
|
+
}
|
|
1330
1351
|
}
|
|
1331
1352
|
}
|
|
1332
|
-
catch {
|
|
1353
|
+
catch (cleanupError) {
|
|
1333
1354
|
rollbackIncomplete = true;
|
|
1355
|
+
cleanupFailures.push(cleanupError);
|
|
1334
1356
|
}
|
|
1335
1357
|
}
|
|
1336
1358
|
if (childLinkAttempted) {
|
|
@@ -1350,27 +1372,35 @@ export class TeleCryptIOStorage {
|
|
|
1350
1372
|
Object.keys(content).length > 0);
|
|
1351
1373
|
})) {
|
|
1352
1374
|
rollbackIncomplete = true;
|
|
1375
|
+
cleanupFailures.push(new Error("child link rollback could not be verified"));
|
|
1353
1376
|
}
|
|
1354
1377
|
}
|
|
1355
1378
|
else {
|
|
1356
1379
|
await this.refreshRoomState(tree.id, { signal: compensationSignal });
|
|
1357
1380
|
const removed = await this.removeParentLink(tree.id, currentParent.id, compensationSignal);
|
|
1358
|
-
if (!removed)
|
|
1381
|
+
if (!removed) {
|
|
1359
1382
|
rollbackIncomplete = true;
|
|
1383
|
+
cleanupFailures.push(new Error("child link rollback could not be verified"));
|
|
1384
|
+
}
|
|
1360
1385
|
}
|
|
1361
1386
|
}
|
|
1362
|
-
catch {
|
|
1387
|
+
catch (cleanupError) {
|
|
1363
1388
|
rollbackIncomplete = true;
|
|
1389
|
+
cleanupFailures.push(cleanupError);
|
|
1364
1390
|
}
|
|
1365
1391
|
}
|
|
1366
1392
|
try {
|
|
1367
1393
|
await this.cleanupCreatedRoom(tree.id, effectiveSignal);
|
|
1368
1394
|
}
|
|
1369
|
-
catch {
|
|
1370
|
-
|
|
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);
|
|
1371
1403
|
}
|
|
1372
|
-
if (rollbackIncomplete)
|
|
1373
|
-
throwWithCleanupDetail(error, tree.id);
|
|
1374
1404
|
throw error;
|
|
1375
1405
|
}
|
|
1376
1406
|
}, signal);
|
|
@@ -1435,19 +1465,6 @@ export class TeleCryptIOStorage {
|
|
|
1435
1465
|
role: roleFor(e.state_key),
|
|
1436
1466
|
}));
|
|
1437
1467
|
}
|
|
1438
|
-
/**
|
|
1439
|
-
* Returns the homeserver's complete joined-room inventory. Local sync room
|
|
1440
|
-
* lists can be truncated by initialSyncLimit and are not an authoritative
|
|
1441
|
-
* basis for deletion safety.
|
|
1442
|
-
*/
|
|
1443
|
-
async listJoinedRoomIds(options) {
|
|
1444
|
-
const body = await requireMatrixHttpTransport(this.client).authedRequest(Method.Get, "/joined_rooms", undefined, undefined, {
|
|
1445
|
-
prefix: ClientPrefix.V3,
|
|
1446
|
-
localTimeoutMs: matrixRequestTimeout(options),
|
|
1447
|
-
abortSignal: matrixRequestSignal(options),
|
|
1448
|
-
});
|
|
1449
|
-
return parseJoinedRoomsResponse(body);
|
|
1450
|
-
}
|
|
1451
1468
|
/** Reads one room membership from the homeserver, not the lagging local sync store. */
|
|
1452
1469
|
async getRoomMembership(roomId, userId = this.client.getUserId(), options) {
|
|
1453
1470
|
if (!userId)
|
|
@@ -1481,17 +1498,17 @@ export class TeleCryptIOStorage {
|
|
|
1481
1498
|
// mutation outcome is safe to propagate unchanged.
|
|
1482
1499
|
if (error instanceof MutationOutcomeUnknownError)
|
|
1483
1500
|
throw error;
|
|
1484
|
-
throw new MutationOutcomeUnknownError("file upload");
|
|
1501
|
+
throw new MutationOutcomeUnknownError("file upload", { cause: error });
|
|
1485
1502
|
}
|
|
1486
1503
|
if (signal?.aborted)
|
|
1487
1504
|
throw new StorageError("operation cancelled");
|
|
1488
1505
|
try {
|
|
1489
1506
|
return validateMatrixEventId(response?.event_id, "file upload response event ID");
|
|
1490
1507
|
}
|
|
1491
|
-
catch {
|
|
1508
|
+
catch (error) {
|
|
1492
1509
|
// An invalid response can still follow a committed event. Treat it as
|
|
1493
1510
|
// unknown rather than presenting a retryable generic upload failure.
|
|
1494
|
-
throw new MutationOutcomeUnknownError("file upload");
|
|
1511
|
+
throw new MutationOutcomeUnknownError("file upload", { cause: error });
|
|
1495
1512
|
}
|
|
1496
1513
|
}
|
|
1497
1514
|
async downloadFile(branch, signal) {
|
|
@@ -1501,26 +1518,38 @@ export class TeleCryptIOStorage {
|
|
|
1501
1518
|
try {
|
|
1502
1519
|
({ info } = await branch.getFileInfo());
|
|
1503
1520
|
}
|
|
1504
|
-
catch {
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
//
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
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.
|
|
1513
1542
|
if (!info || typeof info.url !== "string") {
|
|
1514
|
-
throw new
|
|
1515
|
-
}
|
|
1516
|
-
const declaredSize = info.size;
|
|
1517
|
-
if (declaredSize !== undefined &&
|
|
1518
|
-
(typeof declaredSize !== "number" ||
|
|
1519
|
-
!Number.isSafeInteger(declaredSize) ||
|
|
1520
|
-
declaredSize < 0 ||
|
|
1521
|
-
declaredSize > MAX_MEDIA_FILE_BYTES)) {
|
|
1522
|
-
throw new FileTooLargeError();
|
|
1543
|
+
throw new StorageError("file metadata is missing a media URL");
|
|
1523
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";
|
|
1524
1553
|
const mxcUrl = info.url;
|
|
1525
1554
|
const clientAny = this.client;
|
|
1526
1555
|
const accessToken = clientAny.getAccessToken();
|
|
@@ -1529,8 +1558,8 @@ export class TeleCryptIOStorage {
|
|
|
1529
1558
|
try {
|
|
1530
1559
|
trustedOrigin = validateHomeserverUrl(clientAny.getHomeserverUrl()).origin;
|
|
1531
1560
|
}
|
|
1532
|
-
catch {
|
|
1533
|
-
throw new Error("failed to build media URL");
|
|
1561
|
+
catch (error) {
|
|
1562
|
+
throw new Error("failed to build media URL", { cause: error });
|
|
1534
1563
|
}
|
|
1535
1564
|
const downloadUrl = clientAny.mxcUrlToHttp(mxcUrl, undefined, undefined, undefined, false, true, true);
|
|
1536
1565
|
if (!downloadUrl)
|
|
@@ -1539,8 +1568,8 @@ export class TeleCryptIOStorage {
|
|
|
1539
1568
|
try {
|
|
1540
1569
|
currentUrl = new URL(downloadUrl);
|
|
1541
1570
|
}
|
|
1542
|
-
catch {
|
|
1543
|
-
throw new Error("failed to build media URL");
|
|
1571
|
+
catch (error) {
|
|
1572
|
+
throw new Error("failed to build media URL", { cause: error });
|
|
1544
1573
|
}
|
|
1545
1574
|
if ((currentUrl.protocol !== "http:" && currentUrl.protocol !== "https:") ||
|
|
1546
1575
|
currentUrl.username !== "" ||
|
|
@@ -1563,67 +1592,92 @@ export class TeleCryptIOStorage {
|
|
|
1563
1592
|
redirect: "manual",
|
|
1564
1593
|
signal: controller.signal,
|
|
1565
1594
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1566
|
-
}), controller.signal, () =>
|
|
1595
|
+
}), controller.signal, () => signal?.aborted
|
|
1567
1596
|
? new StorageError("operation cancelled")
|
|
1568
1597
|
: new Error("media download timed out"));
|
|
1569
1598
|
if ([301, 302, 303, 307, 308].includes(res.status)) {
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
// The redirect response is already being rejected or discarded.
|
|
1575
|
-
}
|
|
1576
|
-
if (redirect >= MAX_MEDIA_REDIRECTS)
|
|
1577
|
-
throw new Error("media download redirect limit exceeded");
|
|
1578
|
-
const location = res.headers.get("location");
|
|
1579
|
-
if (!location)
|
|
1580
|
-
throw new Error("media download redirect missing location");
|
|
1581
|
-
try {
|
|
1582
|
-
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");
|
|
1583
1603
|
}
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
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
|
+
}
|
|
1594
1626
|
}
|
|
1627
|
+
await cancelResponseBody(res, "media redirect", redirectError);
|
|
1628
|
+
if (redirectError)
|
|
1629
|
+
throw redirectError;
|
|
1630
|
+
currentUrl = nextUrl;
|
|
1595
1631
|
continue;
|
|
1596
1632
|
}
|
|
1597
1633
|
if (!res.ok) {
|
|
1634
|
+
const statusFailure = new Error(`media download failed: ${res.status}`);
|
|
1635
|
+
let detail = "";
|
|
1598
1636
|
try {
|
|
1599
|
-
|
|
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));
|
|
1600
1643
|
}
|
|
1601
|
-
catch {
|
|
1602
|
-
|
|
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 });
|
|
1603
1652
|
}
|
|
1604
|
-
throw new Error(
|
|
1653
|
+
throw new Error(`${statusFailure.message}${detail === "" ? "" : `: ${detail}`}`);
|
|
1605
1654
|
}
|
|
1606
1655
|
if (!res.body) {
|
|
1607
|
-
|
|
1608
|
-
// Content-Length is only advisory and must not authorize an
|
|
1609
|
-
// unbounded arrayBuffer() allocation.
|
|
1610
|
-
throw new Error("media download has no bounded response body");
|
|
1656
|
+
throw new Error("media download has no response body");
|
|
1611
1657
|
}
|
|
1612
|
-
const body = await
|
|
1613
|
-
if (body.truncated)
|
|
1614
|
-
throw new FileTooLargeError();
|
|
1658
|
+
const body = await readMediaResponseBody(res, MAX_MEDIA_FILE_BYTES, controller.signal);
|
|
1615
1659
|
ciphertext = body.bytes.buffer;
|
|
1616
1660
|
break;
|
|
1617
1661
|
}
|
|
1618
1662
|
}
|
|
1619
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
|
+
}
|
|
1620
1674
|
if (signal?.aborted)
|
|
1621
|
-
throw new StorageError("operation cancelled");
|
|
1675
|
+
throw new StorageError("operation cancelled", { cause: error });
|
|
1622
1676
|
if (controller.signal.aborted)
|
|
1623
|
-
throw new Error("media download timed out");
|
|
1677
|
+
throw new Error("media download timed out", { cause: error });
|
|
1624
1678
|
if (error instanceof FileTooLargeError)
|
|
1625
1679
|
throw error;
|
|
1626
|
-
throw new Error("media download failed");
|
|
1680
|
+
throw new Error("media download failed", { cause: error });
|
|
1627
1681
|
}
|
|
1628
1682
|
finally {
|
|
1629
1683
|
clearTimeout(timeout);
|
|
@@ -1631,41 +1685,20 @@ export class TeleCryptIOStorage {
|
|
|
1631
1685
|
}
|
|
1632
1686
|
let data;
|
|
1633
1687
|
try {
|
|
1634
|
-
// matrix-encrypt-attachment's supported AES-CTR v1/v2 formats are
|
|
1635
|
-
// size-preserving: decryption allocates exactly the ciphertext length.
|
|
1636
|
-
// The ciphertext reader above therefore bounds the allocation before
|
|
1637
|
-
// entering the non-streaming third-party decryptor; the authenticated
|
|
1638
|
-
// event size is also rejected before decryption when present.
|
|
1639
|
-
if (ciphertext.byteLength > MAX_MEDIA_FILE_BYTES)
|
|
1640
|
-
throw new FileTooLargeError();
|
|
1641
1688
|
data = await decryptAttachment(ciphertext, info);
|
|
1642
1689
|
}
|
|
1643
1690
|
catch (error) {
|
|
1644
1691
|
if (error instanceof FileTooLargeError)
|
|
1645
1692
|
throw error;
|
|
1646
|
-
throw new Error("media decryption failed");
|
|
1693
|
+
throw new Error("media decryption failed", { cause: error });
|
|
1647
1694
|
}
|
|
1695
|
+
if (data.byteLength > MAX_MEDIA_FILE_BYTES)
|
|
1696
|
+
throw new FileTooLargeError();
|
|
1648
1697
|
if (signal?.aborted)
|
|
1649
1698
|
throw new StorageError("operation cancelled");
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
throw new StorageError("operation cancelled");
|
|
1653
|
-
if (!isRecord(eventContent))
|
|
1654
|
-
throw new Error("media metadata is invalid");
|
|
1655
|
-
const infoBlock = isRecord(eventContent["info"])
|
|
1656
|
-
? eventContent["info"]
|
|
1657
|
-
: undefined;
|
|
1658
|
-
const mimetype = typeof infoBlock?.["mimetype"] === "string"
|
|
1659
|
-
? infoBlock["mimetype"]
|
|
1660
|
-
: "application/octet-stream";
|
|
1661
|
-
try {
|
|
1662
|
-
validateMimetype(mimetype);
|
|
1699
|
+
if (declaredSize !== null && data.byteLength !== declaredSize) {
|
|
1700
|
+
throw new StorageError("media metadata size does not match decrypted file");
|
|
1663
1701
|
}
|
|
1664
|
-
catch {
|
|
1665
|
-
throw new Error("media metadata is invalid");
|
|
1666
|
-
}
|
|
1667
|
-
if (data.byteLength > MAX_MEDIA_FILE_BYTES)
|
|
1668
|
-
throw new FileTooLargeError();
|
|
1669
1702
|
if (signal?.aborted)
|
|
1670
1703
|
throw new StorageError("operation cancelled");
|
|
1671
1704
|
return { data, mimetype };
|