@protontech/drive-sdk 0.6.0 → 0.6.2
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/diagnostic/diagnostic.d.ts +7 -4
- package/dist/diagnostic/diagnostic.js +16 -8
- package/dist/diagnostic/diagnostic.js.map +1 -1
- package/dist/diagnostic/index.d.ts +1 -1
- package/dist/diagnostic/index.js +9 -1
- package/dist/diagnostic/index.js.map +1 -1
- package/dist/diagnostic/interface.d.ts +24 -9
- package/dist/diagnostic/nodeUtils.d.ts +13 -0
- package/dist/diagnostic/nodeUtils.js +90 -0
- package/dist/diagnostic/nodeUtils.js.map +1 -0
- package/dist/diagnostic/sdkDiagnosticBase.d.ts +36 -0
- package/dist/diagnostic/sdkDiagnosticBase.js +305 -0
- package/dist/diagnostic/sdkDiagnosticBase.js.map +1 -0
- package/dist/diagnostic/sdkDiagnosticMain.d.ts +16 -0
- package/dist/diagnostic/sdkDiagnosticMain.js +79 -0
- package/dist/diagnostic/sdkDiagnosticMain.js.map +1 -0
- package/dist/diagnostic/sdkDiagnosticPhotos.d.ts +13 -0
- package/dist/diagnostic/sdkDiagnosticPhotos.js +65 -0
- package/dist/diagnostic/sdkDiagnosticPhotos.js.map +1 -0
- package/dist/featureFlags.d.ts +7 -0
- package/dist/featureFlags.js +14 -0
- package/dist/featureFlags.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/interface/featureFlags.d.ts +7 -0
- package/dist/interface/featureFlags.js +3 -0
- package/dist/interface/featureFlags.js.map +1 -0
- package/dist/interface/index.d.ts +3 -0
- package/dist/interface/index.js.map +1 -1
- package/dist/internal/devices/interface.d.ts +1 -1
- package/dist/internal/devices/manager.js +1 -1
- package/dist/internal/devices/manager.js.map +1 -1
- package/dist/internal/devices/manager.test.js +3 -3
- package/dist/internal/devices/manager.test.js.map +1 -1
- package/dist/internal/errors.d.ts +5 -0
- package/dist/internal/errors.js +23 -0
- package/dist/internal/errors.js.map +1 -1
- package/dist/internal/errors.test.js +53 -2
- package/dist/internal/errors.test.js.map +1 -1
- package/dist/internal/nodes/cryptoReporter.js +3 -0
- package/dist/internal/nodes/cryptoReporter.js.map +1 -1
- package/dist/internal/nodes/index.test.js +1 -1
- package/dist/internal/nodes/index.test.js.map +1 -1
- package/dist/internal/nodes/interface.d.ts +1 -1
- package/dist/internal/nodes/nodesAccess.d.ts +1 -1
- package/dist/internal/nodes/nodesAccess.js +4 -4
- package/dist/internal/nodes/nodesAccess.js.map +1 -1
- package/dist/internal/nodes/nodesAccess.test.js +2 -2
- package/dist/internal/nodes/nodesAccess.test.js.map +1 -1
- package/dist/internal/photos/albums.js +1 -1
- package/dist/internal/photos/albums.js.map +1 -1
- package/dist/internal/photos/apiService.d.ts +6 -0
- package/dist/internal/photos/apiService.js +16 -0
- package/dist/internal/photos/apiService.js.map +1 -1
- package/dist/internal/photos/index.d.ts +1 -1
- package/dist/internal/photos/index.js +2 -2
- package/dist/internal/photos/index.js.map +1 -1
- package/dist/internal/photos/interface.d.ts +4 -1
- package/dist/internal/photos/shares.d.ts +1 -1
- package/dist/internal/photos/shares.js +3 -3
- package/dist/internal/photos/shares.js.map +1 -1
- package/dist/internal/photos/timeline.d.ts +8 -1
- package/dist/internal/photos/timeline.js +36 -2
- package/dist/internal/photos/timeline.js.map +1 -1
- package/dist/internal/photos/timeline.test.d.ts +1 -0
- package/dist/internal/photos/timeline.test.js +99 -0
- package/dist/internal/photos/timeline.test.js.map +1 -0
- package/dist/internal/photos/upload.js +1 -1
- package/dist/internal/photos/upload.js.map +1 -1
- package/dist/internal/shares/cryptoService.js +3 -0
- package/dist/internal/shares/cryptoService.js.map +1 -1
- package/dist/internal/shares/manager.d.ts +1 -1
- package/dist/internal/shares/manager.js +4 -4
- package/dist/internal/shares/manager.js.map +1 -1
- package/dist/internal/shares/manager.test.js +7 -7
- package/dist/internal/shares/manager.test.js.map +1 -1
- package/dist/internal/sharing/interface.d.ts +1 -1
- package/dist/internal/sharing/sharingAccess.js +1 -1
- package/dist/internal/sharing/sharingAccess.js.map +1 -1
- package/dist/internal/sharing/sharingAccess.test.js +1 -1
- package/dist/internal/sharing/sharingAccess.test.js.map +1 -1
- package/dist/internal/sharing/sharingManagement.js +32 -14
- package/dist/internal/sharing/sharingManagement.js.map +1 -1
- package/dist/internal/sharing/sharingManagement.test.js +46 -1
- package/dist/internal/sharing/sharingManagement.test.js.map +1 -1
- package/dist/internal/sharingPublic/cryptoReporter.js +3 -0
- package/dist/internal/sharingPublic/cryptoReporter.js.map +1 -1
- package/dist/internal/sharingPublic/index.d.ts +3 -0
- package/dist/internal/sharingPublic/index.js +3 -0
- package/dist/internal/sharingPublic/index.js.map +1 -1
- package/dist/internal/sharingPublic/shares.d.ts +1 -1
- package/dist/internal/sharingPublic/shares.js +1 -2
- package/dist/internal/sharingPublic/shares.js.map +1 -1
- package/dist/internal/upload/fileUploader.d.ts +5 -2
- package/dist/internal/upload/fileUploader.js +7 -4
- package/dist/internal/upload/fileUploader.js.map +1 -1
- package/dist/protonDriveClient.d.ts +1 -1
- package/dist/protonDriveClient.js +5 -1
- package/dist/protonDriveClient.js.map +1 -1
- package/dist/protonDrivePhotosClient.d.ts +19 -0
- package/dist/protonDrivePhotosClient.js +23 -1
- package/dist/protonDrivePhotosClient.js.map +1 -1
- package/dist/protonDrivePublicLinkClient.d.ts +33 -1
- package/dist/protonDrivePublicLinkClient.js +51 -2
- package/dist/protonDrivePublicLinkClient.js.map +1 -1
- package/package.json +1 -1
- package/src/diagnostic/diagnostic.ts +27 -8
- package/src/diagnostic/index.ts +17 -2
- package/src/diagnostic/interface.ts +35 -9
- package/src/diagnostic/nodeUtils.ts +100 -0
- package/src/diagnostic/{sdkDiagnostic.ts → sdkDiagnosticBase.ts} +204 -204
- package/src/diagnostic/sdkDiagnosticMain.ts +95 -0
- package/src/diagnostic/sdkDiagnosticPhotos.ts +70 -0
- package/src/featureFlags.ts +11 -0
- package/src/index.ts +1 -0
- package/src/interface/featureFlags.ts +7 -0
- package/src/interface/index.ts +3 -0
- package/src/internal/devices/interface.ts +1 -1
- package/src/internal/devices/manager.test.ts +3 -3
- package/src/internal/devices/manager.ts +1 -1
- package/src/internal/errors.test.ts +62 -1
- package/src/internal/errors.ts +27 -0
- package/src/internal/nodes/cryptoReporter.ts +6 -5
- package/src/internal/nodes/index.test.ts +1 -1
- package/src/internal/nodes/interface.ts +1 -1
- package/src/internal/nodes/nodesAccess.test.ts +2 -2
- package/src/internal/nodes/nodesAccess.ts +5 -5
- package/src/internal/photos/albums.ts +1 -1
- package/src/internal/photos/apiService.ts +40 -0
- package/src/internal/photos/index.ts +9 -1
- package/src/internal/photos/interface.ts +4 -1
- package/src/internal/photos/shares.ts +3 -3
- package/src/internal/photos/timeline.test.ts +116 -0
- package/src/internal/photos/timeline.ts +47 -2
- package/src/internal/photos/upload.ts +1 -1
- package/src/internal/shares/cryptoService.ts +5 -1
- package/src/internal/shares/manager.test.ts +7 -7
- package/src/internal/shares/manager.ts +4 -4
- package/src/internal/sharing/interface.ts +1 -1
- package/src/internal/sharing/sharingAccess.test.ts +1 -1
- package/src/internal/sharing/sharingAccess.ts +1 -1
- package/src/internal/sharing/sharingManagement.test.ts +59 -1
- package/src/internal/sharing/sharingManagement.ts +33 -14
- package/src/internal/sharingPublic/cryptoReporter.ts +5 -1
- package/src/internal/sharingPublic/index.ts +3 -0
- package/src/internal/sharingPublic/shares.ts +1 -2
- package/src/internal/upload/fileUploader.ts +18 -11
- package/src/protonDriveClient.ts +5 -0
- package/src/protonDrivePhotosClient.ts +24 -1
- package/src/protonDrivePublicLinkClient.ts +77 -2
- package/dist/diagnostic/sdkDiagnostic.d.ts +0 -23
- package/dist/diagnostic/sdkDiagnostic.js +0 -320
- package/dist/diagnostic/sdkDiagnostic.js.map +0 -1
package/src/interface/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { OpenPGPCrypto, PrivateKey, SessionKey, SRPModule } from '../crypto';
|
|
|
3
3
|
import { LatestEventIdProvider } from '../internal/events/interface';
|
|
4
4
|
import { ProtonDriveAccount } from './account';
|
|
5
5
|
import { ProtonDriveConfig } from './config';
|
|
6
|
+
import { FeatureFlagProvider } from './featureFlags';
|
|
6
7
|
import { ProtonDriveHTTPClient } from './httpClient';
|
|
7
8
|
import { Telemetry, MetricEvent } from './telemetry';
|
|
8
9
|
|
|
@@ -12,6 +13,7 @@ export type { ProtonDriveAccount, ProtonDriveAccountAddress } from './account';
|
|
|
12
13
|
export type { Author, UnverifiedAuthorError, AnonymousUser } from './author';
|
|
13
14
|
export type { ProtonDriveConfig } from './config';
|
|
14
15
|
export type { Device, DeviceOrUid } from './devices';
|
|
16
|
+
export type { FeatureFlagProvider } from './featureFlags';
|
|
15
17
|
export { DeviceType } from './devices';
|
|
16
18
|
export type { FileDownloader, DownloadController, SeekableReadableStream } from './download';
|
|
17
19
|
export type {
|
|
@@ -117,5 +119,6 @@ export interface ProtonDriveClientContructorParameters {
|
|
|
117
119
|
srpModule: SRPModule;
|
|
118
120
|
config?: ProtonDriveConfig;
|
|
119
121
|
telemetry?: ProtonDriveTelemetry;
|
|
122
|
+
featureFlagProvider?: FeatureFlagProvider;
|
|
120
123
|
latestEventIdProvider?: LatestEventIdProvider;
|
|
121
124
|
}
|
|
@@ -13,7 +13,7 @@ export type DeviceMetadata = {
|
|
|
13
13
|
};
|
|
14
14
|
|
|
15
15
|
export interface SharesService {
|
|
16
|
-
|
|
16
|
+
getRootIDs(): Promise<{ volumeId: string }>;
|
|
17
17
|
getMyFilesShareMemberEmailKey(): Promise<{
|
|
18
18
|
addressId: string;
|
|
19
19
|
email: string;
|
|
@@ -30,7 +30,7 @@ describe('DevicesManager', () => {
|
|
|
30
30
|
};
|
|
31
31
|
// @ts-expect-error No need to implement all methods for mocking
|
|
32
32
|
sharesService = {
|
|
33
|
-
|
|
33
|
+
getRootIDs: jest.fn(),
|
|
34
34
|
};
|
|
35
35
|
// @ts-expect-error No need to implement all methods for mocking
|
|
36
36
|
nodesService = {};
|
|
@@ -74,13 +74,13 @@ describe('DevicesManager', () => {
|
|
|
74
74
|
shareId: 'shareid',
|
|
75
75
|
} as DeviceMetadata;
|
|
76
76
|
|
|
77
|
-
sharesService.
|
|
77
|
+
sharesService.getRootIDs.mockResolvedValue({ volumeId });
|
|
78
78
|
cryptoService.createDevice.mockResolvedValue({ address, shareKey, node });
|
|
79
79
|
apiService.createDevice.mockResolvedValue(createdDevice);
|
|
80
80
|
|
|
81
81
|
const result = await manager.createDevice(name, deviceType);
|
|
82
82
|
|
|
83
|
-
expect(sharesService.
|
|
83
|
+
expect(sharesService.getRootIDs).toHaveBeenCalled();
|
|
84
84
|
expect(cryptoService.createDevice).toHaveBeenCalledWith(name);
|
|
85
85
|
expect(apiService.createDevice).toHaveBeenCalledWith(
|
|
86
86
|
{ volumeId, type: deviceType },
|
|
@@ -47,7 +47,7 @@ export class DevicesManager {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
async createDevice(name: string, deviceType: DeviceType): Promise<Device> {
|
|
50
|
-
const { volumeId } = await this.sharesService.
|
|
50
|
+
const { volumeId } = await this.sharesService.getRootIDs();
|
|
51
51
|
const { address, shareKey, node } = await this.cryptoService.createDevice(name);
|
|
52
52
|
|
|
53
53
|
const device = await this.apiService.createDevice(
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { VERIFICATION_STATUS } from '../crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { AbortError, ConnectionError, RateLimitedError, ValidationError } from '../errors';
|
|
3
|
+
import { getVerificationMessage, isNotApplicationError } from './errors';
|
|
3
4
|
|
|
4
5
|
describe('getVerificationMessage', () => {
|
|
5
6
|
const testCases: [VERIFICATION_STATUS, Error[] | undefined, string | undefined, boolean, string][] = [
|
|
@@ -53,3 +54,63 @@ describe('getVerificationMessage', () => {
|
|
|
53
54
|
});
|
|
54
55
|
}
|
|
55
56
|
});
|
|
57
|
+
|
|
58
|
+
describe('isNotApplicationError', () => {
|
|
59
|
+
describe('SDK errors that should be ignored', () => {
|
|
60
|
+
it('returns true for AbortError', () => {
|
|
61
|
+
const error = new AbortError('Operation aborted');
|
|
62
|
+
expect(isNotApplicationError(error)).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('returns true for ValidationError', () => {
|
|
66
|
+
const error = new ValidationError('Validation failed');
|
|
67
|
+
expect(isNotApplicationError(error)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('returns true for RateLimitedError', () => {
|
|
71
|
+
const error = new RateLimitedError('Rate limited');
|
|
72
|
+
expect(isNotApplicationError(error)).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('returns true for ConnectionError', () => {
|
|
76
|
+
const error = new ConnectionError('Connection failed');
|
|
77
|
+
expect(isNotApplicationError(error)).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('General errors with specific names that should be ignored', () => {
|
|
82
|
+
it('returns true for Error with name AbortError', () => {
|
|
83
|
+
const error = new Error('Aborted');
|
|
84
|
+
error.name = 'AbortError';
|
|
85
|
+
expect(isNotApplicationError(error)).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('returns true for Error with name OfflineError', () => {
|
|
89
|
+
const error = new Error('Offline');
|
|
90
|
+
error.name = 'OfflineError';
|
|
91
|
+
expect(isNotApplicationError(error)).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('returns true for Error with name TimeoutError', () => {
|
|
95
|
+
const error = new Error('Timeout');
|
|
96
|
+
error.name = 'TimeoutError';
|
|
97
|
+
expect(isNotApplicationError(error)).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('Errors that should not be ignored', () => {
|
|
102
|
+
it('returns false for regular Error', () => {
|
|
103
|
+
const error = new Error('Regular error');
|
|
104
|
+
expect(isNotApplicationError(error)).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('returns false for undefined', () => {
|
|
108
|
+
expect(isNotApplicationError(undefined)).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('returns false for non-Error object', () => {
|
|
112
|
+
const error = { message: 'Not an error' };
|
|
113
|
+
expect(isNotApplicationError(error)).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
});
|
package/src/internal/errors.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { c } from 'ttag';
|
|
2
2
|
|
|
3
3
|
import { VERIFICATION_STATUS } from '../crypto';
|
|
4
|
+
import { AbortError, ConnectionError, RateLimitedError, ValidationError } from '../errors';
|
|
4
5
|
|
|
5
6
|
export function getErrorMessage(error: unknown): string {
|
|
6
7
|
return error instanceof Error ? error.message : c('Error').t`Unknown error`;
|
|
@@ -36,3 +37,29 @@ export function getVerificationMessage(
|
|
|
36
37
|
? c('Error').t`Signature verification for ${signatureType} failed`
|
|
37
38
|
: c('Error').t`Signature verification failed`;
|
|
38
39
|
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Returns true if the error is not an application error (it is for example
|
|
43
|
+
* a network error failing to fetch keys) and can be ignored for telemetry.
|
|
44
|
+
*/
|
|
45
|
+
export function isNotApplicationError(error?: unknown): boolean {
|
|
46
|
+
// SDK errors.
|
|
47
|
+
if (
|
|
48
|
+
error instanceof AbortError ||
|
|
49
|
+
error instanceof ValidationError ||
|
|
50
|
+
error instanceof RateLimitedError ||
|
|
51
|
+
error instanceof ConnectionError
|
|
52
|
+
) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// General errors that can come from the SDK dependencies (notably Account
|
|
57
|
+
// dependency which loads the keys for the crypto services).
|
|
58
|
+
if (error instanceof Error) {
|
|
59
|
+
if (error.name === 'AbortError' || error.name === 'OfflineError' || error.name === 'TimeoutError') {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
@@ -9,12 +9,9 @@ import {
|
|
|
9
9
|
MetricsDecryptionErrorField,
|
|
10
10
|
MetricVerificationErrorField,
|
|
11
11
|
} from '../../interface';
|
|
12
|
-
import { getVerificationMessage } from '../errors';
|
|
12
|
+
import { getVerificationMessage, isNotApplicationError } from '../errors';
|
|
13
13
|
import { splitNodeUid } from '../uids';
|
|
14
|
-
import {
|
|
15
|
-
EncryptedNode,
|
|
16
|
-
SharesService,
|
|
17
|
-
} from './interface';
|
|
14
|
+
import { EncryptedNode, SharesService } from './interface';
|
|
18
15
|
|
|
19
16
|
export class NodesCryptoReporter {
|
|
20
17
|
private logger: Logger;
|
|
@@ -92,6 +89,10 @@ export class NodesCryptoReporter {
|
|
|
92
89
|
}
|
|
93
90
|
|
|
94
91
|
async reportDecryptionError(node: EncryptedNode, field: MetricsDecryptionErrorField, error: unknown) {
|
|
92
|
+
if (isNotApplicationError(error)) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
95
96
|
if (this.reportedDecryptionErrors.has(node.uid)) {
|
|
96
97
|
return;
|
|
97
98
|
}
|
|
@@ -53,7 +53,7 @@ describe('nodesModules integration tests', () => {
|
|
|
53
53
|
driveCrypto = {};
|
|
54
54
|
// @ts-expect-error No need to implement all methods for mocking
|
|
55
55
|
sharesService = {
|
|
56
|
-
|
|
56
|
+
getRootIDs: jest.fn().mockResolvedValue({ volumeId: 'volumeId' }),
|
|
57
57
|
};
|
|
58
58
|
|
|
59
59
|
nodesModule = initNodesModule(
|
|
@@ -177,7 +177,7 @@ export interface DecryptedRevision extends Revision {
|
|
|
177
177
|
* Interface describing the dependencies to the shares module.
|
|
178
178
|
*/
|
|
179
179
|
export interface SharesService {
|
|
180
|
-
|
|
180
|
+
getRootIDs(): Promise<{ volumeId: string; rootNodeId: string }>;
|
|
181
181
|
getSharePrivateKey(shareId: string): Promise<PrivateKey>;
|
|
182
182
|
getMyFilesShareMemberEmailKey(): Promise<{
|
|
183
183
|
email: string;
|
|
@@ -46,7 +46,7 @@ describe('nodesAccess', () => {
|
|
|
46
46
|
};
|
|
47
47
|
// @ts-expect-error No need to implement all methods for mocking
|
|
48
48
|
shareService = {
|
|
49
|
-
|
|
49
|
+
getRootIDs: jest.fn().mockResolvedValue({ volumeId: 'volumeId' }),
|
|
50
50
|
getSharePrivateKey: jest.fn(),
|
|
51
51
|
};
|
|
52
52
|
|
|
@@ -388,7 +388,7 @@ describe('nodesAccess', () => {
|
|
|
388
388
|
const node4 = { uid: 'volumeId~node4', isStale: false } as DecryptedNode;
|
|
389
389
|
|
|
390
390
|
beforeEach(() => {
|
|
391
|
-
shareService.
|
|
391
|
+
shareService.getRootIDs = jest.fn().mockResolvedValue({ volumeId });
|
|
392
392
|
apiService.iterateTrashedNodeUids = jest.fn().mockImplementation(async function* () {
|
|
393
393
|
yield node1.uid;
|
|
394
394
|
yield node2.uid;
|
|
@@ -61,7 +61,7 @@ export class NodesAccess {
|
|
|
61
61
|
private cryptoService: NodesCryptoService,
|
|
62
62
|
private shareService: Pick<
|
|
63
63
|
SharesService,
|
|
64
|
-
'
|
|
64
|
+
'getRootIDs' | 'getSharePrivateKey' | 'getContextShareMemberEmailKey'
|
|
65
65
|
>,
|
|
66
66
|
) {
|
|
67
67
|
this.logger = telemetry.getLogger('nodes');
|
|
@@ -74,7 +74,7 @@ export class NodesAccess {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
async getVolumeRootFolder() {
|
|
77
|
-
const { volumeId, rootNodeId } = await this.shareService.
|
|
77
|
+
const { volumeId, rootNodeId } = await this.shareService.getRootIDs();
|
|
78
78
|
const nodeUid = makeNodeUid(volumeId, rootNodeId);
|
|
79
79
|
return this.getNode(nodeUid);
|
|
80
80
|
}
|
|
@@ -154,7 +154,7 @@ export class NodesAccess {
|
|
|
154
154
|
|
|
155
155
|
// Improvement requested: keep status of loaded trash and leverage cache.
|
|
156
156
|
async *iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator<DecryptedNode> {
|
|
157
|
-
const { volumeId } = await this.shareService.
|
|
157
|
+
const { volumeId } = await this.shareService.getRootIDs();
|
|
158
158
|
const batchLoading = new BatchLoading<string, DecryptedNode>({
|
|
159
159
|
iterateItems: (nodeUids) => this.loadNodes(nodeUids, undefined, signal),
|
|
160
160
|
batchSize: BATCH_LOADING_SIZE,
|
|
@@ -230,7 +230,7 @@ export class NodesAccess {
|
|
|
230
230
|
private async loadNode(nodeUid: string): Promise<{ node: DecryptedNode; keys?: DecryptedNodeKeys }> {
|
|
231
231
|
this.debouncer.loadingNode(nodeUid);
|
|
232
232
|
try {
|
|
233
|
-
const { volumeId: ownVolumeId } = await this.shareService.
|
|
233
|
+
const { volumeId: ownVolumeId } = await this.shareService.getRootIDs();
|
|
234
234
|
const encryptedNode = await this.apiService.getNode(nodeUid, ownVolumeId);
|
|
235
235
|
return this.decryptNode(encryptedNode);
|
|
236
236
|
} finally {
|
|
@@ -259,7 +259,7 @@ export class NodesAccess {
|
|
|
259
259
|
const returnedNodeUids: string[] = [];
|
|
260
260
|
const errors = [];
|
|
261
261
|
|
|
262
|
-
const { volumeId: ownVolumeId } = await this.shareService.
|
|
262
|
+
const { volumeId: ownVolumeId } = await this.shareService.getRootIDs();
|
|
263
263
|
|
|
264
264
|
const apiNodesIterator = this.apiService.iterateNodes(nodeUids, ownVolumeId, filterOptions, signal);
|
|
265
265
|
|
|
@@ -21,7 +21,7 @@ export class Albums {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
async *iterateAlbums(signal?: AbortSignal): AsyncGenerator<DecryptedNode> {
|
|
24
|
-
const { volumeId } = await this.photoShares.
|
|
24
|
+
const { volumeId } = await this.photoShares.getRootIDs();
|
|
25
25
|
|
|
26
26
|
const batchLoading = new BatchLoading<string, DecryptedNode>({
|
|
27
27
|
iterateItems: (nodeUids) => this.iterateNodesAndIgnoreMissingOnes(nodeUids, signal),
|
|
@@ -18,6 +18,13 @@ type GetTimelineResponse =
|
|
|
18
18
|
type GetAlbumsResponse =
|
|
19
19
|
drivePaths['/drive/photos/volumes/{volumeID}/albums']['get']['responses']['200']['content']['application/json'];
|
|
20
20
|
|
|
21
|
+
type PostPhotoDuplicateRequest = Extract<
|
|
22
|
+
drivePaths['/drive/volumes/{volumeID}/photos/duplicates']['post']['requestBody'],
|
|
23
|
+
{ content: object }
|
|
24
|
+
>['content']['application/json'];
|
|
25
|
+
type PostPhotoDuplicateResponse =
|
|
26
|
+
drivePaths['/drive/volumes/{volumeID}/photos/duplicates']['post']['responses']['200']['content']['application/json'];
|
|
27
|
+
|
|
21
28
|
/**
|
|
22
29
|
* Provides API communication for fetching and manipulating photos and albums
|
|
23
30
|
* metadata.
|
|
@@ -148,4 +155,37 @@ export class PhotosAPIService {
|
|
|
148
155
|
anchor = response.AnchorID;
|
|
149
156
|
}
|
|
150
157
|
}
|
|
158
|
+
|
|
159
|
+
async checkPhotoDuplicates(
|
|
160
|
+
volumeId: string,
|
|
161
|
+
nameHashes: string[],
|
|
162
|
+
signal?: AbortSignal,
|
|
163
|
+
): Promise<
|
|
164
|
+
{
|
|
165
|
+
nameHash: string;
|
|
166
|
+
contentHash: string;
|
|
167
|
+
nodeUid: string;
|
|
168
|
+
clientUid?: string;
|
|
169
|
+
}[]
|
|
170
|
+
> {
|
|
171
|
+
const response = await this.apiService.post<PostPhotoDuplicateRequest, PostPhotoDuplicateResponse>(
|
|
172
|
+
`drive/volumes/${volumeId}/photos/duplicates`,
|
|
173
|
+
{
|
|
174
|
+
NameHashes: nameHashes,
|
|
175
|
+
},
|
|
176
|
+
signal,
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
return response.DuplicateHashes.map((duplicate) => {
|
|
180
|
+
if (!duplicate.Hash || !duplicate.ContentHash || duplicate.LinkState !== 1 /* Active */) {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
nameHash: duplicate.Hash,
|
|
185
|
+
contentHash: duplicate.ContentHash,
|
|
186
|
+
nodeUid: makeNodeUid(volumeId, duplicate.LinkID),
|
|
187
|
+
clientUid: duplicate.ClientUID || undefined,
|
|
188
|
+
};
|
|
189
|
+
}).filter((duplicate) => duplicate !== undefined);
|
|
190
|
+
}
|
|
151
191
|
}
|
|
@@ -36,12 +36,20 @@ export const PHOTOS_SHARE_TARGET_TYPES = [ShareTargetType.Photo, ShareTargetType
|
|
|
36
36
|
* including API communication, crypto, caching, and event handling.
|
|
37
37
|
*/
|
|
38
38
|
export function initPhotosModule(
|
|
39
|
+
telemetry: ProtonDriveTelemetry,
|
|
39
40
|
apiService: DriveAPIService,
|
|
41
|
+
driveCrypto: DriveCrypto,
|
|
40
42
|
photoShares: PhotoSharesManager,
|
|
41
43
|
nodesService: NodesService,
|
|
42
44
|
) {
|
|
43
45
|
const api = new PhotosAPIService(apiService);
|
|
44
|
-
const timeline = new PhotosTimeline(
|
|
46
|
+
const timeline = new PhotosTimeline(
|
|
47
|
+
telemetry.getLogger('photos-timeline'),
|
|
48
|
+
api,
|
|
49
|
+
driveCrypto,
|
|
50
|
+
photoShares,
|
|
51
|
+
nodesService,
|
|
52
|
+
);
|
|
45
53
|
const albums = new Albums(api, photoShares, nodesService);
|
|
46
54
|
|
|
47
55
|
return {
|
|
@@ -4,7 +4,7 @@ import { DecryptedNode } from '../nodes';
|
|
|
4
4
|
import { EncryptedShare } from '../shares';
|
|
5
5
|
|
|
6
6
|
export interface SharesService {
|
|
7
|
-
|
|
7
|
+
getRootIDs(): Promise<{ volumeId: string; rootNodeId: string }>;
|
|
8
8
|
loadEncryptedShare(shareId: string): Promise<EncryptedShare>;
|
|
9
9
|
getSharePrivateKey(shareId: string): Promise<PrivateKey>;
|
|
10
10
|
getMyFilesShareMemberEmailKey(): Promise<{
|
|
@@ -26,4 +26,7 @@ export interface SharesService {
|
|
|
26
26
|
export interface NodesService {
|
|
27
27
|
getNode(nodeUid: string): Promise<DecryptedNode>;
|
|
28
28
|
iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator<DecryptedNode | MissingNode>;
|
|
29
|
+
getNodeKeys(nodeUid: string): Promise<{
|
|
30
|
+
hashKey?: Uint8Array;
|
|
31
|
+
}>;
|
|
29
32
|
}
|
|
@@ -34,7 +34,7 @@ export class PhotoSharesManager {
|
|
|
34
34
|
this.sharesService = sharesService;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
async
|
|
37
|
+
async getRootIDs(): Promise<VolumeShareNodeIDs> {
|
|
38
38
|
if (this.photoRootIds) {
|
|
39
39
|
return this.photoRootIds;
|
|
40
40
|
}
|
|
@@ -113,7 +113,7 @@ export class PhotoSharesManager {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
async isOwnVolume(volumeId: string): Promise<boolean> {
|
|
116
|
-
const { volumeId: myVolumeId } = await this.
|
|
116
|
+
const { volumeId: myVolumeId } = await this.getRootIDs();
|
|
117
117
|
if (volumeId === myVolumeId) {
|
|
118
118
|
return true;
|
|
119
119
|
}
|
|
@@ -121,7 +121,7 @@ export class PhotoSharesManager {
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
async getVolumeMetricContext(volumeId: string): Promise<MetricVolumeType> {
|
|
124
|
-
const { volumeId: myVolumeId } = await this.
|
|
124
|
+
const { volumeId: myVolumeId } = await this.getRootIDs();
|
|
125
125
|
if (volumeId === myVolumeId) {
|
|
126
126
|
return MetricVolumeType.OwnVolume;
|
|
127
127
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { getMockLogger } from '../../tests/logger';
|
|
2
|
+
import { DriveCrypto } from '../../crypto';
|
|
3
|
+
import { makeNodeUid } from '../uids';
|
|
4
|
+
import { PhotosAPIService } from './apiService';
|
|
5
|
+
import { NodesService } from './interface';
|
|
6
|
+
import { PhotoSharesManager } from './shares';
|
|
7
|
+
import { PhotosTimeline } from './timeline';
|
|
8
|
+
|
|
9
|
+
describe('PhotosTimeline', () => {
|
|
10
|
+
let logger: ReturnType<typeof getMockLogger>;
|
|
11
|
+
let apiService: PhotosAPIService;
|
|
12
|
+
let driveCrypto: DriveCrypto;
|
|
13
|
+
let photoShares: PhotoSharesManager;
|
|
14
|
+
let nodesService: NodesService;
|
|
15
|
+
let timeline: PhotosTimeline;
|
|
16
|
+
|
|
17
|
+
const volumeId = 'volumeId';
|
|
18
|
+
const rootNodeId = 'rootNodeId';
|
|
19
|
+
const rootNodeUid = makeNodeUid(volumeId, rootNodeId);
|
|
20
|
+
const hashKey = new Uint8Array([1, 2, 3]);
|
|
21
|
+
const name = 'photo.jpg';
|
|
22
|
+
const nameHash = 'nameHash123';
|
|
23
|
+
const sha1 = 'sha1Hash123';
|
|
24
|
+
const contentHash = 'contentHash123';
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
logger = getMockLogger();
|
|
28
|
+
// @ts-expect-error No need to implement all methods for mocking
|
|
29
|
+
apiService = {
|
|
30
|
+
checkPhotoDuplicates: jest.fn(),
|
|
31
|
+
};
|
|
32
|
+
// @ts-expect-error No need to implement all methods for mocking
|
|
33
|
+
driveCrypto = {
|
|
34
|
+
generateLookupHash: jest.fn(),
|
|
35
|
+
};
|
|
36
|
+
// @ts-expect-error No need to implement all methods for mocking
|
|
37
|
+
photoShares = {
|
|
38
|
+
getRootIDs: jest.fn().mockResolvedValue({ volumeId, rootNodeId }),
|
|
39
|
+
};
|
|
40
|
+
// @ts-expect-error No need to implement all methods for mocking
|
|
41
|
+
nodesService = {
|
|
42
|
+
getNodeKeys: jest.fn().mockResolvedValue({ hashKey }),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
timeline = new PhotosTimeline(logger, apiService, driveCrypto, photoShares, nodesService);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('isDuplicatePhoto', () => {
|
|
49
|
+
it('should not call sha1 callback when there is no name hash match', async () => {
|
|
50
|
+
const generateSha1 = jest.fn();
|
|
51
|
+
apiService.checkPhotoDuplicates = jest.fn().mockResolvedValue([]);
|
|
52
|
+
driveCrypto.generateLookupHash = jest.fn().mockResolvedValue(nameHash);
|
|
53
|
+
|
|
54
|
+
const result = await timeline.isDuplicatePhoto(name, generateSha1);
|
|
55
|
+
|
|
56
|
+
expect(result).toBe(false);
|
|
57
|
+
expect(generateSha1).not.toHaveBeenCalled();
|
|
58
|
+
expect(photoShares.getRootIDs).toHaveBeenCalled();
|
|
59
|
+
expect(nodesService.getNodeKeys).toHaveBeenCalledWith(rootNodeUid);
|
|
60
|
+
expect(driveCrypto.generateLookupHash).toHaveBeenCalledWith(name, hashKey);
|
|
61
|
+
expect(apiService.checkPhotoDuplicates).toHaveBeenCalledWith(volumeId, [nameHash], undefined);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should call sha1 callback and not logger when name hash match but content hash does not', async () => {
|
|
65
|
+
const generateSha1 = jest.fn().mockResolvedValue(sha1);
|
|
66
|
+
const duplicates = [
|
|
67
|
+
{
|
|
68
|
+
nameHash: nameHash,
|
|
69
|
+
contentHash: 'differentContentHash',
|
|
70
|
+
nodeUid: 'volumeId~node1',
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
apiService.checkPhotoDuplicates = jest.fn().mockResolvedValue(duplicates);
|
|
74
|
+
driveCrypto.generateLookupHash = jest
|
|
75
|
+
.fn()
|
|
76
|
+
.mockResolvedValueOnce(nameHash)
|
|
77
|
+
.mockResolvedValueOnce(contentHash);
|
|
78
|
+
|
|
79
|
+
const result = await timeline.isDuplicatePhoto(name, generateSha1);
|
|
80
|
+
|
|
81
|
+
expect(result).toBe(false);
|
|
82
|
+
expect(generateSha1).toHaveBeenCalledTimes(1);
|
|
83
|
+
expect(driveCrypto.generateLookupHash).toHaveBeenCalledTimes(2);
|
|
84
|
+
expect(driveCrypto.generateLookupHash).toHaveBeenNthCalledWith(1, name, hashKey);
|
|
85
|
+
expect(driveCrypto.generateLookupHash).toHaveBeenNthCalledWith(2, sha1, hashKey);
|
|
86
|
+
expect(logger.debug).not.toHaveBeenCalled();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should call sha1 and logger when name and content hashes match', async () => {
|
|
90
|
+
const generateSha1 = jest.fn().mockResolvedValue(sha1);
|
|
91
|
+
const nodeUid1 = 'volumeId~node1';
|
|
92
|
+
const duplicates = [
|
|
93
|
+
{
|
|
94
|
+
nameHash: nameHash,
|
|
95
|
+
contentHash: contentHash,
|
|
96
|
+
nodeUid: nodeUid1,
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
apiService.checkPhotoDuplicates = jest.fn().mockResolvedValue(duplicates);
|
|
100
|
+
driveCrypto.generateLookupHash = jest
|
|
101
|
+
.fn()
|
|
102
|
+
.mockResolvedValueOnce(nameHash)
|
|
103
|
+
.mockResolvedValueOnce(contentHash);
|
|
104
|
+
|
|
105
|
+
const result = await timeline.isDuplicatePhoto(name, generateSha1);
|
|
106
|
+
|
|
107
|
+
expect(result).toBe(true);
|
|
108
|
+
expect(generateSha1).toHaveBeenCalledTimes(1);
|
|
109
|
+
expect(logger.debug).toHaveBeenCalledTimes(1);
|
|
110
|
+
expect(logger.debug).toHaveBeenCalledWith(
|
|
111
|
+
`Duplicate photo found: name hash: ${nameHash}, content hash: ${contentHash}, node uids: ${nodeUid1}`,
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import { DriveCrypto } from '../../crypto';
|
|
2
|
+
import { Logger } from '../../interface';
|
|
3
|
+
import { makeNodeUid } from '../uids';
|
|
1
4
|
import { PhotosAPIService } from './apiService';
|
|
5
|
+
import { NodesService } from './interface';
|
|
2
6
|
import { PhotoSharesManager } from './shares';
|
|
3
7
|
|
|
4
8
|
/**
|
|
@@ -6,19 +10,60 @@ import { PhotoSharesManager } from './shares';
|
|
|
6
10
|
*/
|
|
7
11
|
export class PhotosTimeline {
|
|
8
12
|
constructor(
|
|
13
|
+
private logger: Logger,
|
|
9
14
|
private apiService: PhotosAPIService,
|
|
15
|
+
private driveCrypto: DriveCrypto,
|
|
10
16
|
private photoShares: PhotoSharesManager,
|
|
17
|
+
private nodesService: NodesService,
|
|
11
18
|
) {
|
|
19
|
+
this.logger = logger;
|
|
12
20
|
this.apiService = apiService;
|
|
21
|
+
this.driveCrypto = driveCrypto;
|
|
13
22
|
this.photoShares = photoShares;
|
|
23
|
+
this.nodesService = nodesService;
|
|
14
24
|
}
|
|
15
25
|
|
|
16
|
-
async*
|
|
26
|
+
async *iterateTimeline(signal?: AbortSignal): AsyncGenerator<{
|
|
17
27
|
nodeUid: string;
|
|
18
28
|
captureTime: Date;
|
|
19
29
|
tags: number[];
|
|
20
30
|
}> {
|
|
21
|
-
const { volumeId } = await this.photoShares.
|
|
31
|
+
const { volumeId } = await this.photoShares.getRootIDs();
|
|
22
32
|
yield* this.apiService.iterateTimeline(volumeId, signal);
|
|
23
33
|
}
|
|
34
|
+
|
|
35
|
+
async isDuplicatePhoto(name: string, generateSha1: () => Promise<string>, signal?: AbortSignal): Promise<boolean> {
|
|
36
|
+
const { volumeId, rootNodeId } = await this.photoShares.getRootIDs();
|
|
37
|
+
const rootNodeUid = makeNodeUid(volumeId, rootNodeId);
|
|
38
|
+
const { hashKey } = await this.nodesService.getNodeKeys(rootNodeUid);
|
|
39
|
+
if (!hashKey) {
|
|
40
|
+
throw new Error('Hash key of photo root node not found');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const nameHash = await this.driveCrypto.generateLookupHash(name, hashKey);
|
|
44
|
+
const duplicates = await this.apiService.checkPhotoDuplicates(volumeId, [nameHash], signal);
|
|
45
|
+
|
|
46
|
+
if (duplicates.length === 0) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Generate the SHA1 only when there is any matching node hash to avoid
|
|
51
|
+
// computing it for every node as in most cases there is no match.
|
|
52
|
+
const sha1 = await generateSha1();
|
|
53
|
+
const contentHash = await this.driveCrypto.generateLookupHash(sha1, hashKey);
|
|
54
|
+
|
|
55
|
+
const matchingDuplicates = duplicates.filter(
|
|
56
|
+
(duplicate) => duplicate.nameHash === nameHash && duplicate.contentHash === contentHash,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
if (matchingDuplicates.length === 0) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const nodeUids = matchingDuplicates.map((duplicate) => duplicate.nodeUid);
|
|
64
|
+
this.logger.debug(
|
|
65
|
+
`Duplicate photo found: name hash: ${nameHash}, content hash: ${contentHash}, node uids: ${nodeUids}`,
|
|
66
|
+
);
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
24
69
|
}
|
|
@@ -220,7 +220,7 @@ export class PhotoUploadAPIService extends UploadAPIService {
|
|
|
220
220
|
XAttr: options.armoredExtendedAttributes || null,
|
|
221
221
|
Photo: {
|
|
222
222
|
ContentHash: photo.contentHash,
|
|
223
|
-
CaptureTime: photo.captureTime?.getTime()
|
|
223
|
+
CaptureTime: photo.captureTime ? Math.floor(photo.captureTime?.getTime() /1000) : 0,
|
|
224
224
|
MainPhotoLinkID: photo.mainPhotoLinkID || null,
|
|
225
225
|
Tags: photo.tags || [],
|
|
226
226
|
Exif: null, // Deprecated field, not used.
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
MetricVolumeType,
|
|
10
10
|
} from '../../interface';
|
|
11
11
|
import { DriveCrypto, PrivateKey, VERIFICATION_STATUS } from '../../crypto';
|
|
12
|
-
import { getVerificationMessage } from '../errors';
|
|
12
|
+
import { getVerificationMessage, isNotApplicationError } from '../errors';
|
|
13
13
|
import {
|
|
14
14
|
EncryptedRootShare,
|
|
15
15
|
DecryptedRootShare,
|
|
@@ -119,6 +119,10 @@ export class SharesCryptoService {
|
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
private reportDecryptionError(share: EncryptedRootShare, error?: unknown) {
|
|
122
|
+
if (isNotApplicationError(error)) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
122
126
|
if (this.reportedDecryptionErrors.has(share.shareId)) {
|
|
123
127
|
return;
|
|
124
128
|
}
|