@protontech/drive-sdk 0.12.1 → 0.13.1

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.
Files changed (154) hide show
  1. package/dist/crypto/driveCrypto.d.ts +1 -0
  2. package/dist/crypto/driveCrypto.js +1 -0
  3. package/dist/crypto/driveCrypto.js.map +1 -1
  4. package/dist/interface/events.d.ts +1 -1
  5. package/dist/interface/featureFlags.d.ts +2 -1
  6. package/dist/interface/featureFlags.js +1 -0
  7. package/dist/interface/featureFlags.js.map +1 -1
  8. package/dist/interface/httpClient.d.ts +1 -0
  9. package/dist/interface/nodes.d.ts +7 -0
  10. package/dist/interface/nodes.js.map +1 -1
  11. package/dist/interface/telemetry.d.ts +4 -0
  12. package/dist/interface/telemetry.js.map +1 -1
  13. package/dist/internal/apiService/apiService.d.ts +1 -0
  14. package/dist/internal/apiService/apiService.js +16 -7
  15. package/dist/internal/apiService/apiService.js.map +1 -1
  16. package/dist/internal/apiService/apiService.test.js +24 -0
  17. package/dist/internal/apiService/apiService.test.js.map +1 -1
  18. package/dist/internal/apiService/driveTypes.d.ts +162 -101
  19. package/dist/internal/download/telemetry.js +4 -0
  20. package/dist/internal/download/telemetry.js.map +1 -1
  21. package/dist/internal/download/telemetry.test.js +5 -0
  22. package/dist/internal/download/telemetry.test.js.map +1 -1
  23. package/dist/internal/events/index.js +2 -2
  24. package/dist/internal/events/index.js.map +1 -1
  25. package/dist/internal/events/interface.d.ts +1 -1
  26. package/dist/internal/nodes/apiService.d.ts +4 -0
  27. package/dist/internal/nodes/apiService.js +4 -0
  28. package/dist/internal/nodes/apiService.js.map +1 -1
  29. package/dist/internal/nodes/apiService.test.js +8 -0
  30. package/dist/internal/nodes/apiService.test.js.map +1 -1
  31. package/dist/internal/nodes/cryptoService.js +3 -0
  32. package/dist/internal/nodes/cryptoService.js.map +1 -1
  33. package/dist/internal/nodes/extendedAttributes.d.ts +5 -5
  34. package/dist/internal/nodes/extendedAttributes.js +5 -14
  35. package/dist/internal/nodes/extendedAttributes.js.map +1 -1
  36. package/dist/internal/nodes/extendedAttributes.test.js +16 -22
  37. package/dist/internal/nodes/extendedAttributes.test.js.map +1 -1
  38. package/dist/internal/nodes/interface.d.ts +5 -0
  39. package/dist/internal/nodes/nodesManagement.d.ts +3 -3
  40. package/dist/internal/nodes/nodesManagement.js +7 -5
  41. package/dist/internal/nodes/nodesManagement.js.map +1 -1
  42. package/dist/internal/photos/albumsManager.js +1 -0
  43. package/dist/internal/photos/albumsManager.js.map +1 -1
  44. package/dist/internal/photos/nodes.d.ts +1 -1
  45. package/dist/internal/photos/nodes.js +2 -2
  46. package/dist/internal/photos/nodes.js.map +1 -1
  47. package/dist/internal/photos/upload.d.ts +5 -5
  48. package/dist/internal/photos/upload.js +8 -2
  49. package/dist/internal/photos/upload.js.map +1 -1
  50. package/dist/internal/sharing/apiService.js +1 -1
  51. package/dist/internal/sharing/apiService.js.map +1 -1
  52. package/dist/internal/sharingPublic/nodes.d.ts +1 -0
  53. package/dist/internal/upload/apiService.d.ts +45 -1
  54. package/dist/internal/upload/apiService.js +69 -1
  55. package/dist/internal/upload/apiService.js.map +1 -1
  56. package/dist/internal/upload/blockVerifier.d.ts +4 -1
  57. package/dist/internal/upload/blockVerifier.js +5 -0
  58. package/dist/internal/upload/blockVerifier.js.map +1 -1
  59. package/dist/internal/upload/cryptoService.d.ts +2 -2
  60. package/dist/internal/upload/cryptoService.js +1 -3
  61. package/dist/internal/upload/cryptoService.js.map +1 -1
  62. package/dist/internal/upload/fileUploader.d.ts +4 -3
  63. package/dist/internal/upload/fileUploader.js +17 -7
  64. package/dist/internal/upload/fileUploader.js.map +1 -1
  65. package/dist/internal/upload/index.d.ts +3 -3
  66. package/dist/internal/upload/index.js +17 -1
  67. package/dist/internal/upload/index.js.map +1 -1
  68. package/dist/internal/upload/index.test.d.ts +1 -0
  69. package/dist/internal/upload/index.test.js +71 -0
  70. package/dist/internal/upload/index.test.js.map +1 -0
  71. package/dist/internal/upload/interface.d.ts +2 -0
  72. package/dist/internal/upload/manager.d.ts +41 -2
  73. package/dist/internal/upload/manager.js +126 -44
  74. package/dist/internal/upload/manager.js.map +1 -1
  75. package/dist/internal/upload/manager.test.js +268 -1
  76. package/dist/internal/upload/manager.test.js.map +1 -1
  77. package/dist/internal/upload/smallFileUploader.d.ts +83 -0
  78. package/dist/internal/upload/smallFileUploader.js +197 -0
  79. package/dist/internal/upload/smallFileUploader.js.map +1 -0
  80. package/dist/internal/upload/smallFileUploader.test.d.ts +1 -0
  81. package/dist/internal/upload/smallFileUploader.test.js +358 -0
  82. package/dist/internal/upload/smallFileUploader.test.js.map +1 -0
  83. package/dist/internal/upload/streamReader.d.ts +4 -0
  84. package/dist/internal/upload/streamReader.js +37 -0
  85. package/dist/internal/upload/streamReader.js.map +1 -0
  86. package/dist/internal/upload/streamReader.test.d.ts +1 -0
  87. package/dist/internal/upload/streamReader.test.js +90 -0
  88. package/dist/internal/upload/streamReader.test.js.map +1 -0
  89. package/dist/internal/upload/streamUploader.d.ts +6 -0
  90. package/dist/internal/upload/streamUploader.js +3 -3
  91. package/dist/internal/upload/streamUploader.js.map +1 -1
  92. package/dist/internal/upload/telemetry.d.ts +3 -2
  93. package/dist/internal/upload/telemetry.js +5 -0
  94. package/dist/internal/upload/telemetry.js.map +1 -1
  95. package/dist/internal/upload/telemetry.test.js +6 -0
  96. package/dist/internal/upload/telemetry.test.js.map +1 -1
  97. package/dist/protonDrivePhotosClient.d.ts +1 -1
  98. package/dist/protonDrivePublicLinkClient.js +3 -1
  99. package/dist/protonDrivePublicLinkClient.js.map +1 -1
  100. package/dist/telemetry.d.ts +1 -0
  101. package/dist/telemetry.js +21 -0
  102. package/dist/telemetry.js.map +1 -1
  103. package/dist/telemetry.test.d.ts +1 -0
  104. package/dist/telemetry.test.js +37 -0
  105. package/dist/telemetry.test.js.map +1 -0
  106. package/dist/transformers.d.ts +1 -1
  107. package/dist/transformers.js +1 -0
  108. package/dist/transformers.js.map +1 -1
  109. package/package.json +1 -1
  110. package/src/crypto/driveCrypto.ts +2 -0
  111. package/src/interface/events.ts +1 -1
  112. package/src/interface/featureFlags.ts +1 -0
  113. package/src/interface/httpClient.ts +1 -0
  114. package/src/interface/nodes.ts +7 -0
  115. package/src/interface/telemetry.ts +4 -0
  116. package/src/internal/apiService/apiService.test.ts +30 -0
  117. package/src/internal/apiService/apiService.ts +23 -7
  118. package/src/internal/apiService/driveTypes.ts +162 -101
  119. package/src/internal/download/telemetry.test.ts +5 -0
  120. package/src/internal/download/telemetry.ts +5 -1
  121. package/src/internal/events/index.ts +2 -2
  122. package/src/internal/events/interface.ts +1 -1
  123. package/src/internal/nodes/apiService.test.ts +9 -0
  124. package/src/internal/nodes/apiService.ts +4 -0
  125. package/src/internal/nodes/cryptoService.ts +11 -1
  126. package/src/internal/nodes/extendedAttributes.test.ts +25 -25
  127. package/src/internal/nodes/extendedAttributes.ts +10 -19
  128. package/src/internal/nodes/interface.ts +5 -0
  129. package/src/internal/nodes/nodesManagement.ts +8 -6
  130. package/src/internal/photos/albumsManager.ts +1 -0
  131. package/src/internal/photos/nodes.ts +2 -2
  132. package/src/internal/photos/upload.ts +23 -10
  133. package/src/internal/sharing/apiService.ts +5 -5
  134. package/src/internal/upload/apiService.ts +167 -2
  135. package/src/internal/upload/blockVerifier.ts +12 -0
  136. package/src/internal/upload/cryptoService.ts +10 -10
  137. package/src/internal/upload/fileUploader.ts +20 -7
  138. package/src/internal/upload/index.test.ts +99 -0
  139. package/src/internal/upload/index.ts +45 -4
  140. package/src/internal/upload/interface.ts +2 -0
  141. package/src/internal/upload/manager.test.ts +368 -2
  142. package/src/internal/upload/manager.ts +229 -78
  143. package/src/internal/upload/smallFileUploader.test.ts +491 -0
  144. package/src/internal/upload/smallFileUploader.ts +353 -0
  145. package/src/internal/upload/streamReader.test.ts +109 -0
  146. package/src/internal/upload/streamReader.ts +38 -0
  147. package/src/internal/upload/streamUploader.ts +1 -1
  148. package/src/internal/upload/telemetry.test.ts +6 -0
  149. package/src/internal/upload/telemetry.ts +8 -2
  150. package/src/protonDrivePhotosClient.ts +1 -1
  151. package/src/protonDrivePublicLinkClient.ts +2 -0
  152. package/src/telemetry.test.ts +40 -0
  153. package/src/telemetry.ts +22 -0
  154. package/src/transformers.ts +2 -0
