@protontech/drive-sdk 0.9.6 → 0.9.8
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/interface/upload.d.ts +10 -0
- package/dist/internal/nodes/mediaTypes.d.ts +2 -0
- package/dist/internal/nodes/mediaTypes.js +3 -0
- package/dist/internal/nodes/mediaTypes.js.map +1 -1
- package/dist/internal/nodes/nodesManagement.js +2 -1
- package/dist/internal/nodes/nodesManagement.js.map +1 -1
- package/dist/internal/photos/albums.d.ts +12 -1
- package/dist/internal/photos/albums.js +80 -1
- package/dist/internal/photos/albums.js.map +1 -1
- package/dist/internal/photos/albums.test.d.ts +1 -0
- package/dist/internal/photos/albums.test.js +193 -0
- package/dist/internal/photos/albums.test.js.map +1 -0
- package/dist/internal/photos/albumsCrypto.d.ts +35 -0
- package/dist/internal/photos/albumsCrypto.js +66 -0
- package/dist/internal/photos/albumsCrypto.js.map +1 -0
- package/dist/internal/photos/albumsCrypto.test.d.ts +1 -0
- package/dist/internal/photos/albumsCrypto.test.js +134 -0
- package/dist/internal/photos/albumsCrypto.test.js.map +1 -0
- package/dist/internal/photos/apiService.d.ts +18 -0
- package/dist/internal/photos/apiService.js +48 -0
- package/dist/internal/photos/apiService.js.map +1 -1
- package/dist/internal/photos/index.d.ts +3 -3
- package/dist/internal/photos/index.js +5 -3
- package/dist/internal/photos/index.js.map +1 -1
- package/dist/internal/photos/upload.js +3 -2
- package/dist/internal/photos/upload.js.map +1 -1
- package/dist/internal/upload/streamUploader.d.ts +3 -1
- package/dist/internal/upload/streamUploader.js +10 -3
- package/dist/internal/upload/streamUploader.js.map +1 -1
- package/dist/internal/upload/streamUploader.test.js +10 -0
- package/dist/internal/upload/streamUploader.test.js.map +1 -1
- package/dist/protonDrivePhotosClient.d.ts +39 -3
- package/dist/protonDrivePhotosClient.js +49 -5
- package/dist/protonDrivePhotosClient.js.map +1 -1
- package/dist/protonDrivePublicLinkClient.d.ts +6 -0
- package/dist/protonDrivePublicLinkClient.js +8 -0
- package/dist/protonDrivePublicLinkClient.js.map +1 -1
- package/package.json +1 -1
- package/src/interface/upload.ts +10 -0
- package/src/internal/nodes/mediaTypes.ts +3 -0
- package/src/internal/nodes/nodesManagement.ts +2 -1
- package/src/internal/photos/albums.test.ts +241 -0
- package/src/internal/photos/albums.ts +119 -0
- package/src/internal/photos/albumsCrypto.test.ts +181 -0
- package/src/internal/photos/albumsCrypto.ts +100 -0
- package/src/internal/photos/apiService.ts +94 -2
- package/src/internal/photos/index.ts +6 -4
- package/src/internal/photos/upload.ts +3 -2
- package/src/internal/upload/streamUploader.test.ts +38 -0
- package/src/internal/upload/streamUploader.ts +10 -3
- package/src/protonDrivePhotosClient.ts +67 -6
- package/src/protonDrivePublicLinkClient.ts +14 -0
|
@@ -1,6 +1,12 @@
|
|
|
1
|
+
import { MemberRole, NodeType, resultOk } from '../../interface';
|
|
1
2
|
import { BatchLoading } from '../batchLoading';
|
|
2
3
|
import { DecryptedNode } from '../nodes';
|
|
4
|
+
import { ALBUM_MEDIA_TYPE } from '../nodes/mediaTypes';
|
|
5
|
+
import { validateNodeName } from '../nodes/validations';
|
|
6
|
+
import { splitNodeUid } from '../uids';
|
|
7
|
+
import { AlbumsCryptoService } from './albumsCrypto';
|
|
3
8
|
import { PhotosAPIService } from './apiService';
|
|
9
|
+
import { DecryptedPhotoNode } from './interface';
|
|
4
10
|
import { PhotosNodesAccess } from './nodes';
|
|
5
11
|
import { PhotoSharesManager } from './shares';
|
|
6
12
|
|
|
@@ -12,10 +18,12 @@ const BATCH_LOADING_SIZE = 10;
|
|
|
12
18
|
export class Albums {
|
|
13
19
|
constructor(
|
|
14
20
|
private apiService: PhotosAPIService,
|
|
21
|
+
private cryptoService: AlbumsCryptoService,
|
|
15
22
|
private photoShares: PhotoSharesManager,
|
|
16
23
|
private nodesService: PhotosNodesAccess,
|
|
17
24
|
) {
|
|
18
25
|
this.apiService = apiService;
|
|
26
|
+
this.cryptoService = cryptoService;
|
|
19
27
|
this.photoShares = photoShares;
|
|
20
28
|
this.nodesService = nodesService;
|
|
21
29
|
}
|
|
@@ -33,6 +41,117 @@ export class Albums {
|
|
|
33
41
|
yield* batchLoading.loadRest();
|
|
34
42
|
}
|
|
35
43
|
|
|
44
|
+
async createAlbum(name: string): Promise<DecryptedPhotoNode> {
|
|
45
|
+
validateNodeName(name);
|
|
46
|
+
|
|
47
|
+
const rootNode = await this.nodesService.getVolumeRootFolder();
|
|
48
|
+
const parentKeys = await this.nodesService.getNodeKeys(rootNode.uid);
|
|
49
|
+
if (!parentKeys.hashKey) {
|
|
50
|
+
throw new Error('Cannot create album: parent folder hash key not available');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const signingKeys = await this.nodesService.getNodeSigningKeys({ parentNodeUid: rootNode.uid });
|
|
54
|
+
const { encryptedCrypto } = await this.cryptoService.createAlbum(
|
|
55
|
+
{ key: parentKeys.key, hashKey: parentKeys.hashKey },
|
|
56
|
+
signingKeys,
|
|
57
|
+
name,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const nodeUid = await this.apiService.createAlbum(rootNode.uid, {
|
|
61
|
+
encryptedName: encryptedCrypto.encryptedName,
|
|
62
|
+
hash: encryptedCrypto.hash,
|
|
63
|
+
armoredKey: encryptedCrypto.armoredKey,
|
|
64
|
+
armoredNodePassphrase: encryptedCrypto.armoredNodePassphrase,
|
|
65
|
+
armoredNodePassphraseSignature: encryptedCrypto.armoredNodePassphraseSignature,
|
|
66
|
+
signatureEmail: encryptedCrypto.signatureEmail,
|
|
67
|
+
armoredHashKey: encryptedCrypto.armoredHashKey,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await this.nodesService.notifyChildCreated(rootNode.uid);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
// Internal metadata
|
|
74
|
+
hash: encryptedCrypto.hash,
|
|
75
|
+
encryptedName: encryptedCrypto.encryptedName,
|
|
76
|
+
|
|
77
|
+
// Basic node metadata
|
|
78
|
+
uid: nodeUid,
|
|
79
|
+
parentUid: rootNode.uid,
|
|
80
|
+
type: NodeType.Album,
|
|
81
|
+
mediaType: ALBUM_MEDIA_TYPE,
|
|
82
|
+
creationTime: new Date(),
|
|
83
|
+
modificationTime: new Date(),
|
|
84
|
+
|
|
85
|
+
// Share node metadata
|
|
86
|
+
isShared: false,
|
|
87
|
+
isSharedPublicly: false,
|
|
88
|
+
directRole: MemberRole.Inherited,
|
|
89
|
+
|
|
90
|
+
// Decrypted metadata
|
|
91
|
+
isStale: false,
|
|
92
|
+
keyAuthor: resultOk(encryptedCrypto.signatureEmail),
|
|
93
|
+
nameAuthor: resultOk(encryptedCrypto.signatureEmail),
|
|
94
|
+
name: resultOk(name),
|
|
95
|
+
treeEventScopeId: splitNodeUid(nodeUid).volumeId,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async updateAlbum(
|
|
100
|
+
nodeUid: string,
|
|
101
|
+
updates: {
|
|
102
|
+
name?: string;
|
|
103
|
+
coverPhotoNodeUid?: string;
|
|
104
|
+
},
|
|
105
|
+
): Promise<DecryptedPhotoNode> {
|
|
106
|
+
if (updates.name) {
|
|
107
|
+
validateNodeName(updates.name);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const node = await this.nodesService.getNode(nodeUid);
|
|
111
|
+
const newNode = { ...node };
|
|
112
|
+
|
|
113
|
+
let nameUpdate:
|
|
114
|
+
| {
|
|
115
|
+
encryptedName: string;
|
|
116
|
+
hash: string;
|
|
117
|
+
originalHash: string;
|
|
118
|
+
nameSignatureEmail: string;
|
|
119
|
+
}
|
|
120
|
+
| undefined;
|
|
121
|
+
|
|
122
|
+
if (updates.name) {
|
|
123
|
+
const parentKeys = await this.nodesService.getParentKeys(node);
|
|
124
|
+
const signingKeys = await this.nodesService.getNodeSigningKeys({ nodeUid, parentNodeUid: node.parentUid });
|
|
125
|
+
|
|
126
|
+
const { signatureEmail, armoredNodeName, hash } = await this.cryptoService.renameAlbum(
|
|
127
|
+
{ key: parentKeys.key, hashKey: parentKeys.hashKey },
|
|
128
|
+
node.encryptedName,
|
|
129
|
+
signingKeys,
|
|
130
|
+
updates.name,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
nameUpdate = {
|
|
134
|
+
encryptedName: armoredNodeName,
|
|
135
|
+
hash,
|
|
136
|
+
originalHash: node.hash || '',
|
|
137
|
+
nameSignatureEmail: signatureEmail,
|
|
138
|
+
};
|
|
139
|
+
newNode.name = resultOk(updates.name);
|
|
140
|
+
newNode.encryptedName = nameUpdate.encryptedName;
|
|
141
|
+
newNode.nameAuthor = resultOk(nameUpdate.nameSignatureEmail);
|
|
142
|
+
newNode.hash = nameUpdate.hash;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
await this.apiService.updateAlbum(nodeUid, updates.coverPhotoNodeUid, nameUpdate);
|
|
146
|
+
await this.nodesService.notifyNodeChanged(nodeUid);
|
|
147
|
+
return newNode;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async deleteAlbum(nodeUid: string, options: { force?: boolean } = {}): Promise<void> {
|
|
151
|
+
await this.apiService.deleteAlbum(nodeUid, options);
|
|
152
|
+
await this.nodesService.notifyNodeDeleted(nodeUid);
|
|
153
|
+
}
|
|
154
|
+
|
|
36
155
|
private async *iterateNodesAndIgnoreMissingOnes(
|
|
37
156
|
nodeUids: string[],
|
|
38
157
|
signal?: AbortSignal,
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { DriveCrypto, PrivateKey, SessionKey } from '../../crypto';
|
|
2
|
+
import { NodeSigningKeys } from '../nodes/interface';
|
|
3
|
+
import { AlbumsCryptoService } from './albumsCrypto';
|
|
4
|
+
|
|
5
|
+
describe('AlbumsCryptoService', () => {
|
|
6
|
+
let driveCrypto: DriveCrypto;
|
|
7
|
+
let albumsCryptoService: AlbumsCryptoService;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
jest.clearAllMocks();
|
|
11
|
+
|
|
12
|
+
// @ts-expect-error No need to implement all methods for mocking
|
|
13
|
+
driveCrypto = {};
|
|
14
|
+
|
|
15
|
+
albumsCryptoService = new AlbumsCryptoService(driveCrypto);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('createAlbum', () => {
|
|
19
|
+
let parentKeys: any;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
parentKeys = {
|
|
23
|
+
key: 'parentKey' as any,
|
|
24
|
+
hashKey: new Uint8Array([1, 2, 3]),
|
|
25
|
+
};
|
|
26
|
+
driveCrypto.generateKey = jest.fn().mockResolvedValue({
|
|
27
|
+
encrypted: {
|
|
28
|
+
armoredKey: 'encryptedNodeKey',
|
|
29
|
+
armoredPassphrase: 'encryptedPassphrase',
|
|
30
|
+
armoredPassphraseSignature: 'passphraseSignature',
|
|
31
|
+
},
|
|
32
|
+
decrypted: {
|
|
33
|
+
key: 'nodeKey' as any,
|
|
34
|
+
passphrase: 'nodePassphrase',
|
|
35
|
+
passphraseSessionKey: 'passphraseSessionKey' as any,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
driveCrypto.encryptNodeName = jest.fn().mockResolvedValue({
|
|
39
|
+
armoredNodeName: 'encryptedNodeName',
|
|
40
|
+
});
|
|
41
|
+
driveCrypto.generateLookupHash = jest.fn().mockResolvedValue('lookupHash');
|
|
42
|
+
driveCrypto.generateHashKey = jest.fn().mockResolvedValue({
|
|
43
|
+
armoredHashKey: 'encryptedHashKey',
|
|
44
|
+
hashKey: new Uint8Array([4, 5, 6]),
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should encrypt new album with user address key', async () => {
|
|
49
|
+
const signingKeys: NodeSigningKeys = {
|
|
50
|
+
type: 'userAddress',
|
|
51
|
+
email: 'test@example.com',
|
|
52
|
+
addressId: 'addressId',
|
|
53
|
+
key: 'addressKey' as any,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const result = await albumsCryptoService.createAlbum(parentKeys, signingKeys, 'My Album');
|
|
57
|
+
|
|
58
|
+
expect(result).toEqual({
|
|
59
|
+
encryptedCrypto: {
|
|
60
|
+
encryptedName: 'encryptedNodeName',
|
|
61
|
+
hash: 'lookupHash',
|
|
62
|
+
armoredKey: 'encryptedNodeKey',
|
|
63
|
+
armoredNodePassphrase: 'encryptedPassphrase',
|
|
64
|
+
armoredNodePassphraseSignature: 'passphraseSignature',
|
|
65
|
+
signatureEmail: 'test@example.com',
|
|
66
|
+
armoredHashKey: 'encryptedHashKey',
|
|
67
|
+
},
|
|
68
|
+
keys: {
|
|
69
|
+
passphrase: 'nodePassphrase',
|
|
70
|
+
key: 'nodeKey',
|
|
71
|
+
passphraseSessionKey: 'passphraseSessionKey',
|
|
72
|
+
hashKey: new Uint8Array([4, 5, 6]),
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(driveCrypto.generateKey).toHaveBeenCalledWith([parentKeys.key], signingKeys.key);
|
|
77
|
+
expect(driveCrypto.encryptNodeName).toHaveBeenCalledWith(
|
|
78
|
+
'My Album',
|
|
79
|
+
undefined,
|
|
80
|
+
parentKeys.key,
|
|
81
|
+
signingKeys.key,
|
|
82
|
+
);
|
|
83
|
+
expect(driveCrypto.generateLookupHash).toHaveBeenCalledWith('My Album', parentKeys.hashKey);
|
|
84
|
+
expect(driveCrypto.generateHashKey).toHaveBeenCalledWith('nodeKey');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should throw error when creating album by anonymous user', async () => {
|
|
88
|
+
const signingKeys: NodeSigningKeys = {
|
|
89
|
+
type: 'nodeKey',
|
|
90
|
+
nodeKey: 'nodeSigningKey' as any,
|
|
91
|
+
parentNodeKey: 'parentNodeKey' as any,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
await expect(albumsCryptoService.createAlbum(parentKeys, signingKeys, 'My Album')).rejects.toThrow(
|
|
95
|
+
'Creating album by anonymous user is not supported',
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('renameAlbum', () => {
|
|
101
|
+
let parentKeys: any;
|
|
102
|
+
let nodeNameSessionKey: SessionKey;
|
|
103
|
+
|
|
104
|
+
beforeEach(() => {
|
|
105
|
+
parentKeys = {
|
|
106
|
+
key: 'parentKey' as any,
|
|
107
|
+
hashKey: new Uint8Array([1, 2, 3]),
|
|
108
|
+
};
|
|
109
|
+
nodeNameSessionKey = 'nameSessionKey' as any;
|
|
110
|
+
driveCrypto.decryptSessionKey = jest.fn().mockResolvedValue(nodeNameSessionKey);
|
|
111
|
+
driveCrypto.encryptNodeName = jest.fn().mockResolvedValue({
|
|
112
|
+
armoredNodeName: 'encryptedNewNodeName',
|
|
113
|
+
});
|
|
114
|
+
driveCrypto.generateLookupHash = jest.fn().mockResolvedValue('newHash');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should encrypt new album name with user address key', async () => {
|
|
118
|
+
const signingKeys: NodeSigningKeys = {
|
|
119
|
+
type: 'userAddress',
|
|
120
|
+
email: 'test@example.com',
|
|
121
|
+
addressId: 'addressId',
|
|
122
|
+
key: 'addressKey' as any,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const result = await albumsCryptoService.renameAlbum(
|
|
126
|
+
parentKeys,
|
|
127
|
+
'oldEncryptedName',
|
|
128
|
+
signingKeys,
|
|
129
|
+
'Renamed Album',
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(result).toEqual({
|
|
133
|
+
signatureEmail: 'test@example.com',
|
|
134
|
+
armoredNodeName: 'encryptedNewNodeName',
|
|
135
|
+
hash: 'newHash',
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(driveCrypto.decryptSessionKey).toHaveBeenCalledWith('oldEncryptedName', parentKeys.key);
|
|
139
|
+
expect(driveCrypto.encryptNodeName).toHaveBeenCalledWith(
|
|
140
|
+
'Renamed Album',
|
|
141
|
+
nodeNameSessionKey,
|
|
142
|
+
parentKeys.key,
|
|
143
|
+
signingKeys.key,
|
|
144
|
+
);
|
|
145
|
+
expect(driveCrypto.generateLookupHash).toHaveBeenCalledWith('Renamed Album', parentKeys.hashKey);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should throw error when renaming album by anonymous user', async () => {
|
|
149
|
+
const signingKeys: NodeSigningKeys = {
|
|
150
|
+
type: 'nodeKey',
|
|
151
|
+
nodeKey: 'nodeSigningKey' as any,
|
|
152
|
+
parentNodeKey: 'parentNodeKey' as any,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
await expect(
|
|
156
|
+
albumsCryptoService.renameAlbum(parentKeys, 'oldEncryptedName', signingKeys, 'Renamed Album'),
|
|
157
|
+
).rejects.toThrow('Renaming album by anonymous user is not supported');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should throw error when parent hash key is not available', async () => {
|
|
161
|
+
const parentKeysWithoutHashKey = {
|
|
162
|
+
key: 'parentKey' as any,
|
|
163
|
+
};
|
|
164
|
+
const signingKeys: NodeSigningKeys = {
|
|
165
|
+
type: 'userAddress',
|
|
166
|
+
email: 'test@example.com',
|
|
167
|
+
addressId: 'addressId',
|
|
168
|
+
key: 'addressKey' as any,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
await expect(
|
|
172
|
+
albumsCryptoService.renameAlbum(
|
|
173
|
+
parentKeysWithoutHashKey,
|
|
174
|
+
'oldEncryptedName',
|
|
175
|
+
signingKeys,
|
|
176
|
+
'Renamed Album',
|
|
177
|
+
),
|
|
178
|
+
).rejects.toThrow('Cannot rename album: parent folder hash key not available');
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { DriveCrypto, PrivateKey } from '../../crypto';
|
|
2
|
+
import { DecryptedNodeKeys, NodeSigningKeys } from '../nodes/interface';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Provides crypto operations for albums.
|
|
6
|
+
*
|
|
7
|
+
* Albums are special folders in the photos volume. This service reuses
|
|
8
|
+
* the drive crypto module for key and name encryption operations.
|
|
9
|
+
*/
|
|
10
|
+
export class AlbumsCryptoService {
|
|
11
|
+
constructor(private driveCrypto: DriveCrypto) {
|
|
12
|
+
this.driveCrypto = driveCrypto;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async createAlbum(
|
|
16
|
+
parentKeys: { key: PrivateKey; hashKey: Uint8Array },
|
|
17
|
+
signingKeys: NodeSigningKeys,
|
|
18
|
+
name: string,
|
|
19
|
+
): Promise<{
|
|
20
|
+
encryptedCrypto: {
|
|
21
|
+
encryptedName: string;
|
|
22
|
+
hash: string;
|
|
23
|
+
armoredKey: string;
|
|
24
|
+
armoredNodePassphrase: string;
|
|
25
|
+
armoredNodePassphraseSignature: string;
|
|
26
|
+
signatureEmail: string;
|
|
27
|
+
armoredHashKey: string;
|
|
28
|
+
};
|
|
29
|
+
keys: DecryptedNodeKeys;
|
|
30
|
+
}> {
|
|
31
|
+
if (signingKeys.type !== 'userAddress') {
|
|
32
|
+
throw new Error('Creating album by anonymous user is not supported');
|
|
33
|
+
}
|
|
34
|
+
const email = signingKeys.email;
|
|
35
|
+
const nameAndPassphraseSigningKey = signingKeys.key;
|
|
36
|
+
|
|
37
|
+
const [nodeKeys, { armoredNodeName }, hash] = await Promise.all([
|
|
38
|
+
this.driveCrypto.generateKey([parentKeys.key], nameAndPassphraseSigningKey),
|
|
39
|
+
this.driveCrypto.encryptNodeName(name, undefined, parentKeys.key, nameAndPassphraseSigningKey),
|
|
40
|
+
this.driveCrypto.generateLookupHash(name, parentKeys.hashKey),
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const { armoredHashKey, hashKey } = await this.driveCrypto.generateHashKey(nodeKeys.decrypted.key);
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
encryptedCrypto: {
|
|
47
|
+
encryptedName: armoredNodeName,
|
|
48
|
+
hash,
|
|
49
|
+
armoredKey: nodeKeys.encrypted.armoredKey,
|
|
50
|
+
armoredNodePassphrase: nodeKeys.encrypted.armoredPassphrase,
|
|
51
|
+
armoredNodePassphraseSignature: nodeKeys.encrypted.armoredPassphraseSignature,
|
|
52
|
+
signatureEmail: email,
|
|
53
|
+
armoredHashKey,
|
|
54
|
+
},
|
|
55
|
+
keys: {
|
|
56
|
+
passphrase: nodeKeys.decrypted.passphrase,
|
|
57
|
+
key: nodeKeys.decrypted.key,
|
|
58
|
+
passphraseSessionKey: nodeKeys.decrypted.passphraseSessionKey,
|
|
59
|
+
hashKey,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async renameAlbum(
|
|
65
|
+
parentKeys: { key: PrivateKey; hashKey?: Uint8Array },
|
|
66
|
+
encryptedName: string,
|
|
67
|
+
signingKeys: NodeSigningKeys,
|
|
68
|
+
newName: string,
|
|
69
|
+
): Promise<{
|
|
70
|
+
signatureEmail: string;
|
|
71
|
+
armoredNodeName: string;
|
|
72
|
+
hash: string;
|
|
73
|
+
}> {
|
|
74
|
+
if (!parentKeys.hashKey) {
|
|
75
|
+
throw new Error('Cannot rename album: parent folder hash key not available');
|
|
76
|
+
}
|
|
77
|
+
if (signingKeys.type !== 'userAddress') {
|
|
78
|
+
throw new Error('Renaming album by anonymous user is not supported');
|
|
79
|
+
}
|
|
80
|
+
const email = signingKeys.email;
|
|
81
|
+
const nameSigningKey = signingKeys.key;
|
|
82
|
+
|
|
83
|
+
const nodeNameSessionKey = await this.driveCrypto.decryptSessionKey(encryptedName, parentKeys.key);
|
|
84
|
+
|
|
85
|
+
const { armoredNodeName } = await this.driveCrypto.encryptNodeName(
|
|
86
|
+
newName,
|
|
87
|
+
nodeNameSessionKey,
|
|
88
|
+
parentKeys.key,
|
|
89
|
+
nameSigningKey,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const hash = await this.driveCrypto.generateLookupHash(newName, parentKeys.hashKey);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
signatureEmail: email,
|
|
96
|
+
armoredNodeName,
|
|
97
|
+
hash,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { c } from 'ttag';
|
|
2
|
+
|
|
3
|
+
import { ValidationError } from '../../errors';
|
|
4
|
+
import { APICodeError, DriveAPIService, drivePaths } from '../apiService';
|
|
2
5
|
import { EncryptedRootShare, EncryptedShareCrypto, ShareType } from '../shares/interface';
|
|
3
|
-
import { makeNodeUid } from '../uids';
|
|
6
|
+
import { makeNodeUid, splitNodeUid } from '../uids';
|
|
4
7
|
|
|
5
8
|
type GetPhotoShareResponse =
|
|
6
9
|
drivePaths['/drive/v2/shares/photos']['get']['responses']['200']['content']['application/json'];
|
|
@@ -18,6 +21,18 @@ type GetTimelineResponse =
|
|
|
18
21
|
type GetAlbumsResponse =
|
|
19
22
|
drivePaths['/drive/photos/volumes/{volumeID}/albums']['get']['responses']['200']['content']['application/json'];
|
|
20
23
|
|
|
24
|
+
type PostCreateAlbumRequest = Extract<
|
|
25
|
+
drivePaths['/drive/photos/volumes/{volumeID}/albums']['post']['requestBody'],
|
|
26
|
+
{ content: object }
|
|
27
|
+
>['content']['application/json'];
|
|
28
|
+
type PostCreateAlbumResponse =
|
|
29
|
+
drivePaths['/drive/photos/volumes/{volumeID}/albums']['post']['responses']['200']['content']['application/json'];
|
|
30
|
+
|
|
31
|
+
type PutUpdateAlbumRequest = Extract<
|
|
32
|
+
drivePaths['/drive/photos/volumes/{volumeID}/albums/{linkID}']['put']['requestBody'],
|
|
33
|
+
{ content: object }
|
|
34
|
+
>['content']['application/json'];
|
|
35
|
+
|
|
21
36
|
type PostPhotoDuplicateRequest = Extract<
|
|
22
37
|
drivePaths['/drive/volumes/{volumeID}/photos/duplicates']['post']['requestBody'],
|
|
23
38
|
{ content: object }
|
|
@@ -25,6 +40,8 @@ type PostPhotoDuplicateRequest = Extract<
|
|
|
25
40
|
type PostPhotoDuplicateResponse =
|
|
26
41
|
drivePaths['/drive/volumes/{volumeID}/photos/duplicates']['post']['responses']['200']['content']['application/json'];
|
|
27
42
|
|
|
43
|
+
const ALBUM_CONTAINS_PHOTOS_NOT_IN_TIMELINE_ERROR_CODE = 200302;
|
|
44
|
+
|
|
28
45
|
/**
|
|
29
46
|
* Provides API communication for fetching and manipulating photos and albums
|
|
30
47
|
* metadata.
|
|
@@ -188,4 +205,79 @@ export class PhotosAPIService {
|
|
|
188
205
|
};
|
|
189
206
|
}).filter((duplicate) => duplicate !== undefined);
|
|
190
207
|
}
|
|
208
|
+
|
|
209
|
+
async createAlbum(
|
|
210
|
+
parentNodeUid: string,
|
|
211
|
+
album: {
|
|
212
|
+
encryptedName: string;
|
|
213
|
+
hash: string;
|
|
214
|
+
armoredKey: string;
|
|
215
|
+
armoredNodePassphrase: string;
|
|
216
|
+
armoredNodePassphraseSignature: string;
|
|
217
|
+
signatureEmail: string;
|
|
218
|
+
armoredHashKey: string;
|
|
219
|
+
},
|
|
220
|
+
): Promise<string> {
|
|
221
|
+
const { volumeId } = splitNodeUid(parentNodeUid);
|
|
222
|
+
const response = await this.apiService.post<PostCreateAlbumRequest, PostCreateAlbumResponse>(
|
|
223
|
+
`drive/photos/volumes/${volumeId}/albums`,
|
|
224
|
+
{
|
|
225
|
+
Locked: false,
|
|
226
|
+
Link: {
|
|
227
|
+
Name: album.encryptedName,
|
|
228
|
+
Hash: album.hash,
|
|
229
|
+
NodeKey: album.armoredKey,
|
|
230
|
+
NodePassphrase: album.armoredNodePassphrase,
|
|
231
|
+
NodePassphraseSignature: album.armoredNodePassphraseSignature,
|
|
232
|
+
SignatureEmail: album.signatureEmail,
|
|
233
|
+
NodeHashKey: album.armoredHashKey,
|
|
234
|
+
XAttr: null,
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
return makeNodeUid(volumeId, response.Album.Link.LinkID);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async updateAlbum(
|
|
243
|
+
albumNodeUid: string,
|
|
244
|
+
coverPhotoNodeUid?: string,
|
|
245
|
+
updatedName?: {
|
|
246
|
+
encryptedName: string;
|
|
247
|
+
hash: string;
|
|
248
|
+
originalHash: string;
|
|
249
|
+
nameSignatureEmail: string;
|
|
250
|
+
},
|
|
251
|
+
): Promise<void> {
|
|
252
|
+
const { volumeId, nodeId: linkId } = splitNodeUid(albumNodeUid);
|
|
253
|
+
const coverLinkId = coverPhotoNodeUid ? splitNodeUid(coverPhotoNodeUid).nodeId : undefined;
|
|
254
|
+
await this.apiService.put<PutUpdateAlbumRequest, void>(
|
|
255
|
+
`drive/photos/volumes/${volumeId}/albums/${linkId}`,
|
|
256
|
+
{
|
|
257
|
+
CoverLinkID: coverLinkId,
|
|
258
|
+
Link: updatedName
|
|
259
|
+
? {
|
|
260
|
+
Name: updatedName.encryptedName,
|
|
261
|
+
Hash: updatedName.hash,
|
|
262
|
+
OriginalHash: updatedName.originalHash,
|
|
263
|
+
NameSignatureEmail: updatedName.nameSignatureEmail,
|
|
264
|
+
}
|
|
265
|
+
: null,
|
|
266
|
+
},
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async deleteAlbum(albumNodeUid: string, options: { force?: boolean } = {}): Promise<void> {
|
|
271
|
+
const { volumeId, nodeId: linkId } = splitNodeUid(albumNodeUid);
|
|
272
|
+
try {
|
|
273
|
+
await this.apiService.delete(
|
|
274
|
+
`drive/photos/volumes/${volumeId}/albums/${linkId}?DeleteAlbumPhotos=${options.force ? 1 : 0}`,
|
|
275
|
+
);
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (error instanceof APICodeError && error.code === ALBUM_CONTAINS_PHOTOS_NOT_IN_TIMELINE_ERROR_CODE) {
|
|
278
|
+
throw new ValidationError(c('Error').t`Album contains photos not in timeline`);
|
|
279
|
+
}
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
191
283
|
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { DriveAPIService } from '../apiService';
|
|
2
1
|
import { DriveCrypto } from '../../crypto';
|
|
3
2
|
import {
|
|
4
3
|
ProtonDriveAccount,
|
|
@@ -6,9 +5,12 @@ import {
|
|
|
6
5
|
ProtonDriveEntitiesCache,
|
|
7
6
|
ProtonDriveTelemetry,
|
|
8
7
|
} from '../../interface';
|
|
8
|
+
import { DriveAPIService } from '../apiService';
|
|
9
9
|
import { NodesCryptoService } from '../nodes/cryptoService';
|
|
10
10
|
import { NodesCryptoReporter } from '../nodes/cryptoReporter';
|
|
11
11
|
import { NodesCryptoCache } from '../nodes/cryptoCache';
|
|
12
|
+
import { NodesEventsHandler } from '../nodes/events';
|
|
13
|
+
import { NodesRevisons } from '../nodes/nodesRevisions';
|
|
12
14
|
import { ShareTargetType } from '../shares';
|
|
13
15
|
import { SharesCache } from '../shares/cache';
|
|
14
16
|
import { SharesCryptoCache } from '../shares/cryptoCache';
|
|
@@ -17,6 +19,7 @@ import { NodesService as UploadNodesService } from '../upload/interface';
|
|
|
17
19
|
import { UploadTelemetry } from '../upload/telemetry';
|
|
18
20
|
import { UploadQueue } from '../upload/queue';
|
|
19
21
|
import { Albums } from './albums';
|
|
22
|
+
import { AlbumsCryptoService } from './albumsCrypto';
|
|
20
23
|
import { PhotosAPIService } from './apiService';
|
|
21
24
|
import { SharesService } from './interface';
|
|
22
25
|
import { PhotosNodesAPIService, PhotosNodesAccess, PhotosNodesCache, PhotosNodesManagement } from './nodes';
|
|
@@ -29,8 +32,6 @@ import {
|
|
|
29
32
|
PhotoUploadManager,
|
|
30
33
|
PhotoUploadMetadata,
|
|
31
34
|
} from './upload';
|
|
32
|
-
import { NodesRevisons } from '../nodes/nodesRevisions';
|
|
33
|
-
import { NodesEventsHandler } from '../nodes/events';
|
|
34
35
|
|
|
35
36
|
export type { DecryptedPhotoNode } from './interface';
|
|
36
37
|
|
|
@@ -51,6 +52,7 @@ export function initPhotosModule(
|
|
|
51
52
|
nodesService: PhotosNodesAccess,
|
|
52
53
|
) {
|
|
53
54
|
const api = new PhotosAPIService(apiService);
|
|
55
|
+
const albumsCryptoService = new AlbumsCryptoService(driveCrypto);
|
|
54
56
|
const timeline = new PhotosTimeline(
|
|
55
57
|
telemetry.getLogger('photos-timeline'),
|
|
56
58
|
api,
|
|
@@ -58,7 +60,7 @@ export function initPhotosModule(
|
|
|
58
60
|
photoShares,
|
|
59
61
|
nodesService,
|
|
60
62
|
);
|
|
61
|
-
const albums = new Albums(api, photoShares, nodesService);
|
|
63
|
+
const albums = new Albums(api, albumsCryptoService, photoShares, nodesService);
|
|
62
64
|
|
|
63
65
|
return {
|
|
64
66
|
timeline,
|
|
@@ -109,13 +109,14 @@ export class PhotoStreamUploader extends StreamUploader {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
async commitFile(thumbnails: Thumbnail[]) {
|
|
112
|
-
this.
|
|
112
|
+
const digests = this.digests.digests();
|
|
113
|
+
this.verifyIntegrity(thumbnails, digests);
|
|
113
114
|
|
|
114
115
|
const extendedAttributes = {
|
|
115
116
|
modificationTime: this.metadata.modificationTime,
|
|
116
117
|
size: this.metadata.expectedSize,
|
|
117
118
|
blockSizes: this.uploadedBlockSizes,
|
|
118
|
-
digests
|
|
119
|
+
digests,
|
|
119
120
|
};
|
|
120
121
|
|
|
121
122
|
await this.photoUploadManager.commitDraftPhoto(this.revisionDraft, this.manifest, extendedAttributes, this.photoMetadata);
|
|
@@ -564,6 +564,44 @@ describe('StreamUploader', () => {
|
|
|
564
564
|
|
|
565
565
|
await verifyFailure('Some file bytes failed to upload', 10 * 1024 * 1024 + 1024);
|
|
566
566
|
});
|
|
567
|
+
|
|
568
|
+
it('should succeed with matching expectedSha1', async () => {
|
|
569
|
+
metadata.expectedSha1 = '8c206a1a87599f532ce68675536f0b1546900d7a';
|
|
570
|
+
|
|
571
|
+
uploader = new StreamUploader(
|
|
572
|
+
telemetry,
|
|
573
|
+
apiService,
|
|
574
|
+
cryptoService,
|
|
575
|
+
uploadManager,
|
|
576
|
+
blockVerifier,
|
|
577
|
+
revisionDraft,
|
|
578
|
+
metadata,
|
|
579
|
+
onFinish,
|
|
580
|
+
controller,
|
|
581
|
+
abortController,
|
|
582
|
+
);
|
|
583
|
+
|
|
584
|
+
await verifySuccess();
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it('should throw an error if SHA1 does not match', async () => {
|
|
588
|
+
metadata.expectedSha1 = 'wrong_sha1_hash_that_will_not_match';
|
|
589
|
+
|
|
590
|
+
uploader = new StreamUploader(
|
|
591
|
+
telemetry,
|
|
592
|
+
apiService,
|
|
593
|
+
cryptoService,
|
|
594
|
+
uploadManager,
|
|
595
|
+
blockVerifier,
|
|
596
|
+
revisionDraft,
|
|
597
|
+
metadata,
|
|
598
|
+
onFinish,
|
|
599
|
+
controller,
|
|
600
|
+
abortController,
|
|
601
|
+
);
|
|
602
|
+
|
|
603
|
+
await verifyFailure('File hash does not match expected hash', 10 * 1024 * 1024 + 1024);
|
|
604
|
+
});
|
|
567
605
|
});
|
|
568
606
|
});
|
|
569
607
|
|