@devtion/backend 0.0.0-7e983e3

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 (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +151 -0
  3. package/dist/src/functions/index.js +2644 -0
  4. package/dist/src/functions/index.mjs +2596 -0
  5. package/dist/types/functions/ceremony.d.ts +33 -0
  6. package/dist/types/functions/ceremony.d.ts.map +1 -0
  7. package/dist/types/functions/circuit.d.ts +63 -0
  8. package/dist/types/functions/circuit.d.ts.map +1 -0
  9. package/dist/types/functions/index.d.ts +7 -0
  10. package/dist/types/functions/index.d.ts.map +1 -0
  11. package/dist/types/functions/participant.d.ts +58 -0
  12. package/dist/types/functions/participant.d.ts.map +1 -0
  13. package/dist/types/functions/storage.d.ts +37 -0
  14. package/dist/types/functions/storage.d.ts.map +1 -0
  15. package/dist/types/functions/timeout.d.ts +26 -0
  16. package/dist/types/functions/timeout.d.ts.map +1 -0
  17. package/dist/types/functions/user.d.ts +15 -0
  18. package/dist/types/functions/user.d.ts.map +1 -0
  19. package/dist/types/lib/errors.d.ts +75 -0
  20. package/dist/types/lib/errors.d.ts.map +1 -0
  21. package/dist/types/lib/services.d.ts +9 -0
  22. package/dist/types/lib/services.d.ts.map +1 -0
  23. package/dist/types/lib/utils.d.ts +141 -0
  24. package/dist/types/lib/utils.d.ts.map +1 -0
  25. package/dist/types/types/enums.d.ts +13 -0
  26. package/dist/types/types/enums.d.ts.map +1 -0
  27. package/dist/types/types/index.d.ts +130 -0
  28. package/dist/types/types/index.d.ts.map +1 -0
  29. package/package.json +89 -0
  30. package/src/functions/ceremony.ts +333 -0
  31. package/src/functions/circuit.ts +1092 -0
  32. package/src/functions/index.ts +36 -0
  33. package/src/functions/participant.ts +526 -0
  34. package/src/functions/storage.ts +548 -0
  35. package/src/functions/timeout.ts +294 -0
  36. package/src/functions/user.ts +142 -0
  37. package/src/lib/errors.ts +237 -0
  38. package/src/lib/services.ts +28 -0
  39. package/src/lib/utils.ts +472 -0
  40. package/src/types/enums.ts +12 -0
  41. package/src/types/index.ts +140 -0
  42. package/test/index.test.ts +62 -0
@@ -0,0 +1,548 @@
1
+ import * as functions from "firebase-functions"
2
+ import admin from "firebase-admin"
3
+ import {
4
+ GetObjectCommand,
5
+ CreateMultipartUploadCommand,
6
+ UploadPartCommand,
7
+ CompleteMultipartUploadCommand,
8
+ HeadObjectCommand,
9
+ CreateBucketCommand,
10
+ PutPublicAccessBlockCommand,
11
+ PutBucketCorsCommand,
12
+ HeadBucketCommand
13
+ } from "@aws-sdk/client-s3"
14
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
15
+ import dotenv from "dotenv"
16
+ import {
17
+ commonTerms,
18
+ getParticipantsCollectionPath,
19
+ ParticipantStatus,
20
+ ParticipantContributionStep,
21
+ formatZkeyIndex,
22
+ getZkeyStorageFilePath
23
+ } from "@p0tion/actions"
24
+ import { getCeremonyCircuits, getDocumentById } from "../lib/utils"
25
+ import { COMMON_ERRORS, logAndThrowError, makeError, printLog, SPECIFIC_ERRORS } from "../lib/errors"
26
+ import { LogLevel } from "../types/enums"
27
+ import { getS3Client } from "../lib/services"
28
+ import {
29
+ BucketAndObjectKeyData,
30
+ CompleteMultiPartUploadData,
31
+ CreateBucketData,
32
+ GeneratePreSignedUrlsPartsData,
33
+ StartMultiPartUploadData
34
+ } from "../types/index"
35
+
36
+ dotenv.config()
37
+
38
+ /**
39
+ * Check if the pre-condition for interacting w/ a multi-part upload for an identified current contributor is valid.
40
+ * @notice the precondition is be a current contributor (contributing status) in the uploading contribution step.
41
+ * @param contributorId <string> - the unique identifier of the contributor.
42
+ * @param ceremonyId <string> - the unique identifier of the ceremony.
43
+ */
44
+ const checkPreConditionForCurrentContributorToInteractWithMultiPartUpload = async (
45
+ contributorId: string,
46
+ ceremonyId: string
47
+ ) => {
48
+ // Get ceremony and participant documents.
49
+ const ceremonyDoc = await getDocumentById(commonTerms.collections.ceremonies.name, ceremonyId)
50
+ const participantDoc = await getDocumentById(getParticipantsCollectionPath(ceremonyId), contributorId!)
51
+
52
+ // Get data from docs.
53
+ const ceremonyData = ceremonyDoc.data()
54
+ const participantData = participantDoc.data()
55
+
56
+ if (!ceremonyData || !participantData) logAndThrowError(COMMON_ERRORS.CM_INEXISTENT_DOCUMENT_DATA)
57
+
58
+ // Check pre-condition to start multi-part upload for a current contributor.
59
+ const { status, contributionStep } = participantData!
60
+
61
+ if (status !== ParticipantStatus.CONTRIBUTING && contributionStep !== ParticipantContributionStep.UPLOADING)
62
+ logAndThrowError(SPECIFIC_ERRORS.SE_STORAGE_CANNOT_INTERACT_WITH_MULTI_PART_UPLOAD)
63
+ }
64
+
65
+ /**
66
+ * Helper function to check whether a contributor is uploading a file related to its contribution.
67
+ * @param contributorId <string> - the unique identifier of the contributor.
68
+ * @param ceremonyId <string> - the unique identifier of the ceremony.
69
+ * @param objectKey <string> - the object key of the file being uploaded.
70
+ */
71
+ const checkUploadingFileValidity = async (contributorId: string, ceremonyId: string, objectKey: string) => {
72
+ // Get the circuits for the ceremony
73
+ const circuits = await getCeremonyCircuits(ceremonyId)
74
+
75
+ // Get the participant document
76
+ const participantDoc = await getDocumentById(getParticipantsCollectionPath(ceremonyId), contributorId!)
77
+ const participantData = participantDoc.data()
78
+
79
+ if (!participantData) logAndThrowError(COMMON_ERRORS.CM_INEXISTENT_DOCUMENT_DATA)
80
+
81
+ // The index of the circuit will be the contribution progress - 1
82
+ const index = participantData?.contributionProgress
83
+ // If the index is zero the user is not the current contributor
84
+ if (index === 0) logAndThrowError(SPECIFIC_ERRORS.SE_STORAGE_CANNOT_INTERACT_WITH_MULTI_PART_UPLOAD)
85
+ // We can safely use index - 1
86
+ const circuit = circuits.at(index - 1)
87
+
88
+ // If the circuit is undefined, throw an error
89
+ if (!circuit) logAndThrowError(SPECIFIC_ERRORS.SE_STORAGE_CANNOT_INTERACT_WITH_MULTI_PART_UPLOAD)
90
+ // Extract the data we need
91
+ const { prefix, waitingQueue } = circuit!.data()
92
+ const { completedContributions, currentContributor } = waitingQueue
93
+
94
+ // If we are not a contributor to this circuit then we cannot upload files
95
+ if (currentContributor === contributorId) {
96
+ // Get the index of the zKey
97
+ const contributorZKeyIndex = formatZkeyIndex(completedContributions + 1)
98
+ // The uploaded file must be the expected one
99
+ const zkeyNameContributor = `${prefix}_${contributorZKeyIndex}.zkey`
100
+ const contributorZKeyStoragePath = getZkeyStorageFilePath(prefix, zkeyNameContributor)
101
+
102
+ // If the object key does not have the expected storage path, throw an error
103
+ if (objectKey !== contributorZKeyStoragePath) {
104
+ logAndThrowError(SPECIFIC_ERRORS.SE_STORAGE_WRONG_OBJECT_KEY)
105
+ }
106
+ } else logAndThrowError(SPECIFIC_ERRORS.SE_STORAGE_CANNOT_INTERACT_WITH_MULTI_PART_UPLOAD)
107
+ }
108
+
109
+ /**
110
+ * Helper function that confirms whether a bucket is used for a ceremony.
111
+ * @dev this helps to prevent unauthorized access to coordinator's buckets.
112
+ * @param bucketName
113
+ */
114
+ const checkIfBucketIsDedicatedToCeremony = async (bucketName: string) => {
115
+ // Get Firestore DB.
116
+ const firestoreDatabase = admin.firestore()
117
+
118
+ // Extract ceremony prefix from bucket name.
119
+ const ceremonyPrefix = bucketName.replace(String(process.env.AWS_CEREMONY_BUCKET_POSTFIX), "")
120
+
121
+ // Query the collection.
122
+ const ceremonyCollection = await firestoreDatabase
123
+ .collection(commonTerms.collections.ceremonies.name)
124
+ .where(commonTerms.collections.ceremonies.fields.prefix, "==", ceremonyPrefix)
125
+ .get()
126
+
127
+ if (ceremonyCollection.empty) logAndThrowError(SPECIFIC_ERRORS.SE_STORAGE_BUCKET_NOT_CONNECTED_TO_CEREMONY)
128
+ }
129
+
130
+ /**
131
+ * Create a new AWS S3 bucket for a particular ceremony.
132
+ * @notice the S3 bucket is used to store all the ceremony artifacts and contributions.
133
+ */
134
+ export const createBucket = functions
135
+ .region("europe-west1")
136
+ .runWith({
137
+ memory: "512MB"
138
+ })
139
+ .https.onCall(async (data: CreateBucketData, context: functions.https.CallableContext) => {
140
+ // Check if the user has the coordinator claim.
141
+ if (!context.auth || !context.auth.token.coordinator) logAndThrowError(COMMON_ERRORS.CM_NOT_COORDINATOR_ROLE)
142
+
143
+ if (!data.bucketName) logAndThrowError(COMMON_ERRORS.CM_MISSING_OR_WRONG_INPUT_DATA)
144
+
145
+ // Connect to S3 client.
146
+ const S3 = await getS3Client()
147
+
148
+ try {
149
+ // Try to get information about the bucket.
150
+ await S3.send(new HeadBucketCommand({ Bucket: data.bucketName }))
151
+ // If the command succeeded, the bucket exists, throw an error.
152
+ logAndThrowError(SPECIFIC_ERRORS.SE_STORAGE_INVALID_BUCKET_NAME)
153
+ } catch (error: any) {
154
+ // eslint-disable-next-line @typescript-eslint/no-shadow
155
+ if (error.name === "NotFound") {
156
+ // Prepare S3 command.
157
+ const command = new CreateBucketCommand({
158
+ Bucket: data.bucketName,
159
+ // CreateBucketConfiguration: {
160
+ // LocationConstraint: String(process.env.AWS_REGION)
161
+ // },
162
+ ObjectOwnership: "BucketOwnerPreferred"
163
+ })
164
+
165
+ try {
166
+ // Execute S3 command.
167
+ const response = await S3.send(command)
168
+
169
+ // Check response.
170
+ if (response.$metadata.httpStatusCode === 200 && !!response.Location)
171
+ printLog(`The AWS S3 bucket ${data.bucketName} has been created successfully`, LogLevel.LOG)
172
+
173
+ const publicBlockCommand = new PutPublicAccessBlockCommand({
174
+ Bucket: data.bucketName,
175
+ PublicAccessBlockConfiguration: {
176
+ BlockPublicAcls: false,
177
+ BlockPublicPolicy: false
178
+ }
179
+ })
180
+
181
+ // Allow objects to be public
182
+ const publicBlockResponse = await S3.send(publicBlockCommand)
183
+ // Check response.
184
+ if (publicBlockResponse.$metadata.httpStatusCode === 204)
185
+ printLog(
186
+ `The AWS S3 bucket ${data.bucketName} has been set with the PublicAccessBlock disabled.`,
187
+ LogLevel.LOG
188
+ )
189
+
190
+ // Set CORS
191
+ const corsCommand = new PutBucketCorsCommand({
192
+ Bucket: data.bucketName,
193
+ CORSConfiguration: {
194
+ CORSRules: [
195
+ {
196
+ AllowedMethods: ["GET"],
197
+ AllowedOrigins: ["*"]
198
+ }
199
+ ]
200
+ }
201
+ })
202
+ const corsResponse = await S3.send(corsCommand)
203
+ // Check response.
204
+ if (corsResponse.$metadata.httpStatusCode === 200)
205
+ printLog(
206
+ `The AWS S3 bucket ${data.bucketName} has been set with the CORS configuration.`,
207
+ LogLevel.LOG
208
+ )
209
+ } catch (error: any) {
210
+ // eslint-disable-next-line @typescript-eslint/no-shadow
211
+ /** * {@link https://docs.aws.amazon.com/simspaceweaver/latest/userguide/troubeshooting_too-many-buckets.html | TooManyBuckets} */
212
+ if (error.$metadata.httpStatusCode === 400 && error.Code === `TooManyBuckets`)
213
+ logAndThrowError(SPECIFIC_ERRORS.SE_STORAGE_TOO_MANY_BUCKETS)
214
+
215
+ // @todo handle more errors here.
216
+
217
+ const commonError = COMMON_ERRORS.CM_INVALID_REQUEST
218
+ const additionalDetails = error.toString()
219
+
220
+ logAndThrowError(makeError(commonError.code, commonError.message, additionalDetails))
221
+ }
222
+ } else {
223
+ // If there was a different error, re-throw it.
224
+ const commonError = COMMON_ERRORS.CM_INVALID_REQUEST
225
+ const additionalDetails = error.toString()
226
+
227
+ logAndThrowError(makeError(commonError.code, commonError.message, additionalDetails))
228
+ }
229
+ }
230
+ })
231
+
232
+ /**
233
+ * Check if a specified object exist in a given AWS S3 bucket.
234
+ * @returns <Promise<boolean>> - true if the object exist in the given bucket; otherwise false.
235
+ */
236
+ export const checkIfObjectExist = functions
237
+ .region("europe-west1")
238
+ .runWith({
239
+ memory: "512MB"
240
+ })
241
+ .https.onCall(async (data: BucketAndObjectKeyData, context: functions.https.CallableContext): Promise<boolean> => {
242
+ // Check if the user has the coordinator claim.
243
+ if (!context.auth || !context.auth.token.coordinator) logAndThrowError(COMMON_ERRORS.CM_NOT_COORDINATOR_ROLE)
244
+
245
+ if (!data.bucketName || !data.objectKey) logAndThrowError(COMMON_ERRORS.CM_MISSING_OR_WRONG_INPUT_DATA)
246
+
247
+ // Connect to S3 client.
248
+ const S3 = await getS3Client()
249
+
250
+ // Prepare S3 command.
251
+ const command = new HeadObjectCommand({ Bucket: data.bucketName, Key: data.objectKey })
252
+
253
+ try {
254
+ // Execute S3 command.
255
+ const response = await S3.send(command)
256
+
257
+ // Check response.
258
+ if (response.$metadata.httpStatusCode === 200 && !!response.ETag) {
259
+ printLog(
260
+ `The object associated w/ ${data.objectKey} key has been found in the ${data.bucketName} bucket`,
261
+ LogLevel.LOG
262
+ )
263
+
264
+ return true
265
+ }
266
+ } catch (error: any) {
267
+ // eslint-disable-next-line @typescript-eslint/no-shadow
268
+ if (error.$metadata.httpStatusCode === 403) logAndThrowError(SPECIFIC_ERRORS.SE_STORAGE_MISSING_PERMISSIONS)
269
+
270
+ // @todo handle more specific errors here.
271
+
272
+ // nb. do not handle common errors! This method must return false if not found!
273
+ // const commonError = COMMON_ERRORS.CM_INVALID_REQUEST
274
+ // const additionalDetails = error.toString()
275
+
276
+ // logAndThrowError(makeError(
277
+ // commonError.code,
278
+ // commonError.message,
279
+ // additionalDetails
280
+ // ))
281
+ }
282
+
283
+ return false
284
+ })
285
+
286
+ /**
287
+ * Return a pre-signed url for a given object contained inside the provided AWS S3 bucket in order to perform a GET request.
288
+ * @notice the pre-signed url has a predefined expiration expressed in seconds inside the environment
289
+ * configuration of the `backend` package. The value should match the configuration of `phase2cli` package
290
+ * environment to avoid inconsistency between client request and CF.
291
+ */
292
+ export const generateGetObjectPreSignedUrl = functions
293
+ .region("europe-west1")
294
+ .runWith({
295
+ memory: "512MB"
296
+ })
297
+ .https.onCall(async (data: BucketAndObjectKeyData, context: functions.https.CallableContext): Promise<any> => {
298
+ if (!context.auth) logAndThrowError(COMMON_ERRORS.CM_NOT_AUTHENTICATED)
299
+
300
+ if (!data.bucketName || !data.objectKey) logAndThrowError(COMMON_ERRORS.CM_MISSING_OR_WRONG_INPUT_DATA)
301
+
302
+ // Prepare input data.
303
+ const { objectKey, bucketName } = data
304
+
305
+ // Check whether the bucket for which we are generating the pre-signed url is dedicated to a ceremony.
306
+ await checkIfBucketIsDedicatedToCeremony(bucketName)
307
+
308
+ // Connect to S3 client.
309
+ const S3 = await getS3Client()
310
+
311
+ // Prepare S3 command.
312
+ const command = new GetObjectCommand({ Bucket: bucketName, Key: objectKey })
313
+
314
+ try {
315
+ // Execute S3 command.
316
+ const url = await getSignedUrl(S3, command, { expiresIn: Number(process.env.AWS_PRESIGNED_URL_EXPIRATION) })
317
+
318
+ if (url) {
319
+ printLog(`The generated pre-signed url is ${url}`, LogLevel.DEBUG)
320
+
321
+ return url
322
+ }
323
+ } catch (error: any) {
324
+ // eslint-disable-next-line @typescript-eslint/no-shadow
325
+ // @todo handle more errors here.
326
+ // if (error.$metadata.httpStatusCode !== 200) {
327
+ const commonError = COMMON_ERRORS.CM_INVALID_REQUEST
328
+ const additionalDetails = error.toString()
329
+
330
+ logAndThrowError(makeError(commonError.code, commonError.message, additionalDetails))
331
+ // }
332
+ }
333
+ })
334
+
335
+ /**
336
+ * Start a new multi-part upload for a specific object in the given AWS S3 bucket.
337
+ * @notice this operation can be performed by either an authenticated participant or a coordinator.
338
+ */
339
+ export const startMultiPartUpload = functions
340
+ .region("europe-west1")
341
+ .runWith({
342
+ memory: "512MB"
343
+ })
344
+ .https.onCall(async (data: StartMultiPartUploadData, context: functions.https.CallableContext): Promise<any> => {
345
+ if (!context.auth || (!context.auth.token.participant && !context.auth.token.coordinator))
346
+ logAndThrowError(COMMON_ERRORS.CM_NOT_AUTHENTICATED)
347
+
348
+ if (!data.bucketName || !data.objectKey || (context.auth?.token.participant && !data.ceremonyId))
349
+ logAndThrowError(COMMON_ERRORS.CM_MISSING_OR_WRONG_INPUT_DATA)
350
+
351
+ // Prepare data.
352
+ const { bucketName, objectKey, ceremonyId } = data
353
+ const userId = context.auth?.uid
354
+
355
+ // Check if the user is a current contributor.
356
+ if (context.auth?.token.participant && !!ceremonyId) {
357
+ // Check pre-condition.
358
+ await checkPreConditionForCurrentContributorToInteractWithMultiPartUpload(userId!, ceremonyId)
359
+
360
+ // Check whether the bucket where the object for which we are generating the pre-signed url is dedicated to a ceremony.
361
+ await checkIfBucketIsDedicatedToCeremony(bucketName)
362
+
363
+ // Check the validity of the uploaded file.
364
+ await checkUploadingFileValidity(userId!, ceremonyId!, objectKey)
365
+ }
366
+
367
+ // Connect to S3 client.
368
+ const S3 = await getS3Client()
369
+
370
+ // Prepare S3 command.
371
+ const command = new CreateMultipartUploadCommand({
372
+ Bucket: bucketName,
373
+ Key: objectKey,
374
+ ACL: context.auth?.token.participant ? "private" : "public-read"
375
+ })
376
+
377
+ try {
378
+ // Execute S3 command.
379
+ const response = await S3.send(command)
380
+ if (response.$metadata.httpStatusCode === 200 && !!response.UploadId) {
381
+ printLog(
382
+ `The multi-part upload identifier is ${response.UploadId}. Requested by ${userId}`,
383
+ LogLevel.DEBUG
384
+ )
385
+
386
+ return response.UploadId
387
+ }
388
+ } catch (error: any) {
389
+ // eslint-disable-next-line @typescript-eslint/no-shadow
390
+ // @todo handle more errors here.
391
+ if (error.$metadata.httpStatusCode !== 200) {
392
+ const commonError = COMMON_ERRORS.CM_INVALID_REQUEST
393
+ const additionalDetails = error.toString()
394
+
395
+ logAndThrowError(makeError(commonError.code, commonError.message, additionalDetails))
396
+ }
397
+ }
398
+ })
399
+
400
+ /**
401
+ * Generate a new pre-signed url for each chunk related to a started multi-part upload.
402
+ * @notice this operation can be performed by either an authenticated participant or a coordinator.
403
+ * the pre-signed url has a predefined expiration expressed in seconds inside the environment
404
+ * configuration of the `backend` package. The value should match the configuration of `phase2cli` package
405
+ * environment to avoid inconsistency between client request and CF.
406
+ */
407
+ export const generatePreSignedUrlsParts = functions
408
+ .region("europe-west1")
409
+ .runWith({
410
+ memory: "512MB"
411
+ })
412
+ .https.onCall(
413
+ async (
414
+ data: GeneratePreSignedUrlsPartsData,
415
+ context: functions.https.CallableContext
416
+ ): Promise<Array<string>> => {
417
+ if (!context.auth || (!context.auth.token.participant && !context.auth.token.coordinator))
418
+ logAndThrowError(COMMON_ERRORS.CM_NOT_AUTHENTICATED)
419
+
420
+ if (
421
+ !data.bucketName ||
422
+ !data.objectKey ||
423
+ !data.uploadId ||
424
+ data.numberOfParts <= 0 ||
425
+ (context.auth?.token.participant && !data.ceremonyId)
426
+ )
427
+ logAndThrowError(COMMON_ERRORS.CM_MISSING_OR_WRONG_INPUT_DATA)
428
+
429
+ // Prepare data.
430
+ const { bucketName, objectKey, uploadId, numberOfParts, ceremonyId } = data
431
+ const userId = context.auth?.uid
432
+
433
+ // Check if the user is a current contributor.
434
+ if (context.auth?.token.participant && !!ceremonyId) {
435
+ // Check pre-condition.
436
+ await checkPreConditionForCurrentContributorToInteractWithMultiPartUpload(userId!, ceremonyId)
437
+ }
438
+
439
+ // Connect to S3 client.
440
+ const S3 = await getS3Client()
441
+
442
+ // Prepare state.
443
+ const parts = []
444
+
445
+ for (let i = 0; i < numberOfParts; i += 1) {
446
+ // Prepare S3 command for each chunk.
447
+ const command = new UploadPartCommand({
448
+ Bucket: bucketName,
449
+ Key: objectKey,
450
+ PartNumber: i + 1,
451
+ UploadId: uploadId
452
+ })
453
+
454
+ try {
455
+ // Get the pre-signed url for the specific chunk.
456
+ const url = await getSignedUrl(S3, command, {
457
+ expiresIn: Number(process.env.AWS_PRESIGNED_URL_EXPIRATION)
458
+ })
459
+
460
+ if (url) {
461
+ // Save.
462
+ parts.push(url)
463
+ }
464
+ } catch (error: any) {
465
+ // eslint-disable-next-line @typescript-eslint/no-shadow
466
+ // @todo handle more errors here.
467
+ // if (error.$metadata.httpStatusCode !== 200) {
468
+ const commonError = COMMON_ERRORS.CM_INVALID_REQUEST
469
+ const additionalDetails = error.toString()
470
+
471
+ logAndThrowError(makeError(commonError.code, commonError.message, additionalDetails))
472
+ // }
473
+ }
474
+ }
475
+
476
+ return parts
477
+ }
478
+ )
479
+
480
+ /**
481
+ * Complete a multi-part upload for a specific object in the given AWS S3 bucket.
482
+ * @notice this operation can be performed by either an authenticated participant or a coordinator.
483
+ */
484
+ export const completeMultiPartUpload = functions
485
+ .region("europe-west1")
486
+ .runWith({
487
+ memory: "512MB"
488
+ })
489
+ .https.onCall(async (data: CompleteMultiPartUploadData, context: functions.https.CallableContext): Promise<any> => {
490
+ if (!context.auth || (!context.auth.token.participant && !context.auth.token.coordinator))
491
+ logAndThrowError(COMMON_ERRORS.CM_NOT_AUTHENTICATED)
492
+
493
+ if (
494
+ !data.bucketName ||
495
+ !data.objectKey ||
496
+ !data.uploadId ||
497
+ !data.parts ||
498
+ (context.auth?.token.participant && !data.ceremonyId)
499
+ )
500
+ logAndThrowError(COMMON_ERRORS.CM_MISSING_OR_WRONG_INPUT_DATA)
501
+
502
+ // Prepare data.
503
+ const { bucketName, objectKey, uploadId, parts, ceremonyId } = data
504
+ const userId = context.auth?.uid
505
+
506
+ // Check if the user is a current contributor.
507
+ if (context.auth?.token.participant && !!ceremonyId) {
508
+ // Check pre-condition.
509
+ await checkPreConditionForCurrentContributorToInteractWithMultiPartUpload(userId!, ceremonyId)
510
+
511
+ // Check if the bucket is dedicated to a ceremony.
512
+ await checkIfBucketIsDedicatedToCeremony(bucketName)
513
+ }
514
+
515
+ // Connect to S3.
516
+ const S3 = await getS3Client()
517
+
518
+ // Prepare S3 command.
519
+ const command = new CompleteMultipartUploadCommand({
520
+ Bucket: bucketName,
521
+ Key: objectKey,
522
+ UploadId: uploadId,
523
+ MultipartUpload: { Parts: parts }
524
+ })
525
+
526
+ try {
527
+ // Execute S3 command.
528
+ const response = await S3.send(command)
529
+
530
+ if (response.$metadata.httpStatusCode === 200 && !!response.Location) {
531
+ printLog(
532
+ `Multi-part upload ${data.uploadId} completed. Object location: ${response.Location}`,
533
+ LogLevel.DEBUG
534
+ )
535
+
536
+ return response.Location
537
+ }
538
+ } catch (error: any) {
539
+ // eslint-disable-next-line @typescript-eslint/no-shadow
540
+ // @todo handle more errors here.
541
+ if (error.$metadata.httpStatusCode !== 200) {
542
+ const commonError = COMMON_ERRORS.CM_INVALID_REQUEST
543
+ const additionalDetails = error.toString()
544
+
545
+ logAndThrowError(makeError(commonError.code, commonError.message, additionalDetails))
546
+ }
547
+ }
548
+ })