@protontech/drive-sdk 0.0.10 → 0.0.11
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/dist/crypto/driveCrypto.d.ts +11 -0
- package/dist/crypto/driveCrypto.js +16 -0
- package/dist/crypto/driveCrypto.js.map +1 -1
- package/dist/crypto/interface.d.ts +2 -0
- package/dist/crypto/openPGPCrypto.d.ts +2 -0
- package/dist/crypto/openPGPCrypto.js +8 -0
- package/dist/crypto/openPGPCrypto.js.map +1 -1
- package/dist/interface/index.d.ts +3 -3
- package/dist/interface/index.js +2 -2
- package/dist/interface/index.js.map +1 -1
- package/dist/interface/nodes.d.ts +9 -0
- package/dist/interface/nodes.js.map +1 -1
- package/dist/interface/sharing.d.ts +22 -2
- package/dist/interface/telemetry.d.ts +10 -8
- package/dist/interface/telemetry.js +7 -7
- package/dist/interface/telemetry.js.map +1 -1
- package/dist/internal/apiService/errors.js +1 -1
- package/dist/internal/apiService/errors.js.map +1 -1
- package/dist/internal/apiService/errors.test.js +7 -0
- package/dist/internal/apiService/errors.test.js.map +1 -1
- package/dist/internal/download/interface.d.ts +2 -2
- package/dist/internal/download/telemetry.js +7 -5
- package/dist/internal/download/telemetry.js.map +1 -1
- package/dist/internal/download/telemetry.test.js +10 -6
- package/dist/internal/download/telemetry.test.js.map +1 -1
- package/dist/internal/nodes/cache.js +25 -1
- package/dist/internal/nodes/cache.js.map +1 -1
- package/dist/internal/nodes/cache.test.js +33 -0
- package/dist/internal/nodes/cache.test.js.map +1 -1
- package/dist/internal/nodes/cryptoService.d.ts +8 -3
- package/dist/internal/nodes/cryptoService.js +11 -11
- package/dist/internal/nodes/cryptoService.js.map +1 -1
- package/dist/internal/nodes/cryptoService.test.js +2 -2
- package/dist/internal/nodes/cryptoService.test.js.map +1 -1
- package/dist/internal/nodes/index.d.ts +1 -1
- package/dist/internal/nodes/interface.d.ts +2 -2
- package/dist/internal/nodes/nodesManagement.js +1 -1
- package/dist/internal/nodes/nodesManagement.js.map +1 -1
- package/dist/internal/nodes/nodesManagement.test.js +1 -1
- package/dist/internal/nodes/nodesManagement.test.js.map +1 -1
- package/dist/internal/shares/cryptoService.js +4 -4
- package/dist/internal/shares/cryptoService.js.map +1 -1
- package/dist/internal/shares/cryptoService.test.js +2 -2
- package/dist/internal/shares/cryptoService.test.js.map +1 -1
- package/dist/internal/shares/manager.d.ts +2 -2
- package/dist/internal/shares/manager.js +2 -2
- package/dist/internal/shares/manager.js.map +1 -1
- package/dist/internal/sharing/apiService.d.ts +2 -2
- package/dist/internal/sharing/apiService.js +1 -1
- package/dist/internal/sharing/apiService.js.map +1 -1
- package/dist/internal/sharing/cryptoService.d.ts +12 -3
- package/dist/internal/sharing/cryptoService.js +110 -1
- package/dist/internal/sharing/cryptoService.js.map +1 -1
- package/dist/internal/sharing/cryptoService.test.js +132 -0
- package/dist/internal/sharing/cryptoService.test.js.map +1 -0
- package/dist/internal/sharing/index.js +1 -1
- package/dist/internal/sharing/index.js.map +1 -1
- package/dist/internal/sharing/interface.d.ts +4 -0
- package/dist/internal/sharing/sharingAccess.d.ts +3 -3
- package/dist/internal/sharing/sharingAccess.js +29 -4
- package/dist/internal/sharing/sharingAccess.js.map +1 -1
- package/dist/internal/sharing/sharingAccess.test.js +65 -0
- package/dist/internal/sharing/sharingAccess.test.js.map +1 -1
- package/dist/internal/sharing/sharingManagement.js +2 -2
- package/dist/internal/sharing/sharingManagement.js.map +1 -1
- package/dist/internal/sharing/sharingManagement.test.js +3 -3
- package/dist/internal/sharing/sharingManagement.test.js.map +1 -1
- package/dist/internal/upload/interface.d.ts +2 -2
- package/dist/internal/upload/telemetry.js +7 -5
- package/dist/internal/upload/telemetry.js.map +1 -1
- package/dist/internal/upload/telemetry.test.js +10 -6
- package/dist/internal/upload/telemetry.test.js.map +1 -1
- package/dist/protonDriveClient.d.ts +16 -1
- package/dist/protonDriveClient.js +23 -2
- package/dist/protonDriveClient.js.map +1 -1
- package/dist/transformers.d.ts +1 -1
- package/dist/transformers.js +16 -2
- package/dist/transformers.js.map +1 -1
- package/package.json +1 -1
- package/src/crypto/driveCrypto.ts +30 -0
- package/src/crypto/interface.ts +6 -0
- package/src/crypto/openPGPCrypto.ts +13 -0
- package/src/interface/index.ts +3 -3
- package/src/interface/nodes.ts +9 -0
- package/src/interface/sharing.ts +24 -2
- package/src/interface/telemetry.ts +8 -7
- package/src/internal/apiService/errors.test.ts +8 -0
- package/src/internal/apiService/errors.ts +1 -1
- package/src/internal/download/interface.ts +2 -2
- package/src/internal/download/telemetry.test.ts +18 -14
- package/src/internal/download/telemetry.ts +8 -5
- package/src/internal/nodes/cache.test.ts +41 -2
- package/src/internal/nodes/cache.ts +31 -3
- package/src/internal/nodes/cryptoService.test.ts +3 -3
- package/src/internal/nodes/cryptoService.ts +14 -13
- package/src/internal/nodes/index.ts +1 -1
- package/src/internal/nodes/interface.ts +2 -2
- package/src/internal/nodes/nodesManagement.test.ts +6 -6
- package/src/internal/nodes/nodesManagement.ts +2 -2
- package/src/internal/shares/cryptoService.test.ts +2 -2
- package/src/internal/shares/cryptoService.ts +7 -7
- package/src/internal/shares/manager.ts +4 -4
- package/src/internal/sharing/apiService.ts +10 -10
- package/src/internal/sharing/cryptoService.test.ts +148 -0
- package/src/internal/sharing/cryptoService.ts +137 -3
- package/src/internal/sharing/index.ts +1 -1
- package/src/internal/sharing/interface.ts +4 -0
- package/src/internal/sharing/sharingAccess.test.ts +74 -0
- package/src/internal/sharing/sharingAccess.ts +29 -5
- package/src/internal/sharing/sharingManagement.test.ts +3 -3
- package/src/internal/sharing/sharingManagement.ts +2 -2
- package/src/internal/upload/interface.ts +2 -2
- package/src/internal/upload/telemetry.test.ts +10 -6
- package/src/internal/upload/telemetry.ts +8 -5
- package/src/protonDriveClient.ts +27 -2
- package/src/transformers.ts +31 -5
- package/dist/cache/index.d.ts +0 -2
- package/dist/cache/index.js +0 -6
- package/dist/cache/index.js.map +0 -1
- package/dist/cache/interface.d.ts +0 -105
- package/dist/cache/interface.js +0 -3
- package/dist/cache/interface.js.map +0 -1
- package/dist/cache/memoryCache.d.ts +0 -18
- package/dist/cache/memoryCache.js +0 -78
- package/dist/cache/memoryCache.js.map +0 -1
- package/dist/cache/memoryCache.test.js +0 -121
- package/dist/cache/memoryCache.test.js.map +0 -1
- package/dist/crypto/hmac.d.ts +0 -22
- package/dist/crypto/hmac.js +0 -44
- package/dist/crypto/hmac.js.map +0 -1
- package/dist/crypto/utils.d.ts +0 -2
- package/dist/crypto/utils.js +0 -35
- package/dist/crypto/utils.js.map +0 -1
- package/dist/errors.d.ts +0 -138
- package/dist/errors.js +0 -163
- package/dist/errors.js.map +0 -1
- package/dist/interface/account.js +0 -3
- package/dist/interface/account.js.map +0 -1
- package/dist/interface/author.d.ts +0 -26
- package/dist/interface/author.js +0 -3
- package/dist/interface/author.js.map +0 -1
- package/dist/interface/download.d.ts +0 -29
- package/dist/interface/download.js +0 -3
- package/dist/interface/download.js.map +0 -1
- package/dist/interface/httpClient.d.ts +0 -38
- package/dist/interface/httpClient.js +0 -3
- package/dist/interface/httpClient.js.map +0 -1
- package/dist/interface/result.d.ts +0 -9
- package/dist/interface/result.js +0 -11
- package/dist/interface/result.js.map +0 -1
- package/dist/interface/thumbnail.d.ts +0 -17
- package/dist/interface/thumbnail.js +0 -9
- package/dist/interface/thumbnail.js.map +0 -1
- package/dist/interface/upload.d.ts +0 -16
- package/dist/interface/upload.js +0 -3
- package/dist/interface/upload.js.map +0 -1
- package/dist/internal/apiService/errorCodes.d.ts +0 -30
- package/dist/internal/apiService/errorCodes.js +0 -11
- package/dist/internal/apiService/errorCodes.js.map +0 -1
- package/dist/internal/apiService/observerStream.d.ts +0 -3
- package/dist/internal/apiService/observerStream.js +0 -15
- package/dist/internal/apiService/observerStream.js.map +0 -1
- package/dist/internal/batchLoading.d.ts +0 -34
- package/dist/internal/batchLoading.js +0 -68
- package/dist/internal/batchLoading.js.map +0 -1
- package/dist/internal/batchLoading.test.d.ts +0 -1
- package/dist/internal/batchLoading.test.js +0 -50
- package/dist/internal/batchLoading.test.js.map +0 -1
- package/dist/internal/download/controller.d.ts +0 -8
- package/dist/internal/download/controller.js +0 -22
- package/dist/internal/download/controller.js.map +0 -1
- package/dist/internal/download/queue.d.ts +0 -5
- package/dist/internal/download/queue.js +0 -31
- package/dist/internal/download/queue.js.map +0 -1
- package/dist/internal/errors.js +0 -28
- package/dist/internal/errors.js.map +0 -1
- package/dist/internal/errors.test.js +0 -22
- package/dist/internal/errors.test.js.map +0 -1
- package/dist/internal/events/interface.d.ts +0 -47
- package/dist/internal/events/interface.js +0 -12
- package/dist/internal/events/interface.js.map +0 -1
- package/dist/internal/nodes/mediaTypes.d.ts +0 -2
- package/dist/internal/nodes/mediaTypes.js +0 -13
- package/dist/internal/nodes/mediaTypes.js.map +0 -1
- package/dist/internal/nodes/validations.d.ts +0 -4
- package/dist/internal/nodes/validations.js +0 -21
- package/dist/internal/nodes/validations.js.map +0 -1
- package/dist/internal/uids.d.ts +0 -38
- package/dist/internal/uids.js +0 -85
- package/dist/internal/uids.js.map +0 -1
- package/dist/internal/upload/chunkStreamReader.d.ts +0 -13
- package/dist/internal/upload/chunkStreamReader.js +0 -46
- package/dist/internal/upload/chunkStreamReader.js.map +0 -1
- package/dist/internal/upload/chunkStreamReader.test.d.ts +0 -1
- package/dist/internal/upload/chunkStreamReader.test.js +0 -75
- package/dist/internal/upload/chunkStreamReader.test.js.map +0 -1
- package/dist/internal/upload/controller.d.ts +0 -8
- package/dist/internal/upload/controller.js +0 -25
- package/dist/internal/upload/controller.js.map +0 -1
- package/dist/internal/upload/digests.d.ts +0 -8
- package/dist/internal/upload/digests.js +0 -22
- package/dist/internal/upload/digests.js.map +0 -1
- package/dist/internal/upload/queue.d.ts +0 -5
- package/dist/internal/upload/queue.js +0 -32
- package/dist/internal/upload/queue.js.map +0 -1
- package/dist/internal/utils.d.ts +0 -1
- package/dist/internal/utils.js +0 -13
- package/dist/internal/utils.js.map +0 -1
- package/dist/internal/wait.d.ts +0 -3
- package/dist/internal/wait.js +0 -28
- package/dist/internal/wait.js.map +0 -1
- package/dist/internal/wait.test.d.ts +0 -1
- package/dist/internal/wait.test.js +0 -21
- package/dist/internal/wait.test.js.map +0 -1
- /package/dist/{cache/memoryCache.test.d.ts → internal/sharing/cryptoService.test.d.ts} +0 -0
|
@@ -2,10 +2,11 @@ import bcrypt from 'bcryptjs';
|
|
|
2
2
|
import { c } from 'ttag';
|
|
3
3
|
|
|
4
4
|
import { DriveCrypto, PrivateKey, SessionKey, SRPVerifier, uint8ArrayToBase64String, VERIFICATION_STATUS } from '../../crypto';
|
|
5
|
-
import { ProtonDriveAccount, ProtonInvitation, ProtonInvitationWithNode, NonProtonInvitation, Author, Result, Member, UnverifiedAuthorError, resultError, resultOk } from "../../interface";
|
|
5
|
+
import { ProtonDriveAccount, ProtonInvitation, ProtonInvitationWithNode, NonProtonInvitation, Author, Result, Member, UnverifiedAuthorError, resultError, resultOk, InvalidNameError, ProtonDriveTelemetry, MetricVolumeType } from "../../interface";
|
|
6
|
+
import { validateNodeName } from '../nodes/validations';
|
|
6
7
|
import { getErrorMessage, getVerificationMessage } from "../errors";
|
|
7
8
|
import { EncryptedShare } from "../shares";
|
|
8
|
-
import { EncryptedInvitation, EncryptedInvitationWithNode, EncryptedExternalInvitation, EncryptedMember, EncryptedPublicLink, PublicLinkWithCreatorEmail } from "./interface";
|
|
9
|
+
import { EncryptedInvitation, EncryptedInvitationWithNode, EncryptedExternalInvitation, EncryptedMember, EncryptedPublicLink, PublicLinkWithCreatorEmail, EncryptedBookmark, SharesService } from "./interface";
|
|
9
10
|
|
|
10
11
|
// Version 2 of bcrypt with 2**10 rounds.
|
|
11
12
|
// https://en.wikipedia.org/wiki/Bcrypt#Description
|
|
@@ -31,11 +32,15 @@ enum PublicLinkFlags {
|
|
|
31
32
|
*/
|
|
32
33
|
export class SharingCryptoService {
|
|
33
34
|
constructor(
|
|
35
|
+
private telemetry: ProtonDriveTelemetry,
|
|
34
36
|
private driveCrypto: DriveCrypto,
|
|
35
37
|
private account: ProtonDriveAccount,
|
|
38
|
+
private sharesService: SharesService,
|
|
36
39
|
) {
|
|
40
|
+
this.telemetry = telemetry;
|
|
37
41
|
this.driveCrypto = driveCrypto;
|
|
38
42
|
this.account = account;
|
|
43
|
+
this.sharesService = sharesService;
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
/**
|
|
@@ -346,7 +351,7 @@ export class SharingCryptoService {
|
|
|
346
351
|
}
|
|
347
352
|
|
|
348
353
|
private async decryptShareUrlPassword(
|
|
349
|
-
encryptedPublicLink: EncryptedPublicLink,
|
|
354
|
+
encryptedPublicLink: Pick<EncryptedPublicLink, 'armoredUrlPassword' | 'flags'>,
|
|
350
355
|
addressKeys: PrivateKey[],
|
|
351
356
|
): Promise<{
|
|
352
357
|
password: string,
|
|
@@ -375,4 +380,133 @@ export class SharingCryptoService {
|
|
|
375
380
|
throw new Error(`Unsupported public link with flags: ${encryptedPublicLink.flags}`);
|
|
376
381
|
}
|
|
377
382
|
}
|
|
383
|
+
|
|
384
|
+
async decryptBookmark(encryptedBookmark: EncryptedBookmark): Promise<{
|
|
385
|
+
url: Result<string, Error>,
|
|
386
|
+
nodeName: Result<string, Error | InvalidNameError>,
|
|
387
|
+
}> {
|
|
388
|
+
// TODO: Signatures are not checked and not specified in the interface.
|
|
389
|
+
// In the future, we will need to add authorship verification.
|
|
390
|
+
|
|
391
|
+
let urlPassword: string;
|
|
392
|
+
try {
|
|
393
|
+
urlPassword = await this.decryptBookmarkUrlPassword(encryptedBookmark);
|
|
394
|
+
} catch (originalError: unknown) {
|
|
395
|
+
const error = originalError instanceof Error ? originalError : new Error(c('Error').t`Unknown error`);
|
|
396
|
+
return {
|
|
397
|
+
url: resultError(error),
|
|
398
|
+
nodeName: resultError(error),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// TODO: API should provide the full URL.
|
|
403
|
+
const url = resultOk<string, Error>(`https://drive.proton.me/urls/${encryptedBookmark.tokenId}#${urlPassword}`);
|
|
404
|
+
|
|
405
|
+
let shareKey: PrivateKey;
|
|
406
|
+
try {
|
|
407
|
+
shareKey = await this.decryptBookmarkKey(encryptedBookmark, urlPassword);
|
|
408
|
+
} catch (originalError: unknown) {
|
|
409
|
+
const error = originalError instanceof Error ? originalError : new Error(c('Error').t`Unknown error`);
|
|
410
|
+
return {
|
|
411
|
+
url,
|
|
412
|
+
nodeName: resultError(error),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const nodeName = await this.decryptBookmarkName(encryptedBookmark, shareKey);
|
|
417
|
+
|
|
418
|
+
return {
|
|
419
|
+
url,
|
|
420
|
+
nodeName,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private async decryptBookmarkUrlPassword(encryptedBookmark: EncryptedBookmark): Promise<string> {
|
|
425
|
+
if (!encryptedBookmark.url.encryptedUrlPassword) {
|
|
426
|
+
throw new Error(c('Error').t`Bookmark password is not available`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const { addressId } = await this.sharesService.getMyFilesShareMemberEmailKey();
|
|
430
|
+
const address = await this.account.getOwnAddress(addressId);
|
|
431
|
+
const addressKeys = address.keys.map(({ key }) => key);
|
|
432
|
+
|
|
433
|
+
try {
|
|
434
|
+
// Decrypt the password for the share URL.
|
|
435
|
+
const urlPassword = await this.driveCrypto.decryptShareUrlPassword(
|
|
436
|
+
encryptedBookmark.url.encryptedUrlPassword,
|
|
437
|
+
addressKeys,
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
return urlPassword;
|
|
441
|
+
} catch (error: unknown) {
|
|
442
|
+
this.telemetry.logEvent({
|
|
443
|
+
eventName: 'decryptionError',
|
|
444
|
+
volumeType: MetricVolumeType.SharedPublic,
|
|
445
|
+
field: 'shareUrlPassword',
|
|
446
|
+
error,
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
const message = getErrorMessage(error);
|
|
450
|
+
const errorMessage = c('Error').t`Failed to decrypt bookmark password: ${message}`;
|
|
451
|
+
throw new Error(errorMessage);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private async decryptBookmarkKey(encryptedBookmark: EncryptedBookmark, urlPassword: string): Promise<PrivateKey> {
|
|
456
|
+
try {
|
|
457
|
+
// Use the password to decrypt the share key.
|
|
458
|
+
const { key: shareKey } = await this.driveCrypto.decryptKeyWithSrpPassword(
|
|
459
|
+
urlPassword,
|
|
460
|
+
encryptedBookmark.url.base64SharePasswordSalt,
|
|
461
|
+
encryptedBookmark.share.armoredKey,
|
|
462
|
+
encryptedBookmark.share.armoredPassphrase,
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
return shareKey;
|
|
466
|
+
} catch (error: unknown) {
|
|
467
|
+
this.telemetry.logEvent({
|
|
468
|
+
eventName: 'decryptionError',
|
|
469
|
+
volumeType: MetricVolumeType.SharedPublic,
|
|
470
|
+
field: 'shareKey',
|
|
471
|
+
error,
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
const message = getErrorMessage(error);
|
|
475
|
+
const errorMessage = c('Error').t`Failed to decrypt bookmark key: ${message}`;
|
|
476
|
+
throw new Error(errorMessage);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private async decryptBookmarkName(encryptedBookmark: EncryptedBookmark, shareKey: PrivateKey): Promise<Result<string, Error | InvalidNameError>> {
|
|
481
|
+
try {
|
|
482
|
+
// Use the share key to decrypt the node name of the bookmark.
|
|
483
|
+
const { name } = await this.driveCrypto.decryptNodeName(
|
|
484
|
+
encryptedBookmark.node.encryptedName,
|
|
485
|
+
shareKey,
|
|
486
|
+
[],
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
try {
|
|
490
|
+
validateNodeName(name);
|
|
491
|
+
} catch (error: unknown) {
|
|
492
|
+
return resultError({
|
|
493
|
+
name,
|
|
494
|
+
error: error instanceof Error ? error.message : c('Error').t`Unknown error`,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return resultOk(name);
|
|
499
|
+
} catch (error: unknown) {
|
|
500
|
+
this.telemetry.logEvent({
|
|
501
|
+
eventName: 'decryptionError',
|
|
502
|
+
volumeType: MetricVolumeType.SharedPublic,
|
|
503
|
+
field: 'nodeName',
|
|
504
|
+
error,
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
const message = getErrorMessage(error);
|
|
508
|
+
const errorMessage = c('Error').t`Failed to decrypt bookmark name: ${message}`;
|
|
509
|
+
return resultError(new Error(errorMessage));
|
|
510
|
+
}
|
|
511
|
+
}
|
|
378
512
|
}
|
|
@@ -30,7 +30,7 @@ export function initSharingModule(
|
|
|
30
30
|
) {
|
|
31
31
|
const api = new SharingAPIService(telemetry.getLogger('sharing-api'), apiService);
|
|
32
32
|
const cache = new SharingCache(driveEntitiesCache);
|
|
33
|
-
const cryptoService = new SharingCryptoService(crypto, account);
|
|
33
|
+
const cryptoService = new SharingCryptoService(telemetry, crypto, account, sharesService);
|
|
34
34
|
const sharingAccess = new SharingAccess(api, cache, cryptoService, sharesService, nodesService);
|
|
35
35
|
const sharingEvents = new SharingEvents(telemetry.getLogger('sharing-events'), driveEvents, cache, nodesService, sharingAccess);
|
|
36
36
|
const sharingManagement = new SharingManagement(telemetry.getLogger('sharing'), api, cryptoService, account, sharesService, nodesService, nodesEvents);
|
|
@@ -143,6 +143,10 @@ export interface PublicLinkWithCreatorEmail extends PublicLink {
|
|
|
143
143
|
export interface SharesService {
|
|
144
144
|
getMyFilesIDs(): Promise<{ volumeId: string }>,
|
|
145
145
|
loadEncryptedShare(shareId: string): Promise<EncryptedShare>,
|
|
146
|
+
getMyFilesShareMemberEmailKey(): Promise<{
|
|
147
|
+
addressId: string,
|
|
148
|
+
addressKey: PrivateKey,
|
|
149
|
+
}>,
|
|
146
150
|
getContextShareMemberEmailKey(shareId: string): Promise<{
|
|
147
151
|
email: string,
|
|
148
152
|
addressId: string,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { NodeType, resultError, resultOk } from "../../interface";
|
|
1
2
|
import { SharingAPIService } from "./apiService";
|
|
2
3
|
import { SharingCache } from "./cache";
|
|
3
4
|
import { SharingCryptoService } from "./cryptoService";
|
|
@@ -26,6 +27,16 @@ describe("SharingAccess", () => {
|
|
|
26
27
|
apiService = {
|
|
27
28
|
iterateSharedNodeUids: jest.fn().mockImplementation(() => nodeUidsIterator()),
|
|
28
29
|
iterateSharedWithMeNodeUids: jest.fn().mockImplementation(() => nodeUidsIterator()),
|
|
30
|
+
iterateBookmarks: jest.fn().mockImplementation(async function* () {
|
|
31
|
+
yield {
|
|
32
|
+
tokenId: "tokenId",
|
|
33
|
+
creationTime: new Date('2025-01-01'),
|
|
34
|
+
node: {
|
|
35
|
+
type: NodeType.File,
|
|
36
|
+
mediaType: "mediaType",
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}),
|
|
29
40
|
}
|
|
30
41
|
// @ts-expect-error No need to implement all methods for mocking
|
|
31
42
|
cache = {
|
|
@@ -35,6 +46,7 @@ describe("SharingAccess", () => {
|
|
|
35
46
|
// @ts-expect-error No need to implement all methods for mocking
|
|
36
47
|
cryptoService = {
|
|
37
48
|
decryptInvitation: jest.fn(),
|
|
49
|
+
decryptBookmark: jest.fn(),
|
|
38
50
|
}
|
|
39
51
|
// @ts-expect-error No need to implement all methods for mocking
|
|
40
52
|
sharesService = {
|
|
@@ -99,4 +111,66 @@ describe("SharingAccess", () => {
|
|
|
99
111
|
expect(cache.setSharedWithMeNodeUids).toHaveBeenCalledWith(nodeUids);
|
|
100
112
|
});
|
|
101
113
|
});
|
|
114
|
+
|
|
115
|
+
describe("iterateBookmarks", () => {
|
|
116
|
+
it("should return decrypted bookmark", async () => {
|
|
117
|
+
cryptoService.decryptBookmark = jest.fn().mockResolvedValue({
|
|
118
|
+
url: resultOk("url"),
|
|
119
|
+
nodeName: resultOk("nodeName"),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const result = await Array.fromAsync(sharingAccess.iterateBookmarks());
|
|
123
|
+
|
|
124
|
+
expect(result).toEqual([resultOk({
|
|
125
|
+
uid: "tokenId",
|
|
126
|
+
creationTime: new Date('2025-01-01'),
|
|
127
|
+
url: "url",
|
|
128
|
+
node: {
|
|
129
|
+
name: "nodeName",
|
|
130
|
+
type: NodeType.File,
|
|
131
|
+
mediaType: "mediaType",
|
|
132
|
+
},
|
|
133
|
+
})]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("should return degraded bookmark if URL password cannot be decrypted", async () => {
|
|
137
|
+
cryptoService.decryptBookmark = jest.fn().mockResolvedValue({
|
|
138
|
+
url: resultError("url cannot be decrypted"),
|
|
139
|
+
nodeName: resultError("url cannot be decrypted"),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const result = await Array.fromAsync(sharingAccess.iterateBookmarks());
|
|
143
|
+
|
|
144
|
+
expect(result).toEqual([resultError({
|
|
145
|
+
uid: "tokenId",
|
|
146
|
+
creationTime: new Date('2025-01-01'),
|
|
147
|
+
url: resultError("url cannot be decrypted"),
|
|
148
|
+
node: {
|
|
149
|
+
name: resultError("url cannot be decrypted"),
|
|
150
|
+
type: NodeType.File,
|
|
151
|
+
mediaType: "mediaType",
|
|
152
|
+
},
|
|
153
|
+
})]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("should return degraded bookmark if node name cannot be decrypted", async () => {
|
|
157
|
+
cryptoService.decryptBookmark = jest.fn().mockResolvedValue({
|
|
158
|
+
url: resultOk("url"),
|
|
159
|
+
nodeName: resultError("node name cannot be decrypted"),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const result = await Array.fromAsync(sharingAccess.iterateBookmarks());
|
|
163
|
+
|
|
164
|
+
expect(result).toEqual([resultError({
|
|
165
|
+
uid: "tokenId",
|
|
166
|
+
creationTime: new Date('2025-01-01'),
|
|
167
|
+
url: resultOk("url"),
|
|
168
|
+
node: {
|
|
169
|
+
name: resultError("node name cannot be decrypted"),
|
|
170
|
+
type: NodeType.File,
|
|
171
|
+
mediaType: "mediaType",
|
|
172
|
+
},
|
|
173
|
+
})]);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
102
176
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { c } from 'ttag';
|
|
2
2
|
|
|
3
|
-
import { ProtonInvitationWithNode } from "../../interface";
|
|
3
|
+
import { MaybeBookmark, ProtonInvitationWithNode, resultError, resultOk } from "../../interface";
|
|
4
4
|
import { ValidationError } from "../../errors";
|
|
5
5
|
import { DecryptedNode } from "../nodes";
|
|
6
6
|
import { BatchLoading } from "../batchLoading";
|
|
@@ -120,14 +120,38 @@ export class SharingAccess {
|
|
|
120
120
|
await this.apiService.rejectInvitation(invitationUid);
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
|
|
124
|
-
async* iterateSharedBookmarks(signal?: AbortSignal): AsyncGenerator<string> {
|
|
123
|
+
async* iterateBookmarks(signal?: AbortSignal): AsyncGenerator<MaybeBookmark> {
|
|
125
124
|
for await (const bookmark of this.apiService.iterateBookmarks(signal)) {
|
|
126
|
-
|
|
125
|
+
const { url, nodeName } = await this.cryptoService.decryptBookmark(bookmark);
|
|
126
|
+
|
|
127
|
+
if (!url.ok || !nodeName.ok) {
|
|
128
|
+
yield resultError({
|
|
129
|
+
uid: bookmark.tokenId,
|
|
130
|
+
creationTime: bookmark.creationTime,
|
|
131
|
+
url: url,
|
|
132
|
+
node: {
|
|
133
|
+
name: nodeName,
|
|
134
|
+
type: bookmark.node.type,
|
|
135
|
+
mediaType: bookmark.node.mediaType,
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
} else {
|
|
139
|
+
yield resultOk({
|
|
140
|
+
uid: bookmark.tokenId,
|
|
141
|
+
creationTime: bookmark.creationTime,
|
|
142
|
+
url: url.value,
|
|
143
|
+
node: {
|
|
144
|
+
name: nodeName.value,
|
|
145
|
+
type: bookmark.node.type,
|
|
146
|
+
mediaType: bookmark.node.mediaType,
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
127
150
|
}
|
|
128
151
|
}
|
|
129
152
|
|
|
130
|
-
async deleteBookmark(
|
|
153
|
+
async deleteBookmark(bookmarkUid: string): Promise<void> {
|
|
154
|
+
const tokenId = bookmarkUid;
|
|
131
155
|
await this.apiService.deleteBookmark(tokenId);
|
|
132
156
|
}
|
|
133
157
|
}
|
|
@@ -533,7 +533,7 @@ describe("SharingManagement", () => {
|
|
|
533
533
|
expect(apiService.createPublicLink).toHaveBeenCalledWith("shareId", expect.objectContaining({
|
|
534
534
|
role: MemberRole.Viewer,
|
|
535
535
|
includesCustomPassword: false,
|
|
536
|
-
|
|
536
|
+
expirationTime: undefined,
|
|
537
537
|
crypto: "publicLinkCrypto",
|
|
538
538
|
srp: "publicLinkSrp",
|
|
539
539
|
}));
|
|
@@ -570,7 +570,7 @@ describe("SharingManagement", () => {
|
|
|
570
570
|
expect(apiService.createPublicLink).toHaveBeenCalledWith("shareId", expect.objectContaining({
|
|
571
571
|
role: MemberRole.Viewer,
|
|
572
572
|
includesCustomPassword: true,
|
|
573
|
-
|
|
573
|
+
expirationTime: 1735776000,
|
|
574
574
|
crypto: "publicLinkCrypto",
|
|
575
575
|
srp: "publicLinkSrp",
|
|
576
576
|
}));
|
|
@@ -617,7 +617,7 @@ describe("SharingManagement", () => {
|
|
|
617
617
|
expect(apiService.updatePublicLink).toHaveBeenCalledWith("publicLinkUid", expect.objectContaining({
|
|
618
618
|
role: MemberRole.Editor,
|
|
619
619
|
includesCustomPassword: true,
|
|
620
|
-
|
|
620
|
+
expirationTime: 1735776000,
|
|
621
621
|
crypto: "publicLinkCrypto",
|
|
622
622
|
srp: "publicLinkSrp",
|
|
623
623
|
}));
|
|
@@ -474,7 +474,7 @@ export class SharingManagement {
|
|
|
474
474
|
creatorEmail,
|
|
475
475
|
role: options.role,
|
|
476
476
|
includesCustomPassword: !!options.customPassword,
|
|
477
|
-
|
|
477
|
+
expirationTime: options.expiration ? Math.floor(options.expiration.getTime() / 1000) : undefined,
|
|
478
478
|
crypto,
|
|
479
479
|
srp,
|
|
480
480
|
});
|
|
@@ -502,7 +502,7 @@ export class SharingManagement {
|
|
|
502
502
|
await this.apiService.updatePublicLink(publicLink.uid, {
|
|
503
503
|
role: options.role,
|
|
504
504
|
includesCustomPassword: !!options.customPassword,
|
|
505
|
-
|
|
505
|
+
expirationTime: options.expiration ? Math.floor(options.expiration.getTime() / 1000) : undefined,
|
|
506
506
|
crypto,
|
|
507
507
|
srp,
|
|
508
508
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { PrivateKey, SessionKey } from "../../crypto";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { MetricVolumeType, ThumbnailType, Result, Revision } from "../../interface";
|
|
4
4
|
import { DecryptedNode } from "../nodes";
|
|
5
5
|
|
|
6
6
|
export type NodeRevisionDraft = {
|
|
@@ -124,5 +124,5 @@ export interface NodesServiceNode {
|
|
|
124
124
|
* Interface describing the dependencies to the shares module.
|
|
125
125
|
*/
|
|
126
126
|
export interface SharesService {
|
|
127
|
-
getVolumeMetricContext(volumeId: string): Promise<
|
|
127
|
+
getVolumeMetricContext(volumeId: string): Promise<MetricVolumeType>,
|
|
128
128
|
}
|
|
@@ -31,26 +31,30 @@ describe('UploadTelemetry', () => {
|
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
it('should log failure during init (excludes uploaded size)', async () => {
|
|
34
|
-
|
|
34
|
+
const error = new Error('Failed');
|
|
35
|
+
await uploadTelemetry.uploadInitFailed(parentNodeUid, error, 1000);
|
|
35
36
|
|
|
36
37
|
expect(mockTelemetry.logEvent).toHaveBeenCalledWith({
|
|
37
38
|
eventName: "upload",
|
|
38
|
-
|
|
39
|
+
volumeType: "own_volume",
|
|
39
40
|
uploadedSize: 0,
|
|
40
41
|
expectedSize: 1000,
|
|
41
42
|
error: "unknown",
|
|
43
|
+
originalError: error,
|
|
42
44
|
});
|
|
43
45
|
});
|
|
44
46
|
|
|
45
47
|
it('should log failure upload', async () => {
|
|
46
|
-
|
|
48
|
+
const error = new Error('Failed');
|
|
49
|
+
await uploadTelemetry.uploadFailed(revisionUid, error, 500, 1000);
|
|
47
50
|
|
|
48
51
|
expect(mockTelemetry.logEvent).toHaveBeenCalledWith({
|
|
49
52
|
eventName: "upload",
|
|
50
|
-
|
|
53
|
+
volumeType: "own_volume",
|
|
51
54
|
uploadedSize: 500,
|
|
52
55
|
expectedSize: 1000,
|
|
53
56
|
error: "unknown",
|
|
57
|
+
originalError: error,
|
|
54
58
|
});
|
|
55
59
|
});
|
|
56
60
|
|
|
@@ -59,7 +63,7 @@ describe('UploadTelemetry', () => {
|
|
|
59
63
|
|
|
60
64
|
expect(mockTelemetry.logEvent).toHaveBeenCalledWith({
|
|
61
65
|
eventName: "upload",
|
|
62
|
-
|
|
66
|
+
volumeType: "own_volume",
|
|
63
67
|
uploadedSize: 1000,
|
|
64
68
|
expectedSize: 1000,
|
|
65
69
|
});
|
|
@@ -109,7 +113,7 @@ describe('UploadTelemetry', () => {
|
|
|
109
113
|
it('should detect "5xx" error for APIHTTPError with 5xx status code', async () => {
|
|
110
114
|
const error = new APIHTTPError('Server error', 500);
|
|
111
115
|
await uploadTelemetry.uploadFailed(revisionUid, error, 500, 1000);
|
|
112
|
-
verifyErrorCategory('
|
|
116
|
+
verifyErrorCategory('server_error');
|
|
113
117
|
});
|
|
114
118
|
|
|
115
119
|
it('should detect "server_error" for TimeoutError', async () => {
|
|
@@ -40,6 +40,7 @@ export class UploadTelemetry {
|
|
|
40
40
|
uploadedSize: 0,
|
|
41
41
|
expectedSize,
|
|
42
42
|
error: errorCategory,
|
|
43
|
+
originalError: error,
|
|
43
44
|
});
|
|
44
45
|
}
|
|
45
46
|
|
|
@@ -57,6 +58,7 @@ export class UploadTelemetry {
|
|
|
57
58
|
uploadedSize,
|
|
58
59
|
expectedSize,
|
|
59
60
|
error: errorCategory,
|
|
61
|
+
originalError: error,
|
|
60
62
|
});
|
|
61
63
|
}
|
|
62
64
|
|
|
@@ -72,17 +74,18 @@ export class UploadTelemetry {
|
|
|
72
74
|
uploadedSize: number,
|
|
73
75
|
expectedSize: number,
|
|
74
76
|
error?: MetricsUploadErrorType,
|
|
77
|
+
originalError?: unknown,
|
|
75
78
|
}) {
|
|
76
|
-
let
|
|
79
|
+
let volumeType;
|
|
77
80
|
try {
|
|
78
|
-
|
|
81
|
+
volumeType = await this.sharesService.getVolumeMetricContext(volumeId);
|
|
79
82
|
} catch (error: unknown) {
|
|
80
|
-
this.logger.error('Failed to get metric
|
|
83
|
+
this.logger.error('Failed to get metric volume type', error);
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
this.telemetry.logEvent({
|
|
84
87
|
eventName: 'upload',
|
|
85
|
-
|
|
88
|
+
volumeType,
|
|
86
89
|
...options,
|
|
87
90
|
});
|
|
88
91
|
}
|
|
@@ -103,7 +106,7 @@ function getErrorCategory(error: unknown): MetricsUploadErrorType | undefined {
|
|
|
103
106
|
return '4xx';
|
|
104
107
|
}
|
|
105
108
|
if (error.statusCode >= 500) {
|
|
106
|
-
return '
|
|
109
|
+
return 'server_error';
|
|
107
110
|
}
|
|
108
111
|
}
|
|
109
112
|
if (error instanceof Error) {
|
package/src/protonDriveClient.ts
CHANGED
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
ProtonInvitationOrUid,
|
|
12
12
|
NonProtonInvitationOrUid,
|
|
13
13
|
ProtonInvitationWithNode,
|
|
14
|
+
MaybeBookmark,
|
|
15
|
+
BookmarkOrUid,
|
|
14
16
|
ShareResult,
|
|
15
17
|
Device,
|
|
16
18
|
DeviceType,
|
|
@@ -410,7 +412,7 @@ export class ProtonDriveClient {
|
|
|
410
412
|
* @throws {@link Error} If another node with the same name already exists.
|
|
411
413
|
*/
|
|
412
414
|
async renameNode(nodeUid: NodeOrUid, newName: string): Promise<MaybeNode> {
|
|
413
|
-
this.logger.info(`Renaming node ${nodeUid}
|
|
415
|
+
this.logger.info(`Renaming node ${getUid(nodeUid)}`);
|
|
414
416
|
return convertInternalNodePromise(this.nodes.management.renameNode(getUid(nodeUid), newName));
|
|
415
417
|
}
|
|
416
418
|
|
|
@@ -511,7 +513,7 @@ export class ProtonDriveClient {
|
|
|
511
513
|
* @throws {@link Error} If another node with the same name already exists.
|
|
512
514
|
*/
|
|
513
515
|
async createFolder(parentNodeUid: NodeOrUid, name: string, modificationTime?: Date): Promise<MaybeNode> {
|
|
514
|
-
this.logger.info(`Creating folder
|
|
516
|
+
this.logger.info(`Creating folder in ${getUid(parentNodeUid)}`);
|
|
515
517
|
return convertInternalNodePromise(this.nodes.management.createFolder(getUid(parentNodeUid), name, modificationTime));
|
|
516
518
|
}
|
|
517
519
|
|
|
@@ -645,6 +647,29 @@ export class ProtonDriveClient {
|
|
|
645
647
|
await this.sharing.access.rejectInvitation(invitationId);
|
|
646
648
|
}
|
|
647
649
|
|
|
650
|
+
/**
|
|
651
|
+
* Iterates the shared bookmarks.
|
|
652
|
+
*
|
|
653
|
+
* The output is not sorted and the order of the bookmarks is not guaranteed.
|
|
654
|
+
*
|
|
655
|
+
* @param signal - Signal to abort the operation.
|
|
656
|
+
* @returns An async generator of the shared bookmarks.
|
|
657
|
+
*/
|
|
658
|
+
async* iterateBookmarks(signal?: AbortSignal): AsyncGenerator<MaybeBookmark> {
|
|
659
|
+
this.logger.info('Iterating shared bookmarks');
|
|
660
|
+
yield* this.sharing.access.iterateBookmarks(signal);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Remove the shared bookmark.
|
|
665
|
+
*
|
|
666
|
+
* @param bookmarkOrUid - Bookmark entity or its UID string.
|
|
667
|
+
*/
|
|
668
|
+
async removeBookmark(bookmarkOrUid: BookmarkOrUid): Promise<void> {
|
|
669
|
+
this.logger.info(`Removing bookmark ${getUid(bookmarkOrUid)}`);
|
|
670
|
+
await this.sharing.access.deleteBookmark(getUid(bookmarkOrUid));
|
|
671
|
+
}
|
|
672
|
+
|
|
648
673
|
/**
|
|
649
674
|
* Get sharing info of the node.
|
|
650
675
|
*
|
package/src/transformers.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
MaybeNode as PublicMaybeNode,
|
|
3
|
+
MaybeMissingNode as PublicMaybeMissingNode,
|
|
4
|
+
NodeEntity as PublicNodeEntity,
|
|
5
|
+
DegradedNode as PublicDegradedNode,
|
|
6
|
+
Revision as PublicRevision,
|
|
7
|
+
Result,
|
|
8
|
+
resultOk,
|
|
9
|
+
resultError,
|
|
10
|
+
MissingNode,
|
|
11
|
+
} from './interface';
|
|
12
|
+
import { DecryptedNode as InternalNode, DecryptedRevision as InternalRevision } from './internal/nodes';
|
|
3
13
|
|
|
4
14
|
type InternalPartialNode = Pick<
|
|
5
15
|
InternalNode,
|
|
@@ -17,7 +27,8 @@ type InternalPartialNode = Pick<
|
|
|
17
27
|
'activeRevision' |
|
|
18
28
|
'folder' |
|
|
19
29
|
'totalStorageSize' |
|
|
20
|
-
'errors'
|
|
30
|
+
'errors' |
|
|
31
|
+
'shareId'
|
|
21
32
|
>;
|
|
22
33
|
|
|
23
34
|
type NodeUid = string | { uid: string } | Result<{ uid: string }, { uid: string }>;
|
|
@@ -76,6 +87,7 @@ export function convertInternalNode(node: InternalPartialNode): PublicMaybeNode
|
|
|
76
87
|
trashTime: node.trashTime,
|
|
77
88
|
totalStorageSize: node.totalStorageSize,
|
|
78
89
|
folder: node.folder,
|
|
90
|
+
deprecatedShareId: node.shareId,
|
|
79
91
|
};
|
|
80
92
|
|
|
81
93
|
const name = node.name;
|
|
@@ -85,7 +97,7 @@ export function convertInternalNode(node: InternalPartialNode): PublicMaybeNode
|
|
|
85
97
|
return resultError({
|
|
86
98
|
...baseNodeMetadata,
|
|
87
99
|
name,
|
|
88
|
-
activeRevision,
|
|
100
|
+
activeRevision: activeRevision?.ok ? resultOk(convertInternalRevision(activeRevision.value)) : activeRevision,
|
|
89
101
|
errors: node.errors,
|
|
90
102
|
} as PublicDegradedNode);
|
|
91
103
|
}
|
|
@@ -93,6 +105,20 @@ export function convertInternalNode(node: InternalPartialNode): PublicMaybeNode
|
|
|
93
105
|
return resultOk({
|
|
94
106
|
...baseNodeMetadata,
|
|
95
107
|
name: name.value,
|
|
96
|
-
activeRevision: activeRevision?.value,
|
|
108
|
+
activeRevision: activeRevision?.ok ? convertInternalRevision(activeRevision.value) : undefined,
|
|
97
109
|
} as PublicNodeEntity);
|
|
98
110
|
}
|
|
111
|
+
|
|
112
|
+
function convertInternalRevision(revision: InternalRevision): PublicRevision {
|
|
113
|
+
return {
|
|
114
|
+
uid: revision.uid,
|
|
115
|
+
state: revision.state,
|
|
116
|
+
creationTime: revision.creationTime,
|
|
117
|
+
contentAuthor: revision.contentAuthor,
|
|
118
|
+
storageSize: revision.storageSize,
|
|
119
|
+
claimedSize: revision.claimedSize,
|
|
120
|
+
claimedModificationTime: revision.claimedModificationTime,
|
|
121
|
+
claimedDigests: revision.claimedDigests,
|
|
122
|
+
claimedAdditionalMetadata: revision.claimedAdditionalMetadata,
|
|
123
|
+
}
|
|
124
|
+
}
|
package/dist/cache/index.d.ts
DELETED
package/dist/cache/index.js
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.MemoryCache = void 0;
|
|
4
|
-
var memoryCache_1 = require("./memoryCache");
|
|
5
|
-
Object.defineProperty(exports, "MemoryCache", { enumerable: true, get: function () { return memoryCache_1.MemoryCache; } });
|
|
6
|
-
//# sourceMappingURL=index.js.map
|
package/dist/cache/index.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/cache/index.ts"],"names":[],"mappings":";;;AACA,6CAA4C;AAAnC,0GAAA,WAAW,OAAA"}
|