@eluvio/elv-client-js 4.2.27 → 4.2.29

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eluvio/elv-client-js",
3
- "version": "4.2.27",
3
+ "version": "4.2.29",
4
4
  "description": "Javascript client for the Eluvio Content Fabric",
5
5
  "main": "src/index.js",
6
6
  "author": "Kevin Talmadge",
package/src/EthClient.js CHANGED
@@ -309,6 +309,7 @@ class EthClient {
309
309
  methodName,
310
310
  methodArgs,
311
311
  value,
312
+ overrides={},
312
313
  timeout,
313
314
  formatArguments=true,
314
315
  cacheContract=true,
@@ -319,7 +320,6 @@ class EthClient {
319
320
  if(!abi) { abi = await this.client.ContractAbi({contractAddress}); }
320
321
 
321
322
  const contract = this.Contract({contractAddress, abi, cacheContract, overrideCachedContract});
322
-
323
323
  // Make method call
324
324
  const createMethodCall = await this.CallContractMethod({
325
325
  contract,
@@ -327,6 +327,7 @@ class EthClient {
327
327
  methodName,
328
328
  methodArgs,
329
329
  value,
330
+ overrides,
330
331
  formatArguments,
331
332
  cacheContract
332
333
  });
@@ -9,6 +9,7 @@ const objectPath = require("object-path");
9
9
 
10
10
  const HttpClient = require("../HttpClient");
11
11
  const ContentObjectAudit = require("../ContentObjectAudit");
12
+ const bs58 = require("bs58");
12
13
 
13
14
  const {
14
15
  ValidateLibrary,
@@ -17,6 +18,7 @@ const {
17
18
  ValidatePartHash,
18
19
  ValidateWriteToken,
19
20
  ValidateParameters,
21
+ ValidatePresence,
20
22
  } = require("../Validation");
21
23
 
22
24
  const MergeWith = require("lodash/mergeWith");
@@ -2713,7 +2715,6 @@ exports.LinkData = async function({libraryId, objectId, versionHash, writeToken,
2713
2715
  );
2714
2716
  };
2715
2717
 
2716
-
2717
2718
  /* Encryption */
2718
2719
 
2719
2720
  exports.CreateEncryptionConk = async function({libraryId, objectId, versionHash, writeToken, createKMSConk=true}) {
@@ -2793,6 +2794,74 @@ exports.CreateEncryptionConk = async function({libraryId, objectId, versionHash,
2793
2794
  return this.encryptionConks[objectId];
2794
2795
  };
2795
2796
 
2797
+ /**
2798
+ * Encrypt the owner encryption key using the new user details provided
2799
+ *
2800
+ * @methodGroup Encryption
2801
+ * @namedParams
2802
+ * @param {string=} libraryId - ID of a library
2803
+ * @param {string=} objectId - ID of an object
2804
+ * @param {string=} versionHash - Hash of an object version
2805
+ * @param {string=} writeToken - The write token for the object
2806
+ * @param {Object=} newEncodedUserPublicKey - raw public key or base-58 encoded publicKey of the new user prefixed 'kupk'
2807
+ */
2808
+ exports.MigrateEncryptionConkForUserProvided = async function({libraryId, objectId, versionHash, writeToken, newEncodedUserPublicKey}) {
2809
+ if(this.signer.remoteSigner) {
2810
+ return;
2811
+ }
2812
+
2813
+ ValidateParameters({libraryId, objectId, versionHash});
2814
+ ValidateWriteToken(writeToken);
2815
+
2816
+ if(!objectId) {
2817
+ objectId = this.utils.DecodeVersionHash(versionHash).objectId;
2818
+ }
2819
+
2820
+ ValidatePresence("newEncodedUserPublicKey", newEncodedUserPublicKey);
2821
+
2822
+ let publicKey;
2823
+ if(!newEncodedUserPublicKey.startsWith("kupk")){
2824
+ publicKey = newEncodedUserPublicKey;
2825
+ } else {
2826
+ const publicKeyBytes = bs58.decode(newEncodedUserPublicKey.slice(4));
2827
+ if(publicKeyBytes.length !== 65) {
2828
+ publicKey = "0x" + Buffer.concat([Buffer.from([0x04]), publicKeyBytes]).toString("hex");
2829
+ } else {
2830
+ publicKey = "0x" + publicKeyBytes.toString("hex");
2831
+ }
2832
+ }
2833
+
2834
+ // Decrypt using existing user key
2835
+ const existingCapKey = `eluv.caps.iusr${this.utils.AddressToHash(this.signer.address)}`;
2836
+ const existingUserCap =
2837
+ await this.ContentObjectMetadata({
2838
+ libraryId,
2839
+ objectId,
2840
+ writeToken,
2841
+ metadataSubtree: existingCapKey
2842
+ });
2843
+
2844
+ if(!existingUserCap) {
2845
+ throw (`No encryption conk present for ${objectId} for user ${this.signer.address}`);
2846
+ }
2847
+
2848
+ this.encryptionConks[objectId] = await this.Crypto.DecryptCap(existingUserCap, this.signer._signingKey().privateKey);
2849
+
2850
+ // Encrypt with the new key
2851
+ const encryptedConk = await this.Crypto.EncryptConk(this.encryptionConks[objectId], publicKey);
2852
+ const newUserCap = `eluv.caps.iusr${this.utils.AddressToHash(this.utils.PublicKeyToAddress(publicKey))}`;
2853
+
2854
+ await this.ReplaceMetadata({
2855
+ libraryId,
2856
+ objectId,
2857
+ writeToken,
2858
+ metadataSubtree: newUserCap,
2859
+ metadata: encryptedConk
2860
+ });
2861
+
2862
+ return this.encryptionConks[objectId];
2863
+ };
2864
+
2796
2865
  /**
2797
2866
  * Retrieve the encryption conk for the specified object. If one has not yet been created
2798
2867
  * and a writeToken has been specified, this method will create a new conk and
@@ -23,6 +23,13 @@ const {
23
23
  ValidateParameters
24
24
  } = require("../Validation");
25
25
 
26
+ const JobStatus = Object.freeze({
27
+ STOPPED: "STOPPED",
28
+ FAILED: "FAILED",
29
+ IN_PROGRESS:"IN_PROGRESS",
30
+ COMPLETED: "COMPLETED"
31
+ });
32
+
26
33
 
27
34
  /* Files */
28
35
 
@@ -52,8 +59,9 @@ exports.ListFiles = async function({libraryId, objectId, path = "", versionHash,
52
59
  });
53
60
  };
54
61
 
62
+
55
63
  /**
56
- * Copy/reference files from S3 to a content object.
64
+ * Upload or resume copy/reference files from S3 to a content object
57
65
  *
58
66
  * S3 authentication is done by either providing an access key and secret or a presigned URL. The credentials will not be stored (neither in the client nor in the Fabric)
59
67
  *
@@ -61,12 +69,12 @@ exports.ListFiles = async function({libraryId, objectId, path = "", versionHash,
61
69
  *
62
70
  * Expected format of fileInfo:
63
71
  *
64
- [
65
- {
66
- path: string,
67
- source: string // either a full path e.g. "s3://BUCKET_NAME/path..." or just the path part without "s3://BUCKET_NAME/"
68
- }
69
- ]
72
+ [
73
+ {
74
+ path: string,
75
+ source: string // either a full path e.g. "s3://BUCKET_NAME/path..." or just the path part without "s3://BUCKET_NAME/"
76
+ }
77
+ ]
70
78
  *
71
79
  * @memberof module:ElvClient/Files+Parts
72
80
  * @methodGroup Files
@@ -82,6 +90,7 @@ exports.ListFiles = async function({libraryId, objectId, path = "", versionHash,
82
90
  * @param {string=} signedUrl
83
91
  * @param {string} encryption="none" - Encryption for uploaded files (copy only) - cgck | none
84
92
  * @param {boolean} copy=false - If true, will copy the data from S3 into the fabric. Otherwise, a reference to the content will be made.
93
+ * @param {boolean} resume=false - If true, will resume the file jobs stopped
85
94
  * @param {function=} callback - If specified, will be periodically called with current upload status
86
95
  * - Arguments (copy): { done: boolean, uploaded: number, total: number, uploadedFiles: number, totalFiles: number, fileStatus: Object }
87
96
  * - Arguments (reference): { done: boolean, uploadedFiles: number, totalFiles: number }
@@ -96,61 +105,41 @@ exports.UploadFilesFromS3 = async function({
96
105
  accessKey,
97
106
  secret,
98
107
  signedUrl,
99
- encryption="none",
100
- copy=false,
108
+ encryption = "none",
109
+ copy = false,
110
+ resume = false,
101
111
  callback
102
112
  }) {
103
- ValidateParameters({libraryId, objectId});
113
+ ValidateParameters({ libraryId, objectId });
104
114
  ValidateWriteToken(writeToken);
105
115
 
106
116
  const s3prefixRegex = /^s3:\/\/([^/]+)\//i; // for matching and extracting bucket name when full s3:// path is specified
107
117
  // if fileInfo source paths start with s3://bucketName/, check against bucket arg passed in, and strip
108
118
  for(let i = 0; i < fileInfo.length; i++) {
109
119
  const fileSourcePath = fileInfo[i].source;
110
- const s3prefixMatch = (s3prefixRegex.exec(fileSourcePath));
120
+ const s3prefixMatch = s3prefixRegex.exec(fileSourcePath);
111
121
  if(s3prefixMatch) {
112
122
  const bucketName = s3prefixMatch[1];
113
123
  if(bucketName !== bucket) {
114
124
  throw Error("Full S3 file path \"" + fileSourcePath + "\" specified, but does not match provided bucket name '" + bucket + "'");
115
- } else {
116
- // strip prefix
117
- fileInfo[i].source = fileSourcePath.replace(s3prefixRegex,"");
118
125
  }
126
+ // strip prefix
127
+ fileInfo[i].source = fileSourcePath.replace(s3prefixRegex, "");
119
128
  }
120
129
  }
121
130
 
122
- if(copy) {
123
- this.Log(`Copying files from S3: ${libraryId} ${objectId} ${writeToken}`);
124
- } else {
125
- this.Log(`Adding links to files in S3: ${libraryId} ${objectId} ${writeToken}`);
126
- }
131
+ this.Log(`${resume ? "Resuming" : (copy ? "Copying" : "Referencing")} files from S3: ${libraryId} ${objectId} ${writeToken}`);
127
132
 
128
133
  let encryption_key;
129
134
  if(encryption === "cgck") {
130
- let conk = await this.EncryptionConk({
131
- libraryId,
132
- objectId,
133
- writeToken
134
- });
135
-
136
- conk = {
137
- ...conk,
138
- secret_key: ""
139
- };
140
-
135
+ let conk = await this.EncryptionConk({ libraryId, objectId, writeToken });
136
+ conk = { ...conk, secret_key: "" };
141
137
  encryption_key = `kp__${this.utils.B58(Buffer.from(JSON.stringify(conk)))}`;
142
138
  }
143
139
 
144
- let cloudCredentials = {
145
- access_key_id: accessKey,
146
- secret_access_key: secret
147
- };
148
-
149
- if(signedUrl) {
150
- cloudCredentials = {
151
- signed_url: signedUrl
152
- };
153
- }
140
+ const cloudCredentials = signedUrl
141
+ ? { signed_url: signedUrl }
142
+ : { access_key_id: accessKey, secret_access_key: secret };
154
143
 
155
144
  const remoteAccess = {
156
145
  protocol: "s3",
@@ -191,55 +180,94 @@ exports.UploadFilesFromS3 = async function({
191
180
  }
192
181
  });
193
182
 
194
- // eslint-disable-next-line no-unused-vars
195
- const {id} = await this.CreateFileUploadJob({libraryId, objectId, writeToken, ops, defaults});
196
-
197
- // eslint-disable-next-line no-constant-condition
198
- while(true) {
199
- await new Promise(resolve => setTimeout(resolve, 1000));
183
+ const trackUploadStatus = async ({ uploadId }) => {
184
+ // eslint-disable-next-line no-constant-condition
185
+ while(true) {
186
+ await new Promise(resolve => setTimeout(resolve, 1000));
187
+ const status = await this.UploadStatus({ libraryId, objectId, writeToken, uploadId });
188
+
189
+ if(status.errors && status.errors.length >= 1) {
190
+ throw new Error(status.errors.join("\n"));
191
+ } else if(status.error) {
192
+ this.Log(`S3 file upload failed:\n${JSON.stringify(status, null, 2)}`);
193
+ throw new Error(status.error);
194
+ } else if(status.status.toLowerCase() === "failed") {
195
+ throw new Error("File upload failed");
196
+ }
200
197
 
201
- const status = await this.UploadStatus({libraryId, objectId, writeToken, uploadId: id});
198
+ let done = false;
199
+ if(copy) {
200
+ done = status.ingest_copy.done;
201
+ if(callback) {
202
+ const progress = status.ingest_copy.progress;
203
+
204
+ callback({
205
+ done,
206
+ uploaded: progress.bytes.completed,
207
+ total: progress.bytes.total,
208
+ uploadedFiles: progress.files.completed,
209
+ totalFiles: progress.files.total,
210
+ fileStatus: progress.files.details
211
+ });
212
+ }
213
+ } else {
214
+ done = status.add_reference.done;
215
+ if(callback) {
216
+ const progress = status.add_reference.progress;
217
+
218
+ callback({
219
+ done,
220
+ uploadedFiles: progress.completed,
221
+ totalFiles: progress.total,
222
+ });
223
+ }
224
+ }
202
225
 
203
- if(status.errors && status.errors.length > 1) {
204
- throw status.errors.join("\n");
205
- } else if(status.error) {
206
- this.Log(`S3 file upload failed:\n${JSON.stringify(status, null, 2)}`);
207
- throw status.error;
208
- } else if(status.status.toLowerCase() === "failed") {
209
- throw "File upload failed";
226
+ if(done) break;
210
227
  }
228
+ };
211
229
 
212
- let done = false;
213
- if(copy) {
214
- done = status.ingest_copy.done;
215
-
216
- if(callback) {
217
- const progress = status.ingest_copy.progress;
218
-
219
- callback({
220
- done,
221
- uploaded: progress.bytes.completed,
222
- total: progress.bytes.total,
223
- uploadedFiles: progress.files.completed,
224
- totalFiles: progress.files.total,
225
- fileStatus: progress.files.details
226
- });
227
- }
228
- } else {
229
- done = status.add_reference.done;
230
+ if(resume) {
231
+ const jobIds = await this.ListFilesJob({ libraryId, objectId, writeToken, encryption });
232
+ const jobsByStatus = jobIds.reduce((acc, job) => {
233
+ acc[job.status] = acc[job.status] || new Map();
234
+ acc[job.status].set(job.id, true);
235
+ return acc;
236
+ }, {});
230
237
 
231
- if(callback) {
232
- const progress = status.add_reference.progress;
238
+ const resumeAndTrack = async (jobIds) => {
239
+ const responses = await this.ResumeFileUploadJob({
240
+ libraryId,
241
+ objectId,
242
+ writeToken,
243
+ ops,
244
+ defaults,
245
+ encryption,
246
+ stoppedOrFailedJobIds: jobIds
247
+ });
248
+ await this.utils.LimitedMap(5, responses, async res => {
249
+ this.Log(`Tracking resumed job id: ${res.id}`);
250
+ await trackUploadStatus({ uploadId: res.id });
251
+ });
252
+ };
233
253
 
234
- callback({
235
- done,
236
- uploadedFiles: progress.completed,
237
- totalFiles: progress.total,
238
- });
254
+ for(const status of [JobStatus.STOPPED, JobStatus.FAILED]) {
255
+ if(jobsByStatus[status]){
256
+ await resumeAndTrack([...jobsByStatus[status].keys()]);
239
257
  }
240
258
  }
241
259
 
242
- if(done) { break; }
260
+ const trackOnlyEntries = Object.entries(jobsByStatus)
261
+ .filter(([status]) => [JobStatus.IN_PROGRESS, JobStatus.COMPLETED].includes(status))
262
+ .flatMap(([status, jobs]) => [...jobs.keys()].map(jobId => ({ status, jobId })));
263
+
264
+ await this.utils.LimitedMap(5, trackOnlyEntries, async ({ status, jobId }) => {
265
+ this.Log(`Tracking ${status.toLowerCase()} job id: ${jobId}`);
266
+ await trackUploadStatus({ uploadId: jobId });
267
+ });
268
+ } else {
269
+ const { id: uploadId } = await this.CreateFileUploadJob({ libraryId, objectId, writeToken, ops, defaults });
270
+ await trackUploadStatus({ uploadId });
243
271
  }
244
272
  };
245
273
 
@@ -248,14 +276,14 @@ exports.UploadFilesFromS3 = async function({
248
276
  *
249
277
  * Expected format of fileInfo:
250
278
  *
251
- [
252
- {
253
- path: string,
254
- mime_type: string,
255
- size: number,
256
- data: File | ArrayBuffer | Buffer | File Descriptor (Node)
257
- }
258
- ]
279
+ [
280
+ {
281
+ path: string,
282
+ mime_type: string,
283
+ size: number,
284
+ data: File | ArrayBuffer | Buffer | File Descriptor (Node)
285
+ }
286
+ ]
259
287
  *
260
288
  *
261
289
  * @memberof module:ElvClient/Files+Parts
@@ -265,11 +293,12 @@ exports.UploadFilesFromS3 = async function({
265
293
  * @param {string} objectId - ID of the object
266
294
  * @param {string} writeToken - Write token of the draft
267
295
  * @param {Array<object>} fileInfo - List of files to upload, including their size, type, and contents
296
+ * @param {string} resume - Resume the upload jobs
268
297
  * @param {string} encryption="none" - Encryption for uploaded files - cgck | none
269
298
  * @param {function=} callback - If specified, will be called after each job segment is finished with the current upload progress
270
299
  * - Format: {"filename1": {uploaded: number, total: number}, ...}
271
300
  */
272
- exports.UploadFiles = async function({libraryId, objectId, writeToken, fileInfo, encryption="none", callback}) {
301
+ exports.UploadFiles = async function({libraryId, objectId, writeToken, fileInfo, resume=false,encryption="none", callback}) {
273
302
  ValidateParameters({libraryId, objectId});
274
303
  ValidateWriteToken(writeToken);
275
304
  ValidatePresence("fileInfo", fileInfo);
@@ -312,160 +341,253 @@ exports.UploadFiles = async function({libraryId, objectId, writeToken, fileInfo,
312
341
 
313
342
  this.Log(fileInfo);
314
343
 
315
- if(callback) {
344
+ // On a fresh upload nothing has happened yet, so 0% is accurate. On resume, the real starting
345
+ // point isn't known until job status is fetched below, so skip this to avoid a misleading
346
+ // initial 0% line ahead of the actual (possibly much higher) baseline.
347
+ if(callback && !resume) {
316
348
  callback(progress);
317
349
  }
318
350
 
319
- const {id, jobs} = await this.CreateFileUploadJob({
320
- libraryId,
321
- objectId,
322
- writeToken,
323
- ops: fileInfo,
324
- encryption
325
- });
351
+ let idJobMap = new Map();
352
+ if(resume) {
353
+ const ids = await this.ListFilesJob({ libraryId, objectId, writeToken, encryption });
326
354
 
327
- this.Log(`Upload ID: ${id}`);
328
- this.Log(jobs);
355
+ const jobsByStatus = ids.reduce((acc, job) => {
356
+ acc[job.status] = acc[job.status] || [];
357
+ acc[job.status].push(job.id);
358
+ return acc;
359
+ }, {});
329
360
 
330
- // How far encryption can get ahead of upload
331
- const bufferSize = 500 * 1024 * 1024;
361
+ const stoppedJobIds = jobsByStatus.STOPPED || [];
362
+ const failedJobIds = jobsByStatus.FAILED || [];
363
+ const inProgressJobIds = jobsByStatus.IN_PROGRESS || [];
332
364
 
333
- let jobSpecs = [];
334
- let prepared = 0;
335
- let uploaded = 0;
365
+ const resumeJobIds = [...stoppedJobIds, ...failedJobIds, ...inProgressJobIds];
366
+ if(resumeJobIds.length === 0) {
367
+ this.Log("No job Ids to resume");
368
+ return;
369
+ }
336
370
 
337
- // Insert the data to upload into the job spec, encrypting if necessary
338
- const PrepareJobs = async () => {
339
- for(let j = 0; j < jobs.length; j++) {
340
- while(prepared - uploaded > bufferSize) {
341
- // Wait for more data to be uploaded
342
- await new Promise(resolve => setTimeout(resolve, 500));
371
+ const resumeJobs = async(jobs) => {
372
+ await this.ResumeFileUploadJob({
373
+ libraryId,
374
+ objectId,
375
+ writeToken,
376
+ ops: fileInfo,
377
+ encryption,
378
+ stoppedOrFailedJobIds: jobs
379
+ });
380
+ };
381
+
382
+ // resume stopped and failed jobs
383
+ for(const jobsToResume of [stoppedJobIds, failedJobIds]){
384
+ if(jobsToResume.length > 0){
385
+ await resumeJobs(jobsToResume);
343
386
  }
387
+ }
344
388
 
345
- // Retrieve job info
346
- const jobId = jobs[j];
347
- let job = await this.UploadJobStatus({
389
+ // for each uploadId, make a map containing jobIds
390
+ const uploadJobsResults = await this.utils.LimitedMap(5, resumeJobIds, async uploadId => {
391
+ const result = await this.ListFilesUploadJobs({
348
392
  libraryId,
349
393
  objectId,
350
394
  writeToken,
351
- uploadId: id,
352
- jobId
395
+ jobId: uploadId,
396
+ encryption
353
397
  });
398
+ return result.jobs;
399
+ });
400
+ resumeJobIds.forEach((uploadId, i) => idJobMap.set(uploadId, uploadJobsResults[i]));
401
+
402
+ } else {
403
+ const jobResponse = await this.CreateFileUploadJob({
404
+ libraryId,
405
+ objectId,
406
+ writeToken,
407
+ ops: fileInfo,
408
+ encryption
409
+ });
410
+ idJobMap.set(jobResponse.id, jobResponse.jobs);
411
+ }
354
412
 
355
- for(let f = 0; f < job.files.length; f++) {
356
- const fileInfo = job.files[f];
413
+ // How far encryption can get ahead of upload (500 MB)
414
+ const bufferSize = 500 * 1024 * 1024;
357
415
 
358
- let data;
359
- if(typeof fileDataMap[fileInfo.path] === "number") {
360
- // File descriptor - Read data from file
361
- data = Buffer.alloc(fileInfo.len);
362
- fs.readSync(fileDataMap[fileInfo.path], data, 0, fileInfo.len, fileInfo.off);
363
- } else {
364
- // Full data - Slice requested chunk
365
- data = fileDataMap[fileInfo.path].slice(fileInfo.off, fileInfo.off + fileInfo.len);
416
+ // Process each uploadId and its jobs
417
+ for(const [uploadId, jobs] of idJobMap.entries()) {
418
+ this.Log(`Upload ID: ${uploadId}`);
419
+ this.Log(jobs.length);
420
+
421
+ // Each upload ID gets its own counters
422
+ let prepared = 0;
423
+ let uploaded = 0;
424
+ let jobSpecs = [];
425
+
426
+ // `${jobId}:${path}` pairs already reflected in progress by the resume baseline scan below,
427
+ // so the normal per-chunk completion credit further down doesn't double-count them
428
+ // It stores the jobId whose remaining bytes = 0
429
+ let alreadyCreditedKeys = new Set();
430
+
431
+ if(resume && callback) {
432
+ // Establish the true resume baseline across ALL chunks up front, before showing any
433
+ // progress. Crediting chunks one at a time as PrepareJobs reaches them (sequentially, each
434
+ // with its own round trip) would make an already-mostly-done file look like it's slowly
435
+ // re-climbing from scratch instead of immediately reflecting what's already been received.
436
+ await this.utils.LimitedMap(5, jobs, async jobId => {
437
+ const job = await this.UploadJobStatus({ libraryId, objectId, writeToken, uploadId, jobId });
438
+
439
+ for(const fileInfo of job.files) {
440
+ // When encrypted, the top-level rem/skip are sentinel (-1) - the real, already-received
441
+ // byte counts for this chunk are under `encrypted` (same lookup UploadFileData uses)
442
+ const fileStatus = (encryption && encryption !== "none") ? fileInfo.encrypted : fileInfo;
443
+ if(fileStatus.rem === 0) {
444
+ alreadyCreditedKeys.add(`${jobId}:${fileInfo.path}`);
445
+ progress[fileInfo.path] = {
446
+ ...progress[fileInfo.path],
447
+ uploaded: progress[fileInfo.path].uploaded + fileInfo.len
448
+ };
449
+ }
366
450
  }
451
+ });
367
452
 
368
- if(encryption === "cgck") {
369
- data = await this.Crypto.Encrypt(conk, data);
370
- }
453
+ callback(progress);
454
+ }
371
455
 
372
- job.files[f].data = data;
456
+ // Insert the data to upload into the job spec, encrypting if necessary
457
+ const PrepareJobs = async () => {
458
+ for(let j = 0; j < jobs.length; j++) {
459
+ while(prepared - uploaded > bufferSize) {
460
+ // Wait for more data to be uploaded
461
+ await new Promise(resolve => setTimeout(resolve, 500));
462
+ }
373
463
 
374
- prepared += fileInfo.len;
375
- }
464
+ // Retrieve job info
465
+ const jobId = jobs[j];
466
+ let job = await this.UploadJobStatus({
467
+ libraryId,
468
+ objectId,
469
+ writeToken,
470
+ uploadId,
471
+ jobId
472
+ });
376
473
 
377
- jobSpecs[j] = job;
474
+ for(let f = 0; f < job.files.length; f++) {
475
+ const fileInfo = job.files[f];
476
+
477
+ let data;
478
+ if(typeof fileDataMap[fileInfo.path] === "number") {
479
+ // File descriptor - Read data from file
480
+ data = Buffer.alloc(fileInfo.len);
481
+ fs.readSync(fileDataMap[fileInfo.path], data, 0, fileInfo.len, fileInfo.off);
482
+ } else {
483
+ // Full data - Slice requested chunk
484
+ data = fileDataMap[fileInfo.path].slice(fileInfo.off, fileInfo.off + fileInfo.len);
485
+ }
378
486
 
379
- // Wait for a bit to let upload start
380
- await new Promise(resolve => setTimeout(resolve, 50));
381
- }
382
- };
487
+ if(encryption === "cgck") {
488
+ data = await this.Crypto.Encrypt(conk, data);
489
+ }
383
490
 
384
- const UploadJob = async (jobId, j) => {
385
- while(!jobSpecs[j]) {
386
- // Wait for more jobs to be prepared
387
- await new Promise(resolve => setTimeout(resolve, 500));
388
- }
491
+ job.files[f].data = data;
389
492
 
390
- const jobSpec = jobSpecs[j];
391
- const files = jobSpec.files;
493
+ prepared += fileInfo.len;
494
+ }
392
495
 
393
- // Upload each item
394
- for(let f = 0; f < files.length; f++) {
395
- const fileInfo = files[f];
496
+ jobSpecs[j] = job;
396
497
 
397
- let retries = 0;
398
- let succeeded = false;
399
- do {
400
- try {
401
- await this.UploadFileData({
402
- libraryId,
403
- objectId,
404
- writeToken,
405
- uploadId: id,
406
- jobId,
407
- filePath: fileInfo.path,
408
- fileData: fileInfo.data,
409
- encryption
410
- });
498
+ // Wait for a bit to let upload start
499
+ await new Promise(resolve => setTimeout(resolve, 50));
500
+ }
411
501
 
412
- succeeded = true;
413
- } catch(error) {
414
- this.Log(error, true);
502
+ };
415
503
 
416
- retries += 1;
504
+ const UploadJob = async (jobId, j) => {
505
+ while(!jobSpecs[j]) {
506
+ // Wait for more jobs to be prepared
507
+ await new Promise(resolve => setTimeout(resolve, 500));
508
+ }
417
509
 
418
- if(retries >= 10) {
419
- throw error;
510
+ const jobSpec = jobSpecs[j];
511
+ const files = jobSpec.files;
512
+
513
+ // Upload each item
514
+ for(let f = 0; f < files.length; f++) {
515
+ const fileInfo = files[f];
516
+
517
+ let retries = 0;
518
+ let succeeded = false;
519
+ do {
520
+ try {
521
+ await this.UploadFileData({
522
+ libraryId,
523
+ objectId,
524
+ writeToken,
525
+ uploadId,
526
+ jobId,
527
+ filePath: fileInfo.path,
528
+ fileData: fileInfo.data,
529
+ encryption
530
+ });
531
+
532
+ succeeded = true;
533
+ } catch(error) {
534
+ this.Log(error, true);
535
+
536
+ retries += 1;
537
+
538
+ if(retries >= 10) {
539
+ throw error;
540
+ }
541
+
542
+ await new Promise(resolve => setTimeout(resolve, 10 * retries * 1000));
420
543
  }
544
+ } while(!succeeded && retries < 10);
421
545
 
422
- await new Promise(resolve => setTimeout(resolve, 10 * retries * 1000));
423
- }
424
- } while(!succeeded && retries < 10);
546
+ delete jobSpecs[j].files[f].data;
547
+ uploaded += fileInfo.len;
425
548
 
426
- delete jobSpecs[j].files[f].data;
427
- uploaded += fileInfo.len;
549
+ if(callback && !alreadyCreditedKeys.has(`${jobId}:${fileInfo.path}`)) {
550
+ progress[fileInfo.path] = {
551
+ ...progress[fileInfo.path],
552
+ uploaded: progress[fileInfo.path].uploaded + fileInfo.len
553
+ };
428
554
 
429
- if(callback) {
430
- progress[fileInfo.path] = {
431
- ...progress[fileInfo.path],
432
- uploaded: progress[fileInfo.path].uploaded + fileInfo.len
433
- };
434
-
435
- callback(progress);
555
+ callback(progress);
556
+ }
436
557
  }
558
+ };
559
+
560
+ // Preparing jobs is done asynchronously
561
+ PrepareJobs().catch(e => { throw e; });
562
+
563
+ // Upload the first several chunks in sequence, to determine average upload rate
564
+ const rateTestJobs = Math.min(3, jobs.length);
565
+ let rates = [];
566
+ for(let j = 0; j < rateTestJobs; j++) {
567
+ const start = new Date().getTime();
568
+ await UploadJob(jobs[j], j);
569
+ const elapsed = (new Date().getTime() - start) / 1000;
570
+ const size = jobSpecs[j].files.map(file => file.len).reduce((length, total) => length + total, 0);
571
+ rates.push(size / elapsed / (1024 * 1024));
437
572
  }
438
- };
439
573
 
440
- // Preparing jobs is done asynchronously
441
- PrepareJobs().catch(e => {
442
- throw e;
443
- });
574
+ const averageRate = rates.reduce((mbps, total) => mbps + total, 0) / rateTestJobs;
444
575
 
445
- // Upload the first several chunks in sequence, to determine average upload rate
446
- const rateTestJobs = Math.min(3, jobs.length);
447
- let rates = [];
448
- for(let j = 0; j < rateTestJobs; j++) {
449
- const start = new Date().getTime();
450
- await UploadJob(jobs[j], j);
451
- const elapsed = (new Date().getTime() - start) / 1000;
452
- const size = jobSpecs[j].files.map(file => file.len).reduce((length, total) => length + total, 0);
453
- rates.push(size / elapsed / (1024 * 1024));
576
+ // Upload remaining jobs in parallel
577
+ const concurrentUploads = Math.min(5, Math.ceil(averageRate / 2));
578
+ await this.utils.LimitedMap(
579
+ concurrentUploads,
580
+ jobs,
581
+ async (jobId, j) => {
582
+ if(j < rateTestJobs) { return; }
583
+ try {
584
+ await UploadJob(jobId, j);
585
+ } catch(err) {
586
+ throw Error (`Failed upload for job ${jobId}: ${err}`);
587
+ }
588
+ }
589
+ );
454
590
  }
455
-
456
- const averageRate = rates.reduce((mbps, total) => mbps + total, 0) / rateTestJobs;
457
-
458
- // Upload remaining jobs in parallel
459
- const concurrentUploads = Math.min(5, Math.ceil(averageRate / 2));
460
- await this.utils.LimitedMap(
461
- concurrentUploads,
462
- jobs,
463
- async (jobId, j) => {
464
- if(j < rateTestJobs) { return; }
465
-
466
- await UploadJob(jobId, j);
467
- }
468
- );
469
591
  };
470
592
 
471
593
  exports.CreateFileUploadJob = async function({libraryId, objectId, writeToken, ops, defaults={}, encryption="none"}) {
@@ -497,12 +619,84 @@ exports.CreateFileUploadJob = async function({libraryId, objectId, writeToken, o
497
619
  });
498
620
  };
499
621
 
622
+ exports.ListFilesJob = async function({libraryId, objectId, writeToken, encryption="none"}) {
623
+ ValidateParameters({libraryId, objectId});
624
+ ValidateWriteToken(writeToken);
625
+
626
+ this.Log(`List file jobs: ${libraryId} ${objectId} ${writeToken}`);
627
+
628
+ const path = UrlJoin( "q", writeToken, "file_jobs");
629
+
630
+ return this.HttpClient.RequestJsonBody({
631
+ headers: await this.authClient.AuthorizationHeader({libraryId, objectId, update: true, encryption}),
632
+ method: "GET",
633
+ path: path,
634
+ allowFailover: false
635
+ });
636
+ };
637
+
638
+ exports.ListFilesUploadJobs = async function({libraryId, objectId, writeToken, jobId, encryption="none"}) {
639
+ ValidateParameters({libraryId, objectId});
640
+ ValidateWriteToken(writeToken);
641
+
642
+ this.Log(`List file upload jobs: ${libraryId} ${objectId} ${writeToken}`);
643
+
644
+ const path = UrlJoin("qlibs", libraryId, "q", writeToken, "file_jobs", jobId, "uploads");
645
+
646
+ return this.HttpClient.RequestJsonBody({
647
+ headers: await this.authClient.AuthorizationHeader({libraryId, objectId, update: true, encryption}),
648
+ method: "GET",
649
+ path: path,
650
+ allowFailover: false
651
+ });
652
+ };
653
+
654
+ exports.ResumeFileUploadJob = async function({libraryId, objectId, writeToken, ops, defaults={}, encryption="none", stoppedOrFailedJobIds=[]}) {
655
+ ValidateParameters({libraryId, objectId});
656
+ ValidateWriteToken(writeToken);
657
+
658
+ if(stoppedOrFailedJobIds.length === 0) {
659
+ this.Log("No stopped/failed jobs found");
660
+ throw new Error("No STOPPED/FAILED jobs found");
661
+ }
662
+
663
+ this.Log(`Resuming file upload job: ${libraryId} ${objectId} ${writeToken}`);
664
+
665
+ if(encryption === "cgck") {
666
+ defaults.encryption = { scheme: "cgck" };
667
+ }
668
+
669
+ const body = {
670
+ seq: 0,
671
+ seq_complete: true,
672
+ clear_resolve: true,
673
+ defaults,
674
+ ops
675
+ };
676
+
677
+ // resume and change jobId status from 'STOPPED' to 'IN_PROGRESS'
678
+ const responses = [];
679
+ this.Log(`Stopped or Failed job IDs: ${stoppedOrFailedJobIds.join(",")}`);
680
+ for(const jobId of stoppedOrFailedJobIds) {
681
+ const path = UrlJoin("q", writeToken, "file_jobs" , jobId, "resume");
682
+
683
+ let res = await this.HttpClient.RequestJsonBody({
684
+ headers: await this.authClient.AuthorizationHeader({libraryId, objectId, update: true, encryption}),
685
+ method: "PUT",
686
+ path: path,
687
+ body,
688
+ allowFailover: false
689
+ });
690
+ responses.push({message: res, id: jobId});
691
+ }
692
+ return responses;
693
+ };
694
+
500
695
  exports.UploadStatus = async function({libraryId, objectId, writeToken, uploadId}) {
501
696
  ValidateParameters({libraryId, objectId});
502
697
  ValidateWriteToken(writeToken);
503
698
 
504
699
  const path = UrlJoin("q", writeToken, "file_jobs", uploadId);
505
-
506
700
  return this.utils.ResponseToJson(
507
701
  this.HttpClient.Request({
508
702
  headers: await this.authClient.AuthorizationHeader({libraryId, objectId, update: true}),
@@ -571,7 +765,7 @@ exports.UploadFileData = async function({libraryId, objectId, writeToken, encryp
571
765
 
572
766
  let path = UrlJoin("q", writeToken, "file_jobs", uploadId, jobId);
573
767
 
574
- return await this.utils.ResponseToJson(
768
+ const response = await this.utils.ResponseToJson(
575
769
  this.HttpClient.Request({
576
770
  method: "POST",
577
771
  path: path,
@@ -585,6 +779,17 @@ exports.UploadFileData = async function({libraryId, objectId, writeToken, encryp
585
779
  allowRetry: false
586
780
  })
587
781
  );
782
+
783
+ if(response && response.part && !response.part.hash) {
784
+ // The request succeeded but the server received fewer bytes than the part needed to seal
785
+ // (e.g. a dropped/truncated connection) - part.hash is only populated once finalized, so an
786
+ // empty hash here means this chunk isn't actually done despite the 200. Throw so the caller's
787
+ // retry loop re-reads the job status (updated skip/rem) and resends the missing remainder,
788
+ // instead of the chunk silently being counted as complete.
789
+ throw new Error(`Upload did not finalize part for ${filePath} (job ${jobId})`);
790
+ }
791
+
792
+ return response;
588
793
  };
589
794
 
590
795
  exports.FinalizeUploadJob = async function({libraryId, objectId, writeToken}) {
@@ -1090,9 +1090,9 @@ exports.StreamStatus = async function({name, showParams=false, writeToken}) {
1090
1090
  status.recordingStatus = lroStatus.custom.status;
1091
1091
  }
1092
1092
  } catch(error) {
1093
- console.log("LRO Status (failed): ", error.response.statusCode);
1093
+ console.log("LRO Status (failed): ", error.response?.statusCode);
1094
1094
  status.state = "stopped";
1095
- status.error = error.response;
1095
+ status.error = error.response || error.message;
1096
1096
  return status;
1097
1097
  }
1098
1098
 
@@ -3532,6 +3532,13 @@ exports.OutputsList = async function({libraryId, objectId, includeState=true}) {
3532
3532
  method: "live/outputs",
3533
3533
  constant: true
3534
3534
  });
3535
+ } catch(error) {
3536
+ if(error.status === 404) {
3537
+ // Stream object may have been deleted
3538
+ return {};
3539
+ }
3540
+
3541
+ throw error;
3535
3542
  } finally {
3536
3543
  restore();
3537
3544
  }
@@ -3544,17 +3551,22 @@ exports.OutputsList = async function({libraryId, objectId, includeState=true}) {
3544
3551
  const streamId = value.input?.stream;
3545
3552
  if(!streamId) { return; }
3546
3553
 
3547
- const [streamLibraryId, streamStatus] = await Promise.all([
3548
- this.ContentObjectLibraryId({objectId: streamId}),
3549
- this.StreamStatus({name: streamId})
3550
- ]);
3551
-
3552
- value.input.name = await this.ContentObjectMetadata({
3553
- libraryId: streamLibraryId,
3554
- objectId: streamId,
3555
- metadataSubtree: "/public/name",
3556
- });
3557
- value.input.status = streamStatus?.state;
3554
+ try {
3555
+ const [streamLibraryId, streamStatus] = await Promise.all([
3556
+ this.ContentObjectLibraryId({objectId: streamId}),
3557
+ this.StreamStatus({name: streamId})
3558
+ ]);
3559
+
3560
+ value.input.name = await this.ContentObjectMetadata({
3561
+ libraryId: streamLibraryId,
3562
+ objectId: streamId,
3563
+ metadataSubtree: "/public/name",
3564
+ });
3565
+ value.input.status = streamStatus?.state;
3566
+ } catch(error) {
3567
+ // Stream object may have been deleted
3568
+ value.input.status = "unavailable";
3569
+ }
3558
3570
  }
3559
3571
  );
3560
3572
 
@@ -3569,9 +3581,13 @@ exports.OutputsList = async function({libraryId, objectId, includeState=true}) {
3569
3581
 
3570
3582
  const nodeUrls = {};
3571
3583
  await this.utils.LimitedMap(10, nodeIds, async nodeId => {
3572
- const nodes = await this.SpaceNodes({matchNodeId: nodeId});
3573
- const fabricUrl = nodes?.[0]?.services?.fabric_api?.urls?.[0];
3574
- if(fabricUrl) { nodeUrls[nodeId] = fabricUrl; }
3584
+ try {
3585
+ const nodes = await this.SpaceNodes({matchNodeId: nodeId});
3586
+ const fabricUrl = nodes?.[0]?.services?.fabric_api?.urls?.[0];
3587
+ if(fabricUrl) { nodeUrls[nodeId] = fabricUrl; }
3588
+ } catch(error) {
3589
+ this.Log(`Failed to resolve node ${nodeId}: ${error.message}`, true);
3590
+ }
3575
3591
  });
3576
3592
 
3577
3593
  // Rewrite fabric-generated srt_pull URLs to the egress host, and group outputs by node
@@ -0,0 +1,62 @@
1
+ const {NewOpt} = require("./lib/options");
2
+ const Utility = require("./lib/Utility");
3
+
4
+ const Client = require("./lib/concerns/Client");
5
+ const Edit = require("./lib/concerns/Edit");
6
+
7
+ class MigrateEncryptionConkForNewUser extends Utility {
8
+ blueprint() {
9
+ return {
10
+ concerns: [Client, Edit],
11
+ options: [
12
+ NewOpt("objectId",
13
+ {
14
+ descTemplate: "object Id",
15
+ type: "string",
16
+ demand: true
17
+ }),
18
+ NewOpt("publicKey",
19
+ {
20
+ descTemplate: "new user public key with/without encoded form",
21
+ type: "string",
22
+ demand: true
23
+ })
24
+ ]
25
+ };
26
+ }
27
+
28
+ async body() {
29
+ const logger = this.logger;
30
+ const {objectId, publicKey} = this.args;
31
+
32
+ const client = await this.concerns.Client.get();
33
+ const libraryId = await client.ContentObjectLibraryId({objectId});
34
+
35
+ const res = await client.EditContentObject({libraryId, objectId});
36
+
37
+ await client.MigrateEncryptionConkForUserProvided({
38
+ libraryId,
39
+ objectId,
40
+ writeToken: res.writeToken,
41
+ newEncodedUserPublicKey: publicKey
42
+ });
43
+ logger.log("Migration of encryption conk is complete...");
44
+
45
+ const versionHash = await this.concerns.Edit.finalize({
46
+ libraryId,
47
+ objectId,
48
+ writeToken:res.writeToken
49
+ });
50
+ logger.log("version hash", versionHash);
51
+ }
52
+
53
+ header() {
54
+ return `Add encryption conk for user provided '${this.args.objectId}':`;
55
+ }
56
+ }
57
+
58
+ if(require.main === module) {
59
+ Utility.cmdLineInvoke(MigrateEncryptionConkForNewUser);
60
+ } else {
61
+ module.exports = MigrateEncryptionConkForNewUser;
62
+ }
@@ -1,6 +1,6 @@
1
1
  // Create new production master from specified file(s)
2
2
 
3
- const {ModOpt, StdOpt} = require("./lib/options");
3
+ const {ModOpt, StdOpt, NewOpt} = require("./lib/options");
4
4
  const Utility = require("./lib/Utility");
5
5
 
6
6
  const ArgNoWait = require("./lib/concerns/ArgNoWait");
@@ -16,14 +16,19 @@ class ObjectAddFiles extends Utility {
16
16
  concerns: [Logger, ExistObj, Edit, ArgNoWait, LocalFile, CloudFile],
17
17
  options: [
18
18
  ModOpt("files", {X: "to add"}),
19
- StdOpt("encrypt", {X: "uploaded files"})
19
+ StdOpt("encrypt", {X: "uploaded files"}),
20
+ NewOpt("resume", {
21
+ descTemplate: "If specified, resume jobs for the given write token (--resume 'write_token')",
22
+ group: "Main",
23
+ type: "string"
24
+ })
20
25
  ]
21
26
  };
22
27
  }
23
28
 
24
29
  async body() {
25
30
  const logger = this.logger;
26
- const {encrypt, noWait} = this.args;
31
+ const {encrypt, noWait, resume: resumeArg} = this.args;
27
32
 
28
33
  let access;
29
34
  if(this.args.s3Reference || this.args.s3Copy) access = this.concerns.CloudFile.credentialSet();
@@ -35,10 +40,22 @@ class ObjectAddFiles extends Utility {
35
40
 
36
41
  const {libraryId, objectId} = await this.concerns.ExistObj.argsProc();
37
42
 
38
- const writeToken = await this.concerns.Edit.getWriteToken({
39
- libraryId,
40
- objectId
41
- });
43
+ let writeToken;
44
+ let resume=false;
45
+ if(resumeArg) {
46
+ writeToken = resumeArg;
47
+ resume=true;
48
+ } else {
49
+ // create new write token
50
+ writeToken = await this.concerns.Edit.getWriteToken({
51
+ libraryId,
52
+ objectId
53
+ });
54
+ }
55
+ logger.data("WRITE_TOKEN", writeToken);
56
+ // eslint-disable-next-line no-console
57
+ console.log("Using write token:", writeToken);
58
+
42
59
 
43
60
  if(access) {
44
61
  await this.concerns.CloudFile.add({
@@ -47,7 +64,8 @@ class ObjectAddFiles extends Utility {
47
64
  writeToken,
48
65
  access,
49
66
  fileInfo,
50
- encrypt
67
+ encrypt,
68
+ resume,
51
69
  });
52
70
  } else {
53
71
  await this.concerns.LocalFile.add({
@@ -55,12 +73,14 @@ class ObjectAddFiles extends Utility {
55
73
  objectId,
56
74
  writeToken,
57
75
  fileInfo,
58
- encrypt
76
+ encrypt,
77
+ resume,
59
78
  });
60
79
  // Close file handles
61
80
  this.concerns.LocalFile.closeFileHandles(fileHandles);
62
81
  }
63
82
 
83
+ // finalize the write token
64
84
  const hash = await this.concerns.Edit.finalize({
65
85
  libraryId,
66
86
  noWait,
@@ -95,7 +95,7 @@ const New = context => {
95
95
  );
96
96
  };
97
97
 
98
- const add = async ({libraryId, objectId, writeToken, files, access, encrypt, copy}) => {
98
+ const add = async ({libraryId, objectId, writeToken, files, access, encrypt, copy, resume}) => {
99
99
  if(kindOf(copy) === "undefined") copy = isCopy();
100
100
  files = files || context.args.files;
101
101
  const groupedFiles = groupByPathMatch(access, files);
@@ -125,6 +125,7 @@ const New = context => {
125
125
  secret,
126
126
  signedUrl,
127
127
  copy,
128
+ resume,
128
129
  callback,
129
130
  encryption: encrypt ? "cgck" : "none"
130
131
  });
@@ -19,7 +19,7 @@ const blueprint = {
19
19
 
20
20
  const New = context => {
21
21
 
22
- const add = async ({libraryId, objectId, writeToken, fileInfo, encrypt}) => {
22
+ const add = async ({libraryId, objectId, writeToken, fileInfo, encrypt, resume}) => {
23
23
  const client = await context.concerns.Client.get();
24
24
  await client.UploadFiles({
25
25
  libraryId,
@@ -27,6 +27,7 @@ const New = context => {
27
27
  writeToken,
28
28
  fileInfo,
29
29
  callback,
30
+ resume,
30
31
  encryption: encrypt ? "cgck" : "none"
31
32
  });
32
33
  };