@@ -0,0 +1,353 @@
1
+ import { PrivateKey, SessionKey } from '../../crypto';
2
+ import { AbortError, IntegrityError } from '../../errors';
3
+ import { Logger, Thumbnail, ThumbnailType, UploadMetadata } from '../../interface';
4
+ import { getErrorMessage } from '../errors';
5
+ import { generateFileExtendedAttributes } from '../nodes';
6
+ import { UploadAPIService } from './apiService';
7
+ import { BlockVerifier, verifyBlockWithContentKey } from './blockVerifier';
8
+ import { UploadCryptoService } from './cryptoService';
9
+ import { UploadDigests } from './digests';
10
+ import { Uploader } from './fileUploader';
11
+ import { NodeRevisionDraft, NodeCrypto } from './interface';
12
+ import { UploadManager } from './manager';
13
+ import { readStreamToUint8Array } from './streamReader';
14
+ import { MAX_BLOCK_ENCRYPTION_RETRIES } from './streamUploader';
15
+ import { UploadTelemetry } from './telemetry';
16
+
17
+ export type NodeKeys = {
18
+ key: PrivateKey;
19
+ contentKeyPacket: Uint8Array<ArrayBuffer>;
20
+ contentKeyPacketSessionKey: SessionKey;
21
+ signingKeys: NodeCrypto['signingKeys'];
22
+ };
23
+
24
+ /**
25
+ * Base uploader for small file and small revision uploads.
26
+ * Shares the single-request flow: read content, get node crypto, encrypt, then call API.
27
+ */
28
+ abstract class SmallUploader extends Uploader {
29
+ protected logger: Logger;
30
+
31
+ constructor(
32
+ telemetry: UploadTelemetry,
33
+ apiService: UploadAPIService,
34
+ cryptoService: UploadCryptoService,
35
+ manager: UploadManager,
36
+ metadata: UploadMetadata,
37
+ onFinish: () => void,
38
+ signal: AbortSignal | undefined,
39
+ ) {
40
+ super(telemetry, apiService, cryptoService, manager, metadata, onFinish, signal);
41
+ this.logger = telemetry.getLoggerForSmallUpload();
42
+ }
43
+ protected async createRevisionDraft(): Promise<{
44
+ revisionDraft: NodeRevisionDraft;
45
+ blockVerifier: BlockVerifier;
46
+ }> {
47
+ throw new Error('Small upload does not use revision draft');
48
+ }
49
+
50
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
51
+ protected async deleteRevisionDraft(revisionDraft: NodeRevisionDraft): Promise<void> {
52
+ throw new Error('Small upload does not use revision draft');
53
+ }
54
+
55
+ protected async startUpload(
56
+ stream: ReadableStream,
57
+ thumbnails: Thumbnail[],
58
+ onProgress?: (uploadedBytes: number) => void,
59
+ ): Promise<{ nodeRevisionUid: string; nodeUid: string }> {
60
+ try {
61
+ const result = await this.handleUpload(stream, thumbnails);
62
+
63
+ onProgress?.(this.metadata.expectedSize);
64
+ void this.telemetry.uploadFinished(result.nodeRevisionUid, this.metadata.expectedSize);
65
+ return result;
66
+ } catch (error) {
67
+ void this.telemetry.uploadInitFailed(this.getTelemetryContextUid(), error, this.metadata.expectedSize);
68
+ throw error;
69
+ } finally {
70
+ this.onFinish();
71
+ }
72
+ }
73
+
74
+ protected abstract getTelemetryContextUid(): string;
75
+
76
+ protected abstract handleUpload(
77
+ stream: ReadableStream,
78
+ thumbnails: Thumbnail[],
79
+ ): Promise<{
80
+ nodeUid: string;
81
+ nodeRevisionUid: string;
82
+ }>;
83
+
84
+ protected async buildPayloads(
85
+ nodeKeys: NodeKeys,
86
+ stream: ReadableStream,
87
+ thumbnails: Thumbnail[],
88
+ ): Promise<{
89
+ commitPayload: {
90
+ armoredManifestSignature: string;
91
+ armoredExtendedAttributes: string;
92
+ };
93
+ encryptedBlock:
94
+ | {
95
+ encryptedData: Uint8Array<ArrayBuffer>;
96
+ armoredSignature: string;
97
+ verificationToken: Uint8Array<ArrayBuffer>;
98
+ }
99
+ | undefined;
100
+ encryptedThumbnails: { type: ThumbnailType; encryptedData: Uint8Array<ArrayBuffer> }[];
101
+ }> {
102
+ const content = await this.readStreamContent(stream);
103
+
104
+ const [encryptedThumbnails, encryptedBlock] = await Promise.all([
105
+ this.encryptThumbnails(nodeKeys, thumbnails),
106
+ this.encryptContentBlock(nodeKeys, content.data),
107
+ ]);
108
+ const commitPayload = await this.encryptCommitPayload(nodeKeys, content.sha1, encryptedBlock);
109
+
110
+ return {
111
+ commitPayload,
112
+ encryptedBlock,
113
+ encryptedThumbnails,
114
+ };
115
+ }
116
+
117
+ private async readStreamContent(stream: ReadableStream): Promise<{
118
+ data: Uint8Array<ArrayBuffer>;
119
+ sha1: string;
120
+ }> {
121
+ const content = await readStreamToUint8Array(stream, this.abortController.signal);
122
+
123
+ if (content.length !== this.metadata.expectedSize) {
124
+ throw new IntegrityError(new Error('Stream size does not match expected size').message, {
125
+ actual: content.length,
126
+ expected: this.metadata.expectedSize,
127
+ });
128
+ }
129
+
130
+ const digests = new UploadDigests();
131
+ digests.update(content);
132
+ const contentSha1 = digests.digests().sha1;
133
+
134
+ if (this.metadata.expectedSha1 && contentSha1 !== this.metadata.expectedSha1) {
135
+ throw new IntegrityError(new Error('File hash does not match expected hash').message, {
136
+ uploadedSha1: contentSha1,
137
+ expectedSha1: this.metadata.expectedSha1,
138
+ });
139
+ }
140
+
141
+ return {
142
+ data: content,
143
+ sha1: contentSha1,
144
+ };
145
+ }
146
+
147
+ private async encryptThumbnails(
148
+ nodeKeys: NodeKeys,
149
+ thumbnails: Thumbnail[],
150
+ ): Promise<{ type: ThumbnailType; encryptedData: Uint8Array<ArrayBuffer> }[]> {
151
+ const result = [];
152
+ for (const thumbnail of thumbnails) {
153
+ this.logger.debug(`Encrypting thumbnail ${thumbnail.type}`);
154
+ const enc = await this.cryptoService.encryptThumbnail(nodeKeys, thumbnail);
155
+ result.push({ type: thumbnail.type, encryptedData: enc.encryptedData });
156
+ }
157
+ return result;
158
+ }
159
+
160
+ private async encryptContentBlock(
161
+ nodeKeys: NodeKeys,
162
+ content: Uint8Array<ArrayBuffer>,
163
+ ): Promise<
164
+ | {
165
+ encryptedData: Uint8Array<ArrayBuffer>;
166
+ armoredSignature: string;
167
+ verificationToken: Uint8Array<ArrayBuffer>;
168
+ blockHash: Uint8Array<ArrayBuffer>;
169
+ }
170
+ | undefined
171
+ > {
172
+ this.logger.debug(`Encrypting block`);
173
+
174
+ if (content.length === 0) {
175
+ return;
176
+ }
177
+
178
+ let attempt = 0;
179
+ let integrityError = false;
180
+ let encrypted;
181
+ while (!encrypted) {
182
+ attempt++;
183
+ try {
184
+ encrypted = await this.cryptoService.encryptBlock(
185
+ (encryptedBlock) =>
186
+ verifyBlockWithContentKey(
187
+ this.cryptoService,
188
+ nodeKeys.contentKeyPacket,
189
+ nodeKeys.contentKeyPacketSessionKey,
190
+ encryptedBlock,
191
+ ),
192
+ nodeKeys,
193
+ content,
194
+ 0,
195
+ );
196
+ if (integrityError) {
197
+ void this.telemetry.logBlockVerificationError(true);
198
+ }
199
+ } catch (error: unknown) {
200
+ // Do not retry or report anything if the upload was aborted.
201
+ if (error instanceof AbortError) {
202
+ throw error;
203
+ }
204
+
205
+ if (error instanceof IntegrityError) {
206
+ integrityError = true;
207
+ }
208
+
209
+ if (attempt <= MAX_BLOCK_ENCRYPTION_RETRIES) {
210
+ this.logger.warn(`Block encryption failed #${attempt}, retrying: ${getErrorMessage(error)}`);
211
+ continue;
212
+ }
213
+
214
+ this.logger.error(`Failed to encrypt block`, error);
215
+ if (integrityError) {
216
+ void this.telemetry.logBlockVerificationError(false);
217
+ }
218
+ throw error;
219
+ }
220
+ }
221
+
222
+ const blockHash = await encrypted.hashPromise;
223
+ return {
224
+ encryptedData: encrypted.encryptedData,
225
+ armoredSignature: encrypted.armoredSignature,
226
+ verificationToken: encrypted.verificationToken,
227
+ blockHash,
228
+ };
229
+ }
230
+
231
+ private async encryptCommitPayload(
232
+ nodeKeys: NodeKeys,
233
+ contentSha1: string,
234
+ encryptedBlock:
235
+ | {
236
+ blockHash: Uint8Array<ArrayBuffer>;
237
+ }
238
+ | undefined,
239
+ ): Promise<{
240
+ armoredManifestSignature: string;
241
+ armoredExtendedAttributes: string;
242
+ }> {
243
+ this.logger.debug(`Preparing commit payload`);
244
+
245
+ const manifest = encryptedBlock ? encryptedBlock.blockHash : new Uint8Array(0);
246
+ const extendedAttributes = generateFileExtendedAttributes(
247
+ {
248
+ modificationTime: this.metadata.modificationTime,
249
+ size: this.metadata.expectedSize,
250
+ blockSizes: this.metadata.expectedSize > 0 ? [this.metadata.expectedSize] : [],
251
+ digests: { sha1: contentSha1 },
252
+ },
253
+ this.metadata.additionalMetadata,
254
+ );
255
+ const commitCrypto = await this.cryptoService.commitFile(nodeKeys, manifest, extendedAttributes);
256
+ return {
257
+ armoredManifestSignature: commitCrypto.armoredManifestSignature,
258
+ armoredExtendedAttributes: commitCrypto.armoredExtendedAttributes,
259
+ };
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Uploader for small new files using the single-request small file endpoint.
265
+ */
266
+ export class SmallFileUploader extends SmallUploader {
267
+ constructor(
268
+ telemetry: UploadTelemetry,
269
+ apiService: UploadAPIService,
270
+ cryptoService: UploadCryptoService,
271
+ manager: UploadManager,
272
+ metadata: UploadMetadata,
273
+ onFinish: () => void,
274
+ signal: AbortSignal | undefined,
275
+ private parentFolderUid: string,
276
+ private name: string,
277
+ ) {
278
+ super(telemetry, apiService, cryptoService, manager, metadata, onFinish, signal);
279
+ this.parentFolderUid = parentFolderUid;
280
+ this.name = name;
281
+ }
282
+
283
+ protected getTelemetryContextUid(): string {
284
+ return this.parentFolderUid;
285
+ }
286
+
287
+ protected async handleUpload(
288
+ stream: ReadableStream,
289
+ thumbnails: Thumbnail[],
290
+ ): Promise<{
291
+ nodeUid: string;
292
+ nodeRevisionUid: string;
293
+ }> {
294
+ const nodeCrypto = await this.manager.generateNewFileCrypto(this.parentFolderUid, this.name);
295
+ const nodeKeys = {
296
+ key: nodeCrypto.nodeKeys.decrypted.key,
297
+ contentKeyPacket: nodeCrypto.contentKey.encrypted.contentKeyPacket,
298
+ contentKeyPacketSessionKey: nodeCrypto.contentKey.decrypted.contentKeyPacketSessionKey,
299
+ signingKeys: nodeCrypto.signingKeys,
300
+ };
301
+ const payloads = await this.buildPayloads(nodeKeys, stream, thumbnails);
302
+ return this.manager.uploadFile(
303
+ this.parentFolderUid,
304
+ nodeCrypto,
305
+ this.metadata,
306
+ payloads.commitPayload,
307
+ payloads.encryptedBlock,
308
+ payloads.encryptedThumbnails,
309
+ );
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Uploader for small new revisions using the single-request small revision endpoint.
315
+ * Reuses the existing file's keys.
316
+ */
317
+ export class SmallFileRevisionUploader extends SmallUploader {
318
+ constructor(
319
+ telemetry: UploadTelemetry,
320
+ apiService: UploadAPIService,
321
+ cryptoService: UploadCryptoService,
322
+ manager: UploadManager,
323
+ metadata: UploadMetadata,
324
+ onFinish: () => void,
325
+ signal: AbortSignal | undefined,
326
+ private nodeUid: string,
327
+ ) {
328
+ super(telemetry, apiService, cryptoService, manager, metadata, onFinish, signal);
329
+ this.nodeUid = nodeUid;
330
+ }
331
+
332
+ protected getTelemetryContextUid(): string {
333
+ return this.nodeUid;
334
+ }
335
+
336
+ protected async handleUpload(
337
+ stream: ReadableStream,
338
+ thumbnails: Thumbnail[],
339
+ ): Promise<{
340
+ nodeUid: string;
341
+ nodeRevisionUid: string;
342
+ }> {
343
+ const nodeKeys = await this.manager.getExistingFileNodeCrypto(this.nodeUid);
344
+ const payloads = await this.buildPayloads(nodeKeys, stream, thumbnails);
345
+ return this.manager.uploadSmallRevision(
346
+ this.nodeUid,
347
+ nodeKeys,
348
+ payloads.commitPayload,
349
+ payloads.encryptedBlock,
350
+ payloads.encryptedThumbnails,
351
+ );
352
+ }
353
+ }
@@ -0,0 +1,109 @@
1
+ import { AbortError } from '../../errors';
2
+ import { readStreamToUint8Array } from './streamReader';
3
+
4
+ describe('readStreamToUint8Array', () => {
5
+ it('should return empty Uint8Array for empty stream', async () => {
6
+ const stream = new ReadableStream<Uint8Array<ArrayBuffer>>({
7
+ start(controller) {
8
+ controller.close();
9
+ },
10
+ });
11
+
12
+ const result = await readStreamToUint8Array(stream);
13
+
14
+ expect(result).toEqual(new Uint8Array([]));
15
+ expect(result.length).toBe(0);
16
+ });
17
+
18
+ it('should read single chunk into Uint8Array', async () => {
19
+ const stream = new ReadableStream<Uint8Array<ArrayBuffer>>({
20
+ start(controller) {
21
+ controller.enqueue(new Uint8Array([1, 2, 3]));
22
+ controller.close();
23
+ },
24
+ });
25
+
26
+ const result = await readStreamToUint8Array(stream);
27
+
28
+ expect(result).toEqual(new Uint8Array([1, 2, 3]));
29
+ expect(result.length).toBe(3);
30
+ });
31
+
32
+ it('should concatenate multiple chunks into single Uint8Array', async () => {
33
+ const stream = new ReadableStream<Uint8Array<ArrayBuffer>>({
34
+ start(controller) {
35
+ controller.enqueue(new Uint8Array([1, 2, 3]));
36
+ controller.enqueue(new Uint8Array([4, 5, 6]));
37
+ controller.enqueue(new Uint8Array([7, 8, 9]));
38
+ controller.close();
39
+ },
40
+ });
41
+
42
+ const result = await readStreamToUint8Array(stream);
43
+
44
+ expect(result).toEqual(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9]));
45
+ expect(result.length).toBe(9);
46
+ });
47
+
48
+ it('should work without abort signal', async () => {
49
+ const stream = new ReadableStream<Uint8Array<ArrayBuffer>>({
50
+ start(controller) {
51
+ controller.enqueue(new Uint8Array([42]));
52
+ controller.close();
53
+ },
54
+ });
55
+
56
+ const result = await readStreamToUint8Array(stream);
57
+
58
+ expect(result).toEqual(new Uint8Array([42]));
59
+ });
60
+
61
+ it('should throw AbortError when signal is aborted during read', async () => {
62
+ const controller = new AbortController();
63
+ const stream = new ReadableStream<Uint8Array<ArrayBuffer>>({
64
+ start(streamController) {
65
+ streamController.enqueue(new Uint8Array([1, 2, 3]));
66
+ setTimeout(() => {
67
+ streamController.enqueue(new Uint8Array([4, 5, 6]));
68
+ streamController.close();
69
+ }, 50);
70
+ },
71
+ });
72
+
73
+ setTimeout(() => controller.abort(), 10);
74
+
75
+ await expect(readStreamToUint8Array(stream, controller.signal)).rejects.toThrow(AbortError);
76
+ });
77
+
78
+ it('should throw AbortError when signal is already aborted before read', async () => {
79
+ const controller = new AbortController();
80
+ controller.abort();
81
+
82
+ const stream = new ReadableStream<Uint8Array<ArrayBuffer>>({
83
+ start(streamController) {
84
+ streamController.enqueue(new Uint8Array([1, 2, 3]));
85
+ streamController.close();
86
+ },
87
+ });
88
+
89
+ await expect(readStreamToUint8Array(stream, controller.signal)).rejects.toThrow(AbortError);
90
+ });
91
+
92
+ it('should release reader lock so stream can be consumed once', async () => {
93
+ const stream = new ReadableStream<Uint8Array<ArrayBuffer>>({
94
+ start(controller) {
95
+ controller.enqueue(new Uint8Array([1]));
96
+ controller.close();
97
+ },
98
+ });
99
+
100
+ const result = await readStreamToUint8Array(stream);
101
+
102
+ expect(result).toEqual(new Uint8Array([1]));
103
+
104
+ const reader = stream.getReader();
105
+ const { done } = await reader.read();
106
+ expect(done).toBe(true);
107
+ reader.releaseLock();
108
+ });
109
+ });
@@ -0,0 +1,38 @@
1
+ import { AbortError } from "../../errors";
2
+
3
+ /**
4
+ * Reads a ReadableStream into a Uint8Array.
5
+ */
6
+ export async function readStreamToUint8Array(
7
+ stream: ReadableStream<Uint8Array<ArrayBuffer>>,
8
+ signal?: AbortSignal,
9
+ ): Promise<Uint8Array<ArrayBuffer>> {
10
+ const reader = stream.getReader();
11
+ const chunks: Uint8Array[] = [];
12
+ let totalLength = 0;
13
+
14
+ try {
15
+ while (true) {
16
+ const { done, value } = await reader.read();
17
+ if (done) {
18
+ break;
19
+ }
20
+ if (signal?.aborted) {
21
+ throw new AbortError();
22
+ }
23
+ const chunk = value;
24
+ totalLength += chunk.length;
25
+ chunks.push(chunk);
26
+ }
27
+
28
+ const result = new Uint8Array(totalLength);
29
+ let offset = 0;
30
+ for (const chunk of chunks) {
31
+ result.set(chunk, offset);
32
+ offset += chunk.length;
33
+ }
34
+ return result;
35
+ } finally {
36
+ reader.releaseLock();
37
+ }
38
+ }
@@ -39,7 +39,7 @@ const MAX_UPLOADING_BLOCKS = 5;
39
39
  * This is to automatically retry random errors that can happen
40
40
  * during encryption, for example bitflips.
41
41
  */
42
- const MAX_BLOCK_ENCRYPTION_RETRIES = 1;
42
+ export const MAX_BLOCK_ENCRYPTION_RETRIES = 1;
43
43
 
44
44
  /**
45
45
  * Maximum number of retries for block upload.
@@ -38,7 +38,9 @@ describe('UploadTelemetry', () => {
38
38
  eventName: 'upload',
39
39
  volumeType: 'own_volume',
40
40
  uploadedSize: 0,
41
+ approximateUploadedSize: 0,
41
42
  expectedSize: 1000,
43
+ approximateExpectedSize: 4095,
42
44
  error: 'unknown',
43
45
  originalError: error,
44
46
  });
@@ -52,7 +54,9 @@ describe('UploadTelemetry', () => {
52
54
  eventName: 'upload',
53
55
  volumeType: 'own_volume',
54
56
  uploadedSize: 500,
57
+ approximateUploadedSize: 4095,
55
58
  expectedSize: 1000,
59
+ approximateExpectedSize: 4095,
56
60
  error: 'unknown',
57
61
  originalError: error,
58
62
  });
@@ -65,7 +69,9 @@ describe('UploadTelemetry', () => {
65
69
  eventName: 'upload',
66
70
  volumeType: 'own_volume',
67
71
  uploadedSize: 1000,
72
+ approximateUploadedSize: 4095,
68
73
  expectedSize: 1000,
74
+ approximateExpectedSize: 4095,
69
75
  });
70
76
  });
71
77
 
@@ -1,12 +1,12 @@
1
1
  import { RateLimitedError, ValidationError, IntegrityError } from '../../errors';
2
2
  import { ProtonDriveTelemetry, MetricsUploadErrorType, Logger } from '../../interface';
3
- import { LoggerWithPrefix } from '../../telemetry';
3
+ import { LoggerWithPrefix, reduceSizePrecision } from '../../telemetry';
4
4
  import { APIHTTPError } from '../apiService';
5
5
  import { splitNodeUid, splitNodeRevisionUid } from '../uids';
6
6
  import { SharesService } from './interface';
7
7
 
8
8
  export class UploadTelemetry {
9
- private logger: Logger;
9
+ readonly logger: Logger;
10
10
 
11
11
  constructor(
12
12
  private telemetry: ProtonDriveTelemetry,
@@ -17,6 +17,10 @@ export class UploadTelemetry {
17
17
  this.sharesService = sharesService;
18
18
  }
19
19
 
20
+ getLoggerForSmallUpload() {
21
+ return new LoggerWithPrefix(this.logger, `small upload`);
22
+ }
23
+
20
24
  getLoggerForRevision(revisionUid: string) {
21
25
  return new LoggerWithPrefix(this.logger, `revision ${revisionUid}`);
22
26
  }
@@ -91,6 +95,8 @@ export class UploadTelemetry {
91
95
  this.telemetry.recordMetric({
92
96
  eventName: 'upload',
93
97
  volumeType,
98
+ approximateUploadedSize: reduceSizePrecision(options.uploadedSize),
99
+ approximateExpectedSize: reduceSizePrecision(options.expectedSize),
94
100
  ...options,
95
101
  });
96
102
  }
@@ -454,7 +454,7 @@ export class ProtonDrivePhotosClient {
454
454
  name: string,
455
455
  metadata: UploadMetadata & {
456
456
  captureTime?: Date;
457
- mainPhotoLinkID?: string;
457
+ mainPhotoNodeUid?: string;
458
458
  tags?: PhotoTag[];
459
459
  },
460
460
  signal?: AbortSignal,
@@ -174,6 +174,8 @@ export class ProtonDrivePublicLinkClient {
174
174
  this.sharingPublic.nodes.access,
175
175
  featureFlagProvider,
176
176
  fullConfig.clientUid,
177
+ // Public links do not support small file upload.
178
+ false,
177
179
  );
178
180
 
179
181
  this.experimental = {
@@ -0,0 +1,40 @@
1
+ import { reduceSizePrecision } from './telemetry';
2
+
3
+ describe('reduceSizePrecision', () => {
4
+ it('returns 0 for size 0', () => {
5
+ expect(reduceSizePrecision(0)).toBe(0);
6
+ });
7
+
8
+ it('returns 4095 for very small files (size < 4096)', () => {
9
+ expect(reduceSizePrecision(1)).toBe(4095);
10
+ expect(reduceSizePrecision(100)).toBe(4095);
11
+ expect(reduceSizePrecision(4095)).toBe(4095);
12
+ });
13
+
14
+ it('returns precision (100_000) for sizes from 4096 to below precision', () => {
15
+ expect(reduceSizePrecision(4096)).toBe(100_000);
16
+ expect(reduceSizePrecision(50_000)).toBe(100_000);
17
+ expect(reduceSizePrecision(99_999)).toBe(100_000);
18
+ });
19
+
20
+ it('returns size unchanged when size equals precision', () => {
21
+ expect(reduceSizePrecision(100_000)).toBe(100_000);
22
+ });
23
+
24
+ it('rounds down to nearest 100_000 for sizes above precision', () => {
25
+ expect(reduceSizePrecision(100_001)).toBe(100_000);
26
+ expect(reduceSizePrecision(150_000)).toBe(100_000);
27
+ expect(reduceSizePrecision(199_999)).toBe(100_000);
28
+ expect(reduceSizePrecision(200_000)).toBe(200_000);
29
+ expect(reduceSizePrecision(250_000)).toBe(200_000);
30
+ expect(reduceSizePrecision(299_999)).toBe(200_000);
31
+ expect(reduceSizePrecision(300_000)).toBe(300_000);
32
+ });
33
+
34
+ it('handles large sizes', () => {
35
+ expect(reduceSizePrecision(1_000_000)).toBe(1_000_000);
36
+ expect(reduceSizePrecision(1_500_000)).toBe(1_500_000);
37
+ expect(reduceSizePrecision(1_999_999)).toBe(1_900_000);
38
+ expect(reduceSizePrecision(10_000_000)).toBe(10_000_000);
39
+ });
40
+ });
package/src/telemetry.ts CHANGED
@@ -352,3 +352,25 @@ class ConsoleMetricHandler<T extends MetricEvent> implements MetricHandler<T> {
352
352
  );
353
353
  }
354
354
  }
355
+
356
+ export function reduceSizePrecision(size: number): number {
357
+ // The client shouldn't send the clear text size of the file.
358
+ // The intented upload size is needed only for early validation that
359
+ // the file can fit in the remaining quota to avoid data transfer when
360
+ // the upload would be rejected. The backend will still validate
361
+ // the quota during block upload and revision commit.
362
+ const precision = 100_000; // bytes
363
+
364
+ if (size === 0) {
365
+ return 0;
366
+ }
367
+ // We care about very small files in metrics, thus we handle explicitely
368
+ // the very small files so they appear correctly in metrics.
369
+ if (size < 4096) {
370
+ return 4095;
371
+ }
372
+ if (size < precision) {
373
+ return precision;
374
+ }
375
+ return Math.floor(size / precision) * precision;
376
+ }
@@ -24,6 +24,7 @@ type InternalPartialNode = Pick<
24
24
  | 'nameAuthor'
25
25
  | 'directRole'
26
26
  | 'membership'
27
+ | 'ownedBy'
27
28
  | 'type'
28
29
  | 'mediaType'
29
30
  | 'isShared'
@@ -93,6 +94,7 @@ export function convertInternalNode(node: InternalPartialNode): PublicMaybeNode
93
94
  nameAuthor: node.nameAuthor,
94
95
  directRole: node.directRole,
95
96
  membership: node.membership,
97
+ ownedBy: node.ownedBy,
96
98
  type: node.type,
97
99
  mediaType: node.mediaType,
98
100
  isShared: node.isShared,