@telecrypt-io/storage 0.7.2 → 0.8.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 +9 -0
- package/dist/TeleCryptIOStorage.d.ts +26 -1
- package/dist/TeleCryptIOStorage.js +295 -25
- package/dist/core/operations.js +148 -116
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -62,6 +62,15 @@ The SDK maps the file tree to Matrix primitives defined by MSC3089:
|
|
|
62
62
|
Vault, folder, and file IDs are opaque Matrix room and event IDs. Names are display labels: two
|
|
63
63
|
objects with the same name are still distinct, and a name is not an idempotency key.
|
|
64
64
|
|
|
65
|
+
Vault and folder names, and file names, are stored in encrypted room messages. Matrix room names
|
|
66
|
+
remain the generic `Encrypted storage`; file-listing state stores only an event pointer, and media
|
|
67
|
+
uploads omit the original filename. Use the asynchronous operations in `@telecrypt-io/storage/core`
|
|
68
|
+
to read names. The synchronous Matrix SDK tree helper cannot decrypt on demand and exposes a generic
|
|
69
|
+
file label until an async operation resolves the name. This is a TeleCrypt metadata format: standard
|
|
70
|
+
MSC3089 clients will not display the private names or understand these file indexes. SDK 0.8 starts
|
|
71
|
+
this format without a reader or migration for trees written by older SDK versions; those old trees
|
|
72
|
+
may be ignored or removed.
|
|
73
|
+
|
|
65
74
|
File bytes are encrypted in the client before upload with Matrix attachment encryption (AES-CTR
|
|
66
75
|
and a per-file key distributed through the room's Megolm session). The server stores encrypted
|
|
67
76
|
media and Matrix events, but encryption does not hide all room, event, or other metadata from the
|
|
@@ -26,8 +26,9 @@ export interface TreeSpace {
|
|
|
26
26
|
}
|
|
27
27
|
export interface FileBranch {
|
|
28
28
|
readonly id: string;
|
|
29
|
+
readonly roomId: string;
|
|
29
30
|
/** Current MSC3089 branch/listing event. Its event ID is distinct from `id` (the state key). */
|
|
30
|
-
readonly indexEvent?: Pick<MatrixEvent, "getId">;
|
|
31
|
+
readonly indexEvent?: Pick<MatrixEvent, "getId" | "getContent" | "getSender" | "isRedacted">;
|
|
31
32
|
/** Matrix SDK keeps a redacted branch in state as an inactive object. */
|
|
32
33
|
readonly isActive?: boolean;
|
|
33
34
|
getName(): string;
|
|
@@ -144,9 +145,32 @@ export declare function withTreeMutation<T>(client: MatrixClient, operation: (si
|
|
|
144
145
|
export declare class TeleCryptIOStorage {
|
|
145
146
|
private client;
|
|
146
147
|
private readonly decoratedTreeSpaces;
|
|
148
|
+
private readonly decoratedFileBranches;
|
|
149
|
+
private readonly treeNames;
|
|
150
|
+
private readonly fileNames;
|
|
147
151
|
constructor(client: MatrixClient);
|
|
148
152
|
/** The underlying matrix-js-sdk client (e.g. to stop it, or for advanced/interop use). */
|
|
149
153
|
getClient(): MatrixClient;
|
|
154
|
+
private fileNameKey;
|
|
155
|
+
private getStateEvent;
|
|
156
|
+
private getTreeOwnerId;
|
|
157
|
+
private sendEncryptedMetadataMessage;
|
|
158
|
+
private readEncryptedMetadataMessage;
|
|
159
|
+
private writeTreeName;
|
|
160
|
+
/** Resolves an encrypted vault/folder name through its pointer state event. */
|
|
161
|
+
getTreeName(roomId: string, options?: MatrixRequestOptions): Promise<string>;
|
|
162
|
+
private writeFileName;
|
|
163
|
+
/** Resolves an encrypted file name from the MSC3089 index pointer. */
|
|
164
|
+
getFileName(treeId: string, fileId: string, options?: MatrixRequestOptions & {
|
|
165
|
+
refreshState?: boolean;
|
|
166
|
+
}): Promise<string>;
|
|
167
|
+
/** Returns the current file metadata pointer, including on inactive branches. */
|
|
168
|
+
getFileMetadataEventId(treeId: string, fileId: string, options?: MatrixRequestOptions): Promise<string | null>;
|
|
169
|
+
/** Fetches and validates a current encrypted rename event, allowing retries after redaction. */
|
|
170
|
+
getFileRenameMetadataEvent(treeId: string, fileId: string, metadataEventId: string): Promise<MatrixEvent>;
|
|
171
|
+
/** Fetches the original encrypted file message directly by event ID. */
|
|
172
|
+
getOriginalFileEvent(treeId: string, fileId: string, allowRedacted?: boolean): Promise<MatrixEvent>;
|
|
173
|
+
private createEncryptedFile;
|
|
150
174
|
/**
|
|
151
175
|
* Refreshes a room's current state from the homeserver. The background sync
|
|
152
176
|
* loop is intentionally asynchronous, so a short-lived CLI process can
|
|
@@ -249,6 +273,7 @@ export declare class TeleCryptIOStorage {
|
|
|
249
273
|
* which refreshes the exact parent/child state before reporting success.
|
|
250
274
|
*/
|
|
251
275
|
private decorateTreeSpace;
|
|
276
|
+
private decorateFileBranch;
|
|
252
277
|
/** Cleans up only a room created by this operation and reports incomplete cleanup. */
|
|
253
278
|
private cleanupCreatedRoom;
|
|
254
279
|
private removeRelationLink;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ClientEvent, createClient, EventType, MatrixEvent, MatrixError, Preset, RoomCreateTypeField, RoomType, SyncState, UNSTABLE_MSC3088_ENABLED, UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3089_TREE_SUBTYPE, } from "matrix-js-sdk";
|
|
1
|
+
import { ClientEvent, createClient, EventType, MatrixEvent, MatrixError, MsgType, Preset, RoomCreateTypeField, RoomType, SyncState, UNSTABLE_MSC3088_ENABLED, UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3089_LEAF, UNSTABLE_MSC3089_TREE_SUBTYPE, } from "matrix-js-sdk";
|
|
2
2
|
import { Method } from "matrix-js-sdk/lib/http-api/method.js";
|
|
3
3
|
import { ClientPrefix } from "matrix-js-sdk/lib/http-api/prefix.js";
|
|
4
4
|
import { encryptAttachment, decryptAttachment } from "matrix-encrypt-attachment";
|
|
@@ -9,6 +9,10 @@ import { cancelResponseBody, raceWithAbort, readMediaResponseBody, readResponseB
|
|
|
9
9
|
import { sanitizeDiagnosticText } from "./core/oidc.js";
|
|
10
10
|
import { validateName } from "./core/validation.js";
|
|
11
11
|
import { isTreeDeleted } from "./deletion-markers.js";
|
|
12
|
+
const STORAGE_METADATA_EVENT_TYPE = "io.telecrypt.storage.metadata";
|
|
13
|
+
const STORAGE_METADATA_MSGTYPE = "io.telecrypt.storage.metadata";
|
|
14
|
+
const GENERIC_TREE_NAME = "Encrypted storage";
|
|
15
|
+
const GENERIC_FILE_NAME = "Encrypted file";
|
|
12
16
|
const recoveryMutationQueues = new WeakMap();
|
|
13
17
|
const treeMutationQueues = new WeakMap();
|
|
14
18
|
const TREE_SYNC_TIMEOUT_MS = 15000;
|
|
@@ -235,12 +239,6 @@ export function readFileEventMetadata(content) {
|
|
|
235
239
|
function isRecord(value) {
|
|
236
240
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
237
241
|
}
|
|
238
|
-
async function isUndecryptableFilePlaceholder(branch) {
|
|
239
|
-
// matrix-js-sdk replaces failed encrypted event content with m.bad.encrypted;
|
|
240
|
-
// its public failure flag is the authoritative state and avoids guessing
|
|
241
|
-
// from that display content (or misclassifying malformed plaintext events).
|
|
242
|
-
return (await branch.getFileEvent()).isDecryptionFailure();
|
|
243
|
-
}
|
|
244
242
|
function validateSecretStorageStatus(value) {
|
|
245
243
|
if (!isRecord(value))
|
|
246
244
|
throw new RecoverySetupAmbiguousError();
|
|
@@ -409,6 +407,9 @@ export async function withTreeMutation(client, operation, signal) {
|
|
|
409
407
|
export class TeleCryptIOStorage {
|
|
410
408
|
client;
|
|
411
409
|
decoratedTreeSpaces = new WeakSet();
|
|
410
|
+
decoratedFileBranches = new WeakMap();
|
|
411
|
+
treeNames = new Map();
|
|
412
|
+
fileNames = new Map();
|
|
412
413
|
constructor(client) {
|
|
413
414
|
this.client = client;
|
|
414
415
|
// Advanced callers may construct a MatrixClient themselves. The SDK does
|
|
@@ -420,6 +421,229 @@ export class TeleCryptIOStorage {
|
|
|
420
421
|
getClient() {
|
|
421
422
|
return this.client;
|
|
422
423
|
}
|
|
424
|
+
fileNameKey(treeId, fileId) {
|
|
425
|
+
return `${treeId}\u0000${fileId}`;
|
|
426
|
+
}
|
|
427
|
+
getStateEvent(roomId, eventType, stateKey) {
|
|
428
|
+
const state = this.client.getRoom(roomId)?.currentState;
|
|
429
|
+
const raw = state?.getStateEvents(eventType, stateKey);
|
|
430
|
+
if (Array.isArray(raw))
|
|
431
|
+
return raw[0] ?? null;
|
|
432
|
+
return raw ?? null;
|
|
433
|
+
}
|
|
434
|
+
getTreeOwnerId(roomId) {
|
|
435
|
+
const create = this.getStateEvent(roomId, EventType.RoomCreate, "");
|
|
436
|
+
const ownerId = create?.getSender();
|
|
437
|
+
if (!ownerId)
|
|
438
|
+
throw new StorageError("storage owner is unavailable");
|
|
439
|
+
return ownerId;
|
|
440
|
+
}
|
|
441
|
+
async sendEncryptedMetadataMessage(roomId, content) {
|
|
442
|
+
const response = await this.client.sendMessage(roomId, content);
|
|
443
|
+
return validateMatrixEventId(response?.event_id, "storage metadata response event ID");
|
|
444
|
+
}
|
|
445
|
+
async readEncryptedMetadataMessage(roomId, eventId, allowRedacted = false) {
|
|
446
|
+
const rawEvent = await this.client.fetchRoomEvent(roomId, eventId);
|
|
447
|
+
const event = this.client.getEventMapper()(rawEvent);
|
|
448
|
+
await this.client.decryptEventIfNeeded(event);
|
|
449
|
+
if (event.isDecryptionFailure())
|
|
450
|
+
throw new UndecryptableFileError();
|
|
451
|
+
if (allowRedacted &&
|
|
452
|
+
event.getId() === eventId &&
|
|
453
|
+
event.getRoomId() === roomId &&
|
|
454
|
+
event.getSender() === this.getTreeOwnerId(roomId) &&
|
|
455
|
+
event.isRedacted()) {
|
|
456
|
+
return event;
|
|
457
|
+
}
|
|
458
|
+
if (event.getId() !== eventId ||
|
|
459
|
+
event.getRoomId() !== roomId ||
|
|
460
|
+
event.getSender() !== this.getTreeOwnerId(roomId) ||
|
|
461
|
+
event.getWireType() !== EventType.RoomMessageEncrypted ||
|
|
462
|
+
event.getType() !== EventType.RoomMessage) {
|
|
463
|
+
throw new StorageError("encrypted storage metadata is invalid or unavailable");
|
|
464
|
+
}
|
|
465
|
+
return event;
|
|
466
|
+
}
|
|
467
|
+
async writeTreeName(roomId, name) {
|
|
468
|
+
validateName(name, "name");
|
|
469
|
+
const metadataEventId = await this.sendEncryptedMetadataMessage(roomId, {
|
|
470
|
+
msgtype: STORAGE_METADATA_MSGTYPE,
|
|
471
|
+
body: name,
|
|
472
|
+
});
|
|
473
|
+
await this.client.sendStateEvent(roomId, STORAGE_METADATA_EVENT_TYPE, { event_id: metadataEventId }, "");
|
|
474
|
+
this.treeNames.set(roomId, name);
|
|
475
|
+
}
|
|
476
|
+
/** Resolves an encrypted vault/folder name through its pointer state event. */
|
|
477
|
+
async getTreeName(roomId, options) {
|
|
478
|
+
if (options?.signal?.aborted)
|
|
479
|
+
throw new StorageError("operation cancelled");
|
|
480
|
+
await this.refreshRoomState(roomId, options);
|
|
481
|
+
const pointer = this.getStateEvent(roomId, STORAGE_METADATA_EVENT_TYPE, "");
|
|
482
|
+
const pointerContent = pointer?.getContent();
|
|
483
|
+
if (!pointer ||
|
|
484
|
+
pointer.getSender() !== this.getTreeOwnerId(roomId) ||
|
|
485
|
+
!isRecord(pointerContent) ||
|
|
486
|
+
typeof pointerContent.event_id !== "string") {
|
|
487
|
+
throw new StorageError("encrypted storage name is unavailable");
|
|
488
|
+
}
|
|
489
|
+
const metadataEventId = validateMatrixEventId(pointerContent.event_id, "storage metadata event ID");
|
|
490
|
+
let event;
|
|
491
|
+
try {
|
|
492
|
+
event = await this.readEncryptedMetadataMessage(roomId, metadataEventId);
|
|
493
|
+
}
|
|
494
|
+
catch (error) {
|
|
495
|
+
// A new device can list storage before restoring room keys. Keep the
|
|
496
|
+
// room visible with a generic label; a later call retries decryption.
|
|
497
|
+
if (error instanceof UndecryptableFileError)
|
|
498
|
+
return GENERIC_TREE_NAME;
|
|
499
|
+
throw error;
|
|
500
|
+
}
|
|
501
|
+
const content = event.getContent();
|
|
502
|
+
if (!isRecord(content) ||
|
|
503
|
+
content.msgtype !== STORAGE_METADATA_MSGTYPE ||
|
|
504
|
+
typeof content.body !== "string" ||
|
|
505
|
+
Object.hasOwn(content, "file_event_id")) {
|
|
506
|
+
throw new StorageError("encrypted storage name is invalid or unavailable");
|
|
507
|
+
}
|
|
508
|
+
validateName(content.body, "name");
|
|
509
|
+
this.treeNames.set(roomId, content.body);
|
|
510
|
+
return content.body;
|
|
511
|
+
}
|
|
512
|
+
async writeFileName(treeId, fileId, name) {
|
|
513
|
+
validateName(name, "file name");
|
|
514
|
+
validateMatrixEventId(fileId, "file event ID");
|
|
515
|
+
const metadataEventId = await this.sendEncryptedMetadataMessage(treeId, {
|
|
516
|
+
msgtype: STORAGE_METADATA_MSGTYPE,
|
|
517
|
+
body: name,
|
|
518
|
+
file_event_id: fileId,
|
|
519
|
+
});
|
|
520
|
+
await this.client.sendStateEvent(treeId, UNSTABLE_MSC3089_BRANCH.name, { active: true, metadata_event_id: metadataEventId }, fileId);
|
|
521
|
+
this.fileNames.set(this.fileNameKey(treeId, fileId), name);
|
|
522
|
+
}
|
|
523
|
+
/** Resolves an encrypted file name from the MSC3089 index pointer. */
|
|
524
|
+
async getFileName(treeId, fileId, options) {
|
|
525
|
+
if (options?.signal?.aborted)
|
|
526
|
+
throw new StorageError("operation cancelled");
|
|
527
|
+
if (options?.refreshState !== false)
|
|
528
|
+
await this.refreshRoomState(treeId, options);
|
|
529
|
+
const tree = this.getTree(treeId);
|
|
530
|
+
const branch = tree?.getFile(fileId);
|
|
531
|
+
if (!branch || branch.isActive === false)
|
|
532
|
+
throw new StorageError("file not found");
|
|
533
|
+
const indexEvent = branch.indexEvent;
|
|
534
|
+
const indexContent = indexEvent?.getContent();
|
|
535
|
+
if (!indexEvent ||
|
|
536
|
+
indexEvent.getSender() !== this.getTreeOwnerId(treeId) ||
|
|
537
|
+
!isRecord(indexContent) ||
|
|
538
|
+
indexContent.active !== true ||
|
|
539
|
+
typeof indexContent.metadata_event_id !== "string") {
|
|
540
|
+
throw new StorageError("encrypted file name is unavailable");
|
|
541
|
+
}
|
|
542
|
+
const metadataEventId = validateMatrixEventId(indexContent.metadata_event_id, "file metadata event ID");
|
|
543
|
+
let event;
|
|
544
|
+
try {
|
|
545
|
+
event = await this.readEncryptedMetadataMessage(treeId, metadataEventId);
|
|
546
|
+
}
|
|
547
|
+
catch (error) {
|
|
548
|
+
// File IDs remain listable on a fresh device while its room keys are
|
|
549
|
+
// being restored. The caller can resolve the private name on retry.
|
|
550
|
+
if (error instanceof UndecryptableFileError)
|
|
551
|
+
return GENERIC_FILE_NAME;
|
|
552
|
+
throw error;
|
|
553
|
+
}
|
|
554
|
+
const content = event.getContent();
|
|
555
|
+
if (!isRecord(content) || typeof content.body !== "string") {
|
|
556
|
+
throw new StorageError("encrypted file name is invalid or unavailable");
|
|
557
|
+
}
|
|
558
|
+
if (content.msgtype === MsgType.File) {
|
|
559
|
+
if (metadataEventId !== fileId || !isRecord(content.file)) {
|
|
560
|
+
throw new StorageError("encrypted file metadata does not match its listing");
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
else if (content.msgtype !== STORAGE_METADATA_MSGTYPE ||
|
|
564
|
+
content.file_event_id !== fileId) {
|
|
565
|
+
throw new StorageError("encrypted file metadata does not match its listing");
|
|
566
|
+
}
|
|
567
|
+
validateName(content.body, "file name");
|
|
568
|
+
this.fileNames.set(this.fileNameKey(treeId, fileId), content.body);
|
|
569
|
+
return content.body;
|
|
570
|
+
}
|
|
571
|
+
/** Returns the current file metadata pointer, including on inactive branches. */
|
|
572
|
+
async getFileMetadataEventId(treeId, fileId, options) {
|
|
573
|
+
if (options?.signal?.aborted)
|
|
574
|
+
throw new StorageError("operation cancelled");
|
|
575
|
+
await this.refreshRoomState(treeId, options);
|
|
576
|
+
const branch = this.getTree(treeId)?.getFile(fileId);
|
|
577
|
+
if (!branch)
|
|
578
|
+
throw new StorageError("file not found");
|
|
579
|
+
const indexEvent = branch.indexEvent;
|
|
580
|
+
const content = indexEvent?.getContent();
|
|
581
|
+
if (!indexEvent || indexEvent.getSender() !== this.getTreeOwnerId(treeId)) {
|
|
582
|
+
throw new StorageError("encrypted file listing is unavailable");
|
|
583
|
+
}
|
|
584
|
+
if (!isRecord(content) ||
|
|
585
|
+
typeof content.metadata_event_id !== "string") {
|
|
586
|
+
if (branch.isActive === false || indexEvent.isRedacted())
|
|
587
|
+
return null;
|
|
588
|
+
throw new StorageError("encrypted file name is unavailable");
|
|
589
|
+
}
|
|
590
|
+
return validateMatrixEventId(content.metadata_event_id, "file metadata event ID");
|
|
591
|
+
}
|
|
592
|
+
/** Fetches and validates a current encrypted rename event, allowing retries after redaction. */
|
|
593
|
+
async getFileRenameMetadataEvent(treeId, fileId, metadataEventId) {
|
|
594
|
+
const event = await this.readEncryptedMetadataMessage(treeId, metadataEventId, true);
|
|
595
|
+
if (event.isRedacted())
|
|
596
|
+
return event;
|
|
597
|
+
const content = event.getContent();
|
|
598
|
+
if (!isRecord(content) ||
|
|
599
|
+
content.msgtype !== STORAGE_METADATA_MSGTYPE ||
|
|
600
|
+
content.file_event_id !== fileId ||
|
|
601
|
+
typeof content.body !== "string") {
|
|
602
|
+
throw new StorageError("encrypted file metadata does not match its listing");
|
|
603
|
+
}
|
|
604
|
+
validateName(content.body, "file name");
|
|
605
|
+
return event;
|
|
606
|
+
}
|
|
607
|
+
/** Fetches the original encrypted file message directly by event ID. */
|
|
608
|
+
async getOriginalFileEvent(treeId, fileId, allowRedacted = false) {
|
|
609
|
+
const event = await this.readEncryptedMetadataMessage(treeId, fileId, allowRedacted);
|
|
610
|
+
if (event.isRedacted() && allowRedacted)
|
|
611
|
+
return event;
|
|
612
|
+
const content = event.getContent();
|
|
613
|
+
if (!isRecord(content) ||
|
|
614
|
+
content.msgtype !== MsgType.File ||
|
|
615
|
+
typeof content.body !== "string" ||
|
|
616
|
+
!isRecord(content.file) ||
|
|
617
|
+
typeof content.file.url !== "string") {
|
|
618
|
+
throw new StorageError("encrypted file message is invalid or unavailable");
|
|
619
|
+
}
|
|
620
|
+
return event;
|
|
621
|
+
}
|
|
622
|
+
async createEncryptedFile(treeId, name, encryptedContents, info, additionalContent) {
|
|
623
|
+
validateFileName(name);
|
|
624
|
+
const bytes = encryptedContents instanceof Uint8Array
|
|
625
|
+
? encryptedContents
|
|
626
|
+
: new Uint8Array(encryptedContents);
|
|
627
|
+
const body = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
628
|
+
const uploaded = await this.client.uploadContent(body, { includeFilename: false });
|
|
629
|
+
if (typeof uploaded.content_uri !== "string" || uploaded.content_uri.length === 0) {
|
|
630
|
+
throw new StorageError("encrypted media upload returned no content URI");
|
|
631
|
+
}
|
|
632
|
+
const fileInfo = { ...info, url: uploaded.content_uri };
|
|
633
|
+
const fileContent = {
|
|
634
|
+
...additionalContent,
|
|
635
|
+
msgtype: MsgType.File,
|
|
636
|
+
body: name,
|
|
637
|
+
url: uploaded.content_uri,
|
|
638
|
+
file: fileInfo,
|
|
639
|
+
[UNSTABLE_MSC3089_LEAF.name]: {},
|
|
640
|
+
};
|
|
641
|
+
const sent = await this.client.sendMessage(treeId, fileContent);
|
|
642
|
+
const fileEventId = validateMatrixEventId(sent?.event_id, "file upload response event ID");
|
|
643
|
+
await this.client.sendStateEvent(treeId, UNSTABLE_MSC3089_BRANCH.name, { active: true, metadata_event_id: fileEventId }, fileEventId);
|
|
644
|
+
this.fileNames.set(this.fileNameKey(treeId, fileEventId), name);
|
|
645
|
+
return { event_id: fileEventId };
|
|
646
|
+
}
|
|
423
647
|
/**
|
|
424
648
|
* Refreshes a room's current state from the homeserver. The background sync
|
|
425
649
|
* loop is intentionally asynchronous, so a short-lived CLI process can
|
|
@@ -1030,7 +1254,7 @@ export class TeleCryptIOStorage {
|
|
|
1030
1254
|
if (!userId)
|
|
1031
1255
|
throw new Error("createTree: Matrix client has no user ID");
|
|
1032
1256
|
return {
|
|
1033
|
-
name,
|
|
1257
|
+
name: GENERIC_TREE_NAME,
|
|
1034
1258
|
preset: Preset.PrivateChat,
|
|
1035
1259
|
power_level_content_override: {
|
|
1036
1260
|
invite: 100,
|
|
@@ -1052,6 +1276,7 @@ export class TeleCryptIOStorage {
|
|
|
1052
1276
|
[EventType.SpaceChild]: 100,
|
|
1053
1277
|
[EventType.SpaceParent]: 100,
|
|
1054
1278
|
[UNSTABLE_MSC3089_BRANCH.name]: 100,
|
|
1279
|
+
[STORAGE_METADATA_EVENT_TYPE]: 100,
|
|
1055
1280
|
},
|
|
1056
1281
|
users: { [userId]: 100 },
|
|
1057
1282
|
},
|
|
@@ -1119,10 +1344,46 @@ export class TeleCryptIOStorage {
|
|
|
1119
1344
|
return tree;
|
|
1120
1345
|
this.decoratedTreeSpaces.add(tree);
|
|
1121
1346
|
const originalGetDirectories = tree.getDirectories.bind(tree);
|
|
1347
|
+
const originalListFiles = typeof tree.listFiles === "function"
|
|
1348
|
+
? tree.listFiles.bind(tree)
|
|
1349
|
+
: () => [];
|
|
1350
|
+
const originalGetFile = typeof tree.getFile === "function"
|
|
1351
|
+
? tree.getFile.bind(tree)
|
|
1352
|
+
: () => null;
|
|
1122
1353
|
tree.getDirectories = () => originalGetDirectories().map((child) => this.decorateTreeSpace(child));
|
|
1123
1354
|
tree.createDirectory = (name) => this.createSubtree(tree, name);
|
|
1355
|
+
tree.setName = (name) => this.writeTreeName(tree.id, name);
|
|
1356
|
+
tree.listFiles = () => originalListFiles().map((branch) => this.decorateFileBranch(tree.id, branch));
|
|
1357
|
+
tree.getFile = (fileId) => {
|
|
1358
|
+
const branch = originalGetFile(fileId);
|
|
1359
|
+
return branch ? this.decorateFileBranch(tree.id, branch) : null;
|
|
1360
|
+
};
|
|
1361
|
+
tree.createFile = (name, encryptedContents, info, additionalContent) => this.createEncryptedFile(tree.id, name, encryptedContents, info, additionalContent);
|
|
1124
1362
|
return tree;
|
|
1125
1363
|
}
|
|
1364
|
+
decorateFileBranch(treeId, branch) {
|
|
1365
|
+
const existing = this.decoratedFileBranches.get(branch);
|
|
1366
|
+
if (existing)
|
|
1367
|
+
return existing;
|
|
1368
|
+
const decorated = Object.create(branch);
|
|
1369
|
+
const nameKey = this.fileNameKey(treeId, branch.id);
|
|
1370
|
+
Object.defineProperties(decorated, {
|
|
1371
|
+
getName: {
|
|
1372
|
+
value: () => this.fileNames.get(nameKey) ?? GENERIC_FILE_NAME,
|
|
1373
|
+
},
|
|
1374
|
+
setName: {
|
|
1375
|
+
value: (name) => this.writeFileName(treeId, branch.id, name),
|
|
1376
|
+
},
|
|
1377
|
+
delete: {
|
|
1378
|
+
value: async () => {
|
|
1379
|
+
const { deleteFile } = await import("./core/operations.js");
|
|
1380
|
+
await deleteFile(this, treeId, branch.id);
|
|
1381
|
+
},
|
|
1382
|
+
},
|
|
1383
|
+
});
|
|
1384
|
+
this.decoratedFileBranches.set(branch, decorated);
|
|
1385
|
+
return decorated;
|
|
1386
|
+
}
|
|
1126
1387
|
/** Cleans up only a room created by this operation and reports incomplete cleanup. */
|
|
1127
1388
|
async cleanupCreatedRoom(roomId) {
|
|
1128
1389
|
// Cleanup is compensating work. It must not inherit the caller's already
|
|
@@ -1203,9 +1464,26 @@ export class TeleCryptIOStorage {
|
|
|
1203
1464
|
throw new RoomCreationAmbiguousError(operation, error);
|
|
1204
1465
|
}
|
|
1205
1466
|
try {
|
|
1206
|
-
|
|
1467
|
+
const tree = await this.waitForTreeSpace(roomId, operation, signal);
|
|
1468
|
+
try {
|
|
1469
|
+
await this.writeTreeName(roomId, name);
|
|
1470
|
+
}
|
|
1471
|
+
catch (error) {
|
|
1472
|
+
try {
|
|
1473
|
+
await this.cleanupCreatedRoom(roomId);
|
|
1474
|
+
}
|
|
1475
|
+
catch (cleanupError) {
|
|
1476
|
+
throwWithCleanupDetail(error, roomId, cleanupError);
|
|
1477
|
+
}
|
|
1478
|
+
throw new StorageError(`${operation}: encrypted name initialization failed`, { cause: error });
|
|
1479
|
+
}
|
|
1480
|
+
return tree;
|
|
1207
1481
|
}
|
|
1208
1482
|
catch (error) {
|
|
1483
|
+
if (error instanceof StorageError &&
|
|
1484
|
+
error.message === `${operation}: encrypted name initialization failed`) {
|
|
1485
|
+
throw error;
|
|
1486
|
+
}
|
|
1209
1487
|
try {
|
|
1210
1488
|
await this.cleanupCreatedRoom(roomId);
|
|
1211
1489
|
}
|
|
@@ -1409,7 +1687,9 @@ export class TeleCryptIOStorage {
|
|
|
1409
1687
|
const members = parseMatrixMembersResponse(membersBody);
|
|
1410
1688
|
const pls = parseMatrixPowerLevels(plsRaw);
|
|
1411
1689
|
const viewLevel = pls.users_default ?? 0;
|
|
1412
|
-
|
|
1690
|
+
// Match matrix-js-sdk's MSC3089TreeSpace.getPermissions() default. An
|
|
1691
|
+
// explicit zero still means regular users lack the editor threshold.
|
|
1692
|
+
const editLevel = pls.events_default || 50;
|
|
1413
1693
|
const adminLevel = pls.events?.["m.room.power_levels"] ?? 100;
|
|
1414
1694
|
const roleFor = (userId) => {
|
|
1415
1695
|
const userLevel = pls.users?.[userId] ?? viewLevel;
|
|
@@ -1479,27 +1759,17 @@ export class TeleCryptIOStorage {
|
|
|
1479
1759
|
if (signal?.aborted)
|
|
1480
1760
|
throw new StorageError("operation cancelled");
|
|
1481
1761
|
let info;
|
|
1762
|
+
let fileEvent;
|
|
1482
1763
|
try {
|
|
1483
|
-
|
|
1764
|
+
fileEvent = await this.getOriginalFileEvent(branch.roomId, branch.id);
|
|
1765
|
+
const content = fileEvent.getContent();
|
|
1766
|
+
info = isRecord(content) && isRecord(content.file) ? content.file : undefined;
|
|
1484
1767
|
}
|
|
1485
1768
|
catch (error) {
|
|
1486
1769
|
if (signal?.aborted)
|
|
1487
1770
|
throw new StorageError("operation cancelled");
|
|
1488
1771
|
if (error instanceof StorageError || error instanceof MatrixError)
|
|
1489
1772
|
throw error;
|
|
1490
|
-
// matrix-js-sdk's file-info accessor reads an attachment absent from
|
|
1491
|
-
// failed-decryption events. Use its explicit decryption status, not a
|
|
1492
|
-
// guessed content shape; preserve other SDK/transport failures.
|
|
1493
|
-
let isPlaceholder = false;
|
|
1494
|
-
try {
|
|
1495
|
-
isPlaceholder = await isUndecryptableFilePlaceholder(branch);
|
|
1496
|
-
}
|
|
1497
|
-
catch (placeholderError) {
|
|
1498
|
-
throw new AggregateError([error, placeholderError], "file metadata lookup and placeholder inspection both failed", { cause: error });
|
|
1499
|
-
}
|
|
1500
|
-
if (isPlaceholder) {
|
|
1501
|
-
throw new UndecryptableFileError();
|
|
1502
|
-
}
|
|
1503
1773
|
throw error;
|
|
1504
1774
|
}
|
|
1505
1775
|
// Missing metadata alone does not establish a decryption failure.
|
|
@@ -1509,7 +1779,7 @@ export class TeleCryptIOStorage {
|
|
|
1509
1779
|
// `info` is the encrypted attachment descriptor. Its shape is owned by
|
|
1510
1780
|
// matrix-encrypt-attachment and does not carry the plaintext size. The
|
|
1511
1781
|
// event's `info` block is the sole source of user-facing file metadata.
|
|
1512
|
-
const eventMetadata = readFileEventMetadata(
|
|
1782
|
+
const eventMetadata = readFileEventMetadata(fileEvent.getContent());
|
|
1513
1783
|
if (signal?.aborted)
|
|
1514
1784
|
throw new StorageError("operation cancelled");
|
|
1515
1785
|
const declaredSize = eventMetadata.size;
|
package/dist/core/operations.js
CHANGED
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
* platform-specific and stays with the caller.
|
|
12
12
|
*/
|
|
13
13
|
import { readFileEventMetadata, withMatrixMutationAbort, withTreeMutation, } from "../TeleCryptIOStorage.js";
|
|
14
|
-
import { EventType, MatrixError, RoomCreateTypeField, RoomType,
|
|
14
|
+
import { EventType, MatrixError, RoomCreateTypeField, RoomType, UNSTABLE_MSC3088_ENABLED, UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_TREE_SUBTYPE, } from "matrix-js-sdk";
|
|
15
15
|
import { ClientPrefix } from "matrix-js-sdk/lib/http-api/prefix.js";
|
|
16
16
|
import { Method } from "matrix-js-sdk/lib/http-api/method.js";
|
|
17
|
+
import { validateMatrixEventId } from "./constants.js";
|
|
17
18
|
import { FileTooLargeError, MutationPartialError, MutationOutcomeUnknownError, NonEmptyTreeError, RoomCreationAmbiguousError, RoomCleanupIncompleteError, StorageError, } from "./errors.js";
|
|
18
19
|
import { ConditionTimeoutError, waitForCondition } from "./poll.js";
|
|
19
20
|
import { validateName } from "./validation.js";
|
|
@@ -237,8 +238,6 @@ async function refreshDeletionRooms(storage, rootId, signal) {
|
|
|
237
238
|
throw new StorageError("could not enumerate storage parent safely");
|
|
238
239
|
const parentIds = new Set();
|
|
239
240
|
for (const event of parentEvents) {
|
|
240
|
-
if (!isActiveRelationEvent(event))
|
|
241
|
-
continue;
|
|
242
241
|
const parentId = relationStateKey(event);
|
|
243
242
|
if (!parentId)
|
|
244
243
|
throw new StorageError("could not enumerate storage parent safely");
|
|
@@ -250,11 +249,6 @@ async function refreshDeletionRooms(storage, rootId, signal) {
|
|
|
250
249
|
const parents = [...parentIds];
|
|
251
250
|
if (parents[0]) {
|
|
252
251
|
await storage.refreshRoomState(parents[0], { signal });
|
|
253
|
-
const childEvents = readRelationEvents(client, parents[0], EventType.SpaceChild, rootId);
|
|
254
|
-
if (childEvents === null ||
|
|
255
|
-
!childEvents.some((event) => isActiveRelationEvent(event) && relationStateKey(event, rootId) === rootId)) {
|
|
256
|
-
throw new StorageError("storage parent relation is inconsistent");
|
|
257
|
-
}
|
|
258
252
|
}
|
|
259
253
|
if (signal?.aborted)
|
|
260
254
|
throw new StorageError("operation cancelled");
|
|
@@ -332,25 +326,46 @@ function activeTreeDirectories(client, tree) {
|
|
|
332
326
|
}
|
|
333
327
|
async function unlinkExternalParents(storage, rootId, externalParents, signal) {
|
|
334
328
|
const client = storage.getClient();
|
|
335
|
-
|
|
329
|
+
const completedRelationEventIds = [];
|
|
330
|
+
const redactRelation = async (roomId, eventType, stateKey) => {
|
|
331
|
+
await storage.refreshRoomState(roomId, { signal });
|
|
332
|
+
if (signal?.aborted)
|
|
333
|
+
throw new StorageError("operation cancelled");
|
|
334
|
+
const events = readRelationEvents(client, roomId, eventType, stateKey);
|
|
335
|
+
if (events === null)
|
|
336
|
+
throw new StorageError("storage folder state is unavailable");
|
|
337
|
+
const event = events[0];
|
|
338
|
+
if (!event || !isActiveRelationEvent(event))
|
|
339
|
+
return;
|
|
340
|
+
const currentUserId = client.getUserId();
|
|
341
|
+
if (!currentUserId || event.getSender?.() !== currentUserId) {
|
|
342
|
+
throw new StorageError("storage folder relation was not authored by the owner");
|
|
343
|
+
}
|
|
344
|
+
const eventId = validateMatrixEventId(event.getId?.(), "storage folder relation event ID");
|
|
345
|
+
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(roomId, eventId), signal), signal);
|
|
346
|
+
if (!completedRelationEventIds.includes(eventId))
|
|
347
|
+
completedRelationEventIds.push(eventId);
|
|
348
|
+
await storage.refreshRoomState(roomId, { signal });
|
|
349
|
+
const remaining = readRelationEvents(client, roomId, eventType, stateKey);
|
|
350
|
+
if (remaining === null || remaining.some((candidate) => isActiveRelationEvent(candidate))) {
|
|
351
|
+
throw new StorageError("delete graph unlink could not be verified");
|
|
352
|
+
}
|
|
353
|
+
};
|
|
336
354
|
try {
|
|
337
355
|
for (const parentId of externalParents) {
|
|
338
356
|
if (signal?.aborted)
|
|
339
357
|
throw new StorageError("operation cancelled");
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
// the relation
|
|
343
|
-
|
|
344
|
-
await
|
|
345
|
-
if (signal?.aborted)
|
|
346
|
-
throw new StorageError("operation cancelled");
|
|
347
|
-
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.sendStateEvent(rootId, EventType.SpaceParent, {}, parentId), signal), signal);
|
|
358
|
+
// Redact owner-authored relations one room at a time. If the process stops
|
|
359
|
+
// after the parent-side redaction, the redacted state key remains visible
|
|
360
|
+
// and the next call continues with the child-side relation.
|
|
361
|
+
await redactRelation(parentId, EventType.SpaceChild, rootId);
|
|
362
|
+
await redactRelation(rootId, EventType.SpaceParent, parentId);
|
|
348
363
|
await storage.refreshRoomState(parentId, { signal });
|
|
349
364
|
await storage.refreshRoomState(rootId, { signal });
|
|
350
365
|
const childLink = readRelationEvents(client, parentId, EventType.SpaceChild, rootId);
|
|
351
366
|
const parentLink = readRelationEvents(client, rootId, EventType.SpaceParent, parentId);
|
|
352
|
-
if (
|
|
353
|
-
|
|
367
|
+
if (childLink === null ||
|
|
368
|
+
parentLink === null ||
|
|
354
369
|
childLink.some((event) => isActiveRelationEvent(event)) ||
|
|
355
370
|
parentLink.some((event) => isActiveRelationEvent(event))) {
|
|
356
371
|
throw new StorageError("delete graph unlink could not be verified");
|
|
@@ -358,45 +373,14 @@ async function unlinkExternalParents(storage, rootId, externalParents, signal) {
|
|
|
358
373
|
}
|
|
359
374
|
}
|
|
360
375
|
catch (error) {
|
|
361
|
-
if (
|
|
376
|
+
if (error instanceof MutationOutcomeUnknownError || error instanceof MutationPartialError)
|
|
362
377
|
throw error;
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
// not leave a still-live vault detached from its external parent. Best
|
|
366
|
-
// effort is insufficient here: surface a distinct failure if the repair
|
|
367
|
-
// itself cannot be completed so callers never mistake a partial mutation
|
|
368
|
-
// for a successful delete.
|
|
369
|
-
try {
|
|
370
|
-
await relinkExternalParents(storage, rootId, externalParents, new AbortController().signal);
|
|
371
|
-
}
|
|
372
|
-
catch (cleanupError) {
|
|
373
|
-
throw new StorageError("delete graph unlink cleanup is incomplete", {
|
|
374
|
-
cause: new AggregateError([error, cleanupError], "delete graph unlink and relink both failed", { cause: error }),
|
|
375
|
-
});
|
|
378
|
+
if (completedRelationEventIds.length > 0) {
|
|
379
|
+
throw new MutationPartialError("delete folder links", completedRelationEventIds, "some parent links were removed; retry deleting the same folder to finish unlinking", { cause: error });
|
|
376
380
|
}
|
|
377
381
|
throw error;
|
|
378
382
|
}
|
|
379
383
|
}
|
|
380
|
-
async function relinkExternalParents(storage, rootId, externalParents, signal) {
|
|
381
|
-
const client = storage.getClient();
|
|
382
|
-
const via = client.getDomain();
|
|
383
|
-
if (!via)
|
|
384
|
-
throw new StorageError("delete graph relink is unavailable");
|
|
385
|
-
for (const parentId of externalParents) {
|
|
386
|
-
if (signal?.aborted)
|
|
387
|
-
throw new StorageError("operation cancelled");
|
|
388
|
-
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.sendStateEvent(parentId, EventType.SpaceChild, { via: [via] }, rootId), signal), signal);
|
|
389
|
-
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.sendStateEvent(rootId, EventType.SpaceParent, { via: [via] }, parentId), signal), signal);
|
|
390
|
-
await storage.refreshRoomState(parentId, { signal });
|
|
391
|
-
await storage.refreshRoomState(rootId, { signal });
|
|
392
|
-
const childLink = readRelationEvents(client, parentId, EventType.SpaceChild, rootId);
|
|
393
|
-
const parentLink = readRelationEvents(client, rootId, EventType.SpaceParent, parentId);
|
|
394
|
-
if (!childLink?.some((event) => isActiveRelationEvent(event)) ||
|
|
395
|
-
!parentLink?.some((event) => isActiveRelationEvent(event))) {
|
|
396
|
-
throw new StorageError("delete graph relink could not be verified");
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
384
|
function isGoneError(error) {
|
|
401
385
|
return (error instanceof MatrixError &&
|
|
402
386
|
(error.errcode === "M_NOT_FOUND" || error.errcode === "M_UNKNOWN"));
|
|
@@ -409,12 +393,25 @@ async function deleteRoomDeterministically(storage, roomId, signal) {
|
|
|
409
393
|
};
|
|
410
394
|
try {
|
|
411
395
|
const client = storage.getClient();
|
|
412
|
-
const tree = storage.getTree(roomId);
|
|
413
|
-
if (!tree)
|
|
414
|
-
throw new StorageError("storage room is unavailable for deletion");
|
|
415
396
|
const self = client.getUserId();
|
|
416
397
|
if (!self)
|
|
417
398
|
throw new StorageError("Matrix user identity is unavailable");
|
|
399
|
+
const ownMembership = await storage.getRoomMembership(roomId, undefined, { signal });
|
|
400
|
+
if (ownMembership === "leave" || ownMembership === "ban") {
|
|
401
|
+
try {
|
|
402
|
+
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.forget(roomId), signal), signal);
|
|
403
|
+
}
|
|
404
|
+
catch (error) {
|
|
405
|
+
if (!isGoneError(error))
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
408
|
+
removeRoomFromLocalStore(client, roomId);
|
|
409
|
+
markTreeDeleted(client, roomId);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const tree = storage.getTree(roomId);
|
|
413
|
+
if (!tree)
|
|
414
|
+
throw new StorageError("storage room is unavailable for deletion");
|
|
418
415
|
const members = await storage.listMembers(tree, { signal });
|
|
419
416
|
if (members.some((member) => member.userId !== self && member.role === "owner" &&
|
|
420
417
|
(member.membership === "join" || member.membership === "invite" || member.membership === "knock"))) {
|
|
@@ -444,7 +441,6 @@ async function deleteRoomDeterministically(storage, roomId, signal) {
|
|
|
444
441
|
throw error;
|
|
445
442
|
}
|
|
446
443
|
}
|
|
447
|
-
const ownMembership = await storage.getRoomMembership(roomId, undefined, { signal });
|
|
448
444
|
if (ownMembership === "join" || ownMembership === "invite" || ownMembership === "knock") {
|
|
449
445
|
try {
|
|
450
446
|
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.leave(roomId), signal), signal);
|
|
@@ -522,7 +518,11 @@ export async function createVault(storage, name, options) {
|
|
|
522
518
|
export async function listVaults(storage, options) {
|
|
523
519
|
return withOperationDeadline(options, async (signal) => {
|
|
524
520
|
const trees = await storage.listTrees(signal);
|
|
525
|
-
|
|
521
|
+
const topLevel = trees.filter((tree) => tree.isTopLevel);
|
|
522
|
+
return Promise.all(topLevel.map(async (tree) => ({
|
|
523
|
+
id: tree.id,
|
|
524
|
+
name: await storage.getTreeName(tree.id, { signal }),
|
|
525
|
+
})));
|
|
526
526
|
});
|
|
527
527
|
}
|
|
528
528
|
/** Current account's effective role for a vault, or null while it is unavailable. */
|
|
@@ -574,17 +574,8 @@ export async function joinVault(storage, vaultId, options) {
|
|
|
574
574
|
return { vaultId, joined: true };
|
|
575
575
|
}, "mutation");
|
|
576
576
|
}
|
|
577
|
-
function roomDisplayName(
|
|
578
|
-
|
|
579
|
-
const room = client.getRoom(roomId);
|
|
580
|
-
if (fallbackName?.trim())
|
|
581
|
-
return fallbackName.trim();
|
|
582
|
-
if (room?.name)
|
|
583
|
-
return room.name;
|
|
584
|
-
const userId = client.getUserId();
|
|
585
|
-
if (room && userId)
|
|
586
|
-
return room.getDefaultRoomName(userId);
|
|
587
|
-
return roomId;
|
|
577
|
+
function roomDisplayName(_storage, _roomId, _fallbackName) {
|
|
578
|
+
return "Encrypted storage";
|
|
588
579
|
}
|
|
589
580
|
function hasActiveSpaceParent(room) {
|
|
590
581
|
const result = room.currentState?.getStateEvents?.(EventType.SpaceParent);
|
|
@@ -908,7 +899,10 @@ export async function listFiles(storage, treeId, options) {
|
|
|
908
899
|
const files = tree
|
|
909
900
|
.listFiles()
|
|
910
901
|
.filter((file) => !isMarkedFileDeleted(storage, tree.id, file.id));
|
|
911
|
-
return files.map((
|
|
902
|
+
return await Promise.all(files.map(async (file) => ({
|
|
903
|
+
id: file.id,
|
|
904
|
+
name: await storage.getFileName(tree.id, file.id, { signal, refreshState: false }),
|
|
905
|
+
})));
|
|
912
906
|
}
|
|
913
907
|
catch (error) {
|
|
914
908
|
if (signal.aborted)
|
|
@@ -925,7 +919,10 @@ export async function listSubfolders(storage, parentId, options) {
|
|
|
925
919
|
ensureOperationActive(signal);
|
|
926
920
|
const directories = activeTreeDirectories(storage.getClient(), tree)
|
|
927
921
|
.filter((directory) => !isMarkedTreeDeleted(storage, directory.id));
|
|
928
|
-
return directories.map((
|
|
922
|
+
return Promise.all(directories.map(async (directory) => ({
|
|
923
|
+
id: directory.id,
|
|
924
|
+
name: await storage.getTreeName(directory.id, { signal }),
|
|
925
|
+
})));
|
|
929
926
|
});
|
|
930
927
|
}
|
|
931
928
|
export async function createSubfolder(storage, parentId, name, options) {
|
|
@@ -957,11 +954,9 @@ async function renameTree(storage, treeId, name, options) {
|
|
|
957
954
|
await waitForCondition(async () => {
|
|
958
955
|
ensureOperationActive(signal);
|
|
959
956
|
const current = storage.getTree(treeId);
|
|
960
|
-
if (current
|
|
961
|
-
return
|
|
962
|
-
await storage.
|
|
963
|
-
const refreshed = storage.getTree(treeId);
|
|
964
|
-
return refreshed?.room.name === name ? refreshed : null;
|
|
957
|
+
if (!current)
|
|
958
|
+
return null;
|
|
959
|
+
return (await storage.getTreeName(treeId, { signal })) === name ? current : null;
|
|
965
960
|
}, { timeoutMs: 15000, signal });
|
|
966
961
|
}
|
|
967
962
|
catch (error) {
|
|
@@ -990,6 +985,20 @@ async function deleteTree(storage, treeId, options) {
|
|
|
990
985
|
const pending = withTreeMutation(client, async () => {
|
|
991
986
|
if (isTreeDeleted(client, treeId))
|
|
992
987
|
return { id: treeId, deleted: true };
|
|
988
|
+
const ownMembership = await storage.getRoomMembership(treeId, undefined, {
|
|
989
|
+
signal: operation.signal,
|
|
990
|
+
});
|
|
991
|
+
if (ownMembership === "leave" || ownMembership === "ban") {
|
|
992
|
+
try {
|
|
993
|
+
await deleteRoomDeterministically(storage, treeId, operation.signal);
|
|
994
|
+
}
|
|
995
|
+
catch (error) {
|
|
996
|
+
if (error instanceof StorageError)
|
|
997
|
+
throw error;
|
|
998
|
+
throw new StorageError("delete cleanup failed", { cause: error });
|
|
999
|
+
}
|
|
1000
|
+
return { id: treeId, deleted: true };
|
|
1001
|
+
}
|
|
993
1002
|
const tree = await resolveTree(storage, treeId, operation.signal);
|
|
994
1003
|
let externalParents;
|
|
995
1004
|
try {
|
|
@@ -1015,14 +1024,6 @@ async function deleteTree(storage, treeId, options) {
|
|
|
1015
1024
|
await deleteRoomDeterministically(storage, tree.id, operation.signal);
|
|
1016
1025
|
}
|
|
1017
1026
|
catch (error) {
|
|
1018
|
-
try {
|
|
1019
|
-
await relinkExternalParents(storage, tree.id, externalParents, new AbortController().signal);
|
|
1020
|
-
}
|
|
1021
|
-
catch (cleanupError) {
|
|
1022
|
-
throw new StorageError("delete cleanup is incomplete", {
|
|
1023
|
-
cause: new AggregateError([error, cleanupError], "delete and external-parent relink both failed", { cause: error }),
|
|
1024
|
-
});
|
|
1025
|
-
}
|
|
1026
1027
|
if (error instanceof StorageError)
|
|
1027
1028
|
throw error;
|
|
1028
1029
|
throw new StorageError("delete failed", { cause: error });
|
|
@@ -1054,9 +1055,11 @@ export async function renameFile(storage, treeId, fileId, name, options) {
|
|
|
1054
1055
|
// fresh CLI/UI process can still read the previous local room state for a
|
|
1055
1056
|
// short time. Do not report success until this client has observed the new
|
|
1056
1057
|
// name through its normal sync loop.
|
|
1057
|
-
await waitForCondition(() => {
|
|
1058
|
+
await waitForCondition(async () => {
|
|
1058
1059
|
const current = tree.getFile(fileId);
|
|
1059
|
-
|
|
1060
|
+
if (!current)
|
|
1061
|
+
return null;
|
|
1062
|
+
return (await storage.getFileName(treeId, fileId, { signal })) === name ? current : null;
|
|
1060
1063
|
}, { timeoutMs: 15000, signal });
|
|
1061
1064
|
}
|
|
1062
1065
|
catch (error) {
|
|
@@ -1093,51 +1096,80 @@ async function deleteFileMedia(storage, mediaIds, signal) {
|
|
|
1093
1096
|
export async function deleteFile(storage, treeId, fileId, options) {
|
|
1094
1097
|
return withOperationDeadline(options, async (signal) => {
|
|
1095
1098
|
const tree = await resolveTree(storage, treeId, signal);
|
|
1096
|
-
|
|
1097
|
-
|
|
1099
|
+
if (isMarkedFileDeleted(storage, tree.id, fileId))
|
|
1100
|
+
return { id: fileId, deleted: true };
|
|
1101
|
+
await storage.refreshRoomState(treeId, { signal });
|
|
1102
|
+
const branch = tree.getFile(fileId);
|
|
1103
|
+
if (!branch)
|
|
1104
|
+
throw new StorageError("file not found");
|
|
1105
|
+
let originalEvent;
|
|
1098
1106
|
try {
|
|
1099
|
-
|
|
1107
|
+
originalEvent = await storage.getOriginalFileEvent(tree.id, fileId, true);
|
|
1100
1108
|
}
|
|
1101
1109
|
catch (error) {
|
|
1102
1110
|
throw new StorageError("could not resolve encrypted file safely", { cause: error });
|
|
1103
1111
|
}
|
|
1104
|
-
|
|
1112
|
+
if (originalEvent.isRedacted()) {
|
|
1113
|
+
if (branch.isActive === false) {
|
|
1114
|
+
markFileDeleted(storage.getClient(), tree.id, fileId);
|
|
1115
|
+
return { id: fileId, deleted: true };
|
|
1116
|
+
}
|
|
1117
|
+
throw new StorageError("file listing is active but its attachment event is redacted");
|
|
1118
|
+
}
|
|
1119
|
+
const originalContent = originalEvent.getContent();
|
|
1120
|
+
const fileDescriptor = typeof originalContent === "object" &&
|
|
1121
|
+
originalContent !== null &&
|
|
1122
|
+
!Array.isArray(originalContent) &&
|
|
1123
|
+
"file" in originalContent &&
|
|
1124
|
+
typeof originalContent.file === "object" &&
|
|
1125
|
+
originalContent.file !== null &&
|
|
1126
|
+
!Array.isArray(originalContent.file)
|
|
1127
|
+
? originalContent.file
|
|
1128
|
+
: undefined;
|
|
1129
|
+
const mediaId = fileDescriptor?.url;
|
|
1105
1130
|
if (typeof mediaId !== "string" || mediaId.length === 0) {
|
|
1106
1131
|
throw new StorageError("encrypted file has no media identifier");
|
|
1107
1132
|
}
|
|
1108
1133
|
await deleteFileMedia(storage, [mediaId], signal);
|
|
1109
|
-
const completedIds = [];
|
|
1134
|
+
const completedIds = [mediaId];
|
|
1110
1135
|
try {
|
|
1111
1136
|
const client = storage.getClient();
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1137
|
+
const metadataEventId = await storage.getFileMetadataEventId(tree.id, fileId, { signal });
|
|
1138
|
+
if (metadataEventId && metadataEventId !== fileId) {
|
|
1139
|
+
const metadataEvent = await storage.getFileRenameMetadataEvent(tree.id, fileId, metadataEventId);
|
|
1140
|
+
if (!metadataEvent.isRedacted()) {
|
|
1141
|
+
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(tree.id, metadataEventId), signal, "delete file rename metadata"), signal);
|
|
1142
|
+
}
|
|
1143
|
+
completedIds.push(metadataEventId);
|
|
1144
|
+
}
|
|
1145
|
+
const currentBranch = tree.getFile(fileId);
|
|
1146
|
+
const indexEvent = currentBranch?.indexEvent;
|
|
1147
|
+
const listingEventId = indexEvent?.getId();
|
|
1148
|
+
if (!indexEvent || typeof listingEventId !== "string" || listingEventId.length === 0) {
|
|
1118
1149
|
throw new StorageError("encrypted file has no listing event identifier");
|
|
1119
1150
|
}
|
|
1120
|
-
|
|
1121
|
-
|
|
1151
|
+
if (!indexEvent.isRedacted()) {
|
|
1152
|
+
if (indexEvent.getSender() !== client.getUserId()) {
|
|
1153
|
+
throw new StorageError("file listing was not authored by the owner");
|
|
1154
|
+
}
|
|
1155
|
+
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(tree.id, listingEventId), signal, "delete file listing"), signal);
|
|
1156
|
+
}
|
|
1122
1157
|
completedIds.push(listingEventId);
|
|
1158
|
+
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(tree.id, fileId), signal, "delete file attachment event"), signal);
|
|
1159
|
+
completedIds.push(fileId);
|
|
1123
1160
|
}
|
|
1124
1161
|
catch (error) {
|
|
1125
|
-
|
|
1126
|
-
throw error;
|
|
1127
|
-
if (signal.aborted) {
|
|
1128
|
-
throw new MutationPartialError("delete file", completedIds, "media was deleted but Matrix event cleanup was cancelled", { cause: error });
|
|
1129
|
-
}
|
|
1130
|
-
throw new MutationPartialError("delete file", completedIds, "media was deleted but Matrix event cleanup stopped", { cause: error });
|
|
1162
|
+
throw new MutationPartialError("delete file", completedIds, "file media and the listed completed steps were deleted; retry with the original file ID to finish Matrix cleanup", { cause: error });
|
|
1131
1163
|
}
|
|
1132
1164
|
try {
|
|
1133
1165
|
await waitForCondition(async () => {
|
|
1134
1166
|
await storage.refreshRoomState(tree.id, { signal });
|
|
1135
1167
|
ensureOperationActive(signal);
|
|
1136
1168
|
const current = tree.getFile(fileId);
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
return
|
|
1169
|
+
if (current && current.isActive !== false)
|
|
1170
|
+
return null;
|
|
1171
|
+
const attachment = await storage.getOriginalFileEvent(tree.id, fileId, true);
|
|
1172
|
+
return attachment.isRedacted() ? true : null;
|
|
1141
1173
|
}, { timeoutMs: 15000, signal });
|
|
1142
1174
|
}
|
|
1143
1175
|
catch (error) {
|
|
@@ -1146,7 +1178,7 @@ export async function deleteFile(storage, treeId, fileId, options) {
|
|
|
1146
1178
|
const detail = "Matrix deletion completed but the file state could not be verified";
|
|
1147
1179
|
throw new MutationPartialError("delete file", completedIds, detail, { cause: error });
|
|
1148
1180
|
}
|
|
1149
|
-
markFileDeleted(storage.getClient(), tree.id,
|
|
1181
|
+
markFileDeleted(storage.getClient(), tree.id, fileId);
|
|
1150
1182
|
ensureOperationActive(signal);
|
|
1151
1183
|
return { id: fileId, deleted: true };
|
|
1152
1184
|
}, "mutation");
|
|
@@ -1205,7 +1237,7 @@ export async function downloadFile(storage, treeId, fileId, options) {
|
|
|
1205
1237
|
return {
|
|
1206
1238
|
bytes: new Uint8Array(result.data),
|
|
1207
1239
|
mimetype: result.mimetype,
|
|
1208
|
-
name:
|
|
1240
|
+
name: await storage.getFileName(tree.id, fileId, { signal }),
|
|
1209
1241
|
};
|
|
1210
1242
|
});
|
|
1211
1243
|
}
|
|
@@ -1223,15 +1255,15 @@ function tsToIso(ts) {
|
|
|
1223
1255
|
export async function getFileDetails(storage, treeId, fileId, options) {
|
|
1224
1256
|
return withOperationDeadline(options, async (signal) => {
|
|
1225
1257
|
const tree = await resolveTree(storage, treeId, signal);
|
|
1226
|
-
|
|
1227
|
-
const name =
|
|
1258
|
+
await resolveFile(storage, tree, fileId, signal);
|
|
1259
|
+
const name = await storage.getFileName(tree.id, fileId, { signal });
|
|
1228
1260
|
let mimetype = null;
|
|
1229
1261
|
let size = null;
|
|
1230
1262
|
let createdAt = null;
|
|
1231
1263
|
let updatedAt = null;
|
|
1232
1264
|
try {
|
|
1233
1265
|
ensureOperationActive(signal);
|
|
1234
|
-
const event = await
|
|
1266
|
+
const event = await storage.getOriginalFileEvent(tree.id, fileId);
|
|
1235
1267
|
const metadata = readFileEventMetadata(event.getContent());
|
|
1236
1268
|
mimetype = metadata.mimetype;
|
|
1237
1269
|
size = metadata.size;
|
|
@@ -1250,7 +1282,7 @@ export async function getFileDetails(storage, treeId, fileId, options) {
|
|
|
1250
1282
|
});
|
|
1251
1283
|
}
|
|
1252
1284
|
async function getTreeDetails(storage, treeId, signal) {
|
|
1253
|
-
|
|
1285
|
+
await resolveTree(storage, treeId, signal);
|
|
1254
1286
|
if (signal?.aborted)
|
|
1255
1287
|
throw new StorageError("operation cancelled");
|
|
1256
1288
|
const client = storage.getClient();
|
|
@@ -1268,7 +1300,7 @@ async function getTreeDetails(storage, treeId, signal) {
|
|
|
1268
1300
|
}
|
|
1269
1301
|
}
|
|
1270
1302
|
return {
|
|
1271
|
-
name:
|
|
1303
|
+
name: await storage.getTreeName(treeId, { signal }),
|
|
1272
1304
|
id: treeId,
|
|
1273
1305
|
createdAt,
|
|
1274
1306
|
memberCount,
|