@devtion/backend 0.0.0-004e6ad

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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +177 -0
  3. package/dist/src/functions/index.js +2884 -0
  4. package/dist/src/functions/index.mjs +2834 -0
  5. package/dist/types/functions/bandada.d.ts +4 -0
  6. package/dist/types/functions/bandada.d.ts.map +1 -0
  7. package/dist/types/functions/ceremony.d.ts +33 -0
  8. package/dist/types/functions/ceremony.d.ts.map +1 -0
  9. package/dist/types/functions/circuit.d.ts +63 -0
  10. package/dist/types/functions/circuit.d.ts.map +1 -0
  11. package/dist/types/functions/index.d.ts +9 -0
  12. package/dist/types/functions/index.d.ts.map +1 -0
  13. package/dist/types/functions/participant.d.ts +58 -0
  14. package/dist/types/functions/participant.d.ts.map +1 -0
  15. package/dist/types/functions/siwe.d.ts +4 -0
  16. package/dist/types/functions/siwe.d.ts.map +1 -0
  17. package/dist/types/functions/storage.d.ts +37 -0
  18. package/dist/types/functions/storage.d.ts.map +1 -0
  19. package/dist/types/functions/timeout.d.ts +26 -0
  20. package/dist/types/functions/timeout.d.ts.map +1 -0
  21. package/dist/types/functions/user.d.ts +15 -0
  22. package/dist/types/functions/user.d.ts.map +1 -0
  23. package/dist/types/lib/errors.d.ts +76 -0
  24. package/dist/types/lib/errors.d.ts.map +1 -0
  25. package/dist/types/lib/services.d.ts +16 -0
  26. package/dist/types/lib/services.d.ts.map +1 -0
  27. package/dist/types/lib/utils.d.ts +141 -0
  28. package/dist/types/lib/utils.d.ts.map +1 -0
  29. package/dist/types/types/enums.d.ts +13 -0
  30. package/dist/types/types/enums.d.ts.map +1 -0
  31. package/dist/types/types/index.d.ts +186 -0
  32. package/dist/types/types/index.d.ts.map +1 -0
  33. package/package.json +90 -0
  34. package/src/functions/bandada.ts +155 -0
  35. package/src/functions/ceremony.ts +338 -0
  36. package/src/functions/circuit.ts +1044 -0
  37. package/src/functions/index.ts +38 -0
  38. package/src/functions/participant.ts +526 -0
  39. package/src/functions/siwe.ts +77 -0
  40. package/src/functions/storage.ts +551 -0
  41. package/src/functions/timeout.ts +296 -0
  42. package/src/functions/user.ts +167 -0
  43. package/src/lib/errors.ts +242 -0
  44. package/src/lib/services.ts +64 -0
  45. package/src/lib/utils.ts +474 -0
  46. package/src/types/declarations.d.ts +1 -0
  47. package/src/types/enums.ts +12 -0
  48. package/src/types/index.ts +200 -0
  49. package/test/index.test.ts +62 -0
@@ -0,0 +1,551 @@
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 "@devtion/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: "1GB"
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", "PUT"],
197
+ AllowedOrigins: ["*"],
198
+ ExposeHeaders: ["ETag", "Content-Length"],
199
+ AllowedHeaders: ["*"]
200
+ }
201
+ ]
202
+ }
203
+ })
204
+ const corsResponse = await S3.send(corsCommand)
205
+ // Check response.
206
+ if (corsResponse.$metadata.httpStatusCode === 200)
207
+ printLog(
208
+ `The AWS S3 bucket ${data.bucketName} has been set with the CORS configuration.`,
209
+ LogLevel.LOG
210
+ )
211
+ } catch (error: any) {
212
+ // eslint-disable-next-line @typescript-eslint/no-shadow
213
+ /** * {@link https://docs.aws.amazon.com/simspaceweaver/latest/userguide/troubeshooting_too-many-buckets.html | TooManyBuckets} */
214
+ if (error.$metadata.httpStatusCode === 400 && error.Code === `TooManyBuckets`)
215
+ logAndThrowError(SPECIFIC_ERRORS.SE_STORAGE_TOO_MANY_BUCKETS)
216
+
217
+ // @todo handle more errors here.
218
+
219
+ const commonError = COMMON_ERRORS.CM_INVALID_REQUEST
220
+ const additionalDetails = error.toString()
221
+
222
+ logAndThrowError(makeError(commonError.code, commonError.message, additionalDetails))
223
+ }
224
+ } else {
225
+ // If there was a different error, re-throw it.
226
+ const commonError = COMMON_ERRORS.CM_INVALID_REQUEST
227
+ const additionalDetails = error.toString()
228
+
229
+ logAndThrowError(makeError(commonError.code, commonError.message, additionalDetails))
230
+ }
231
+ }
232
+ })
233
+
234
+ /**
235
+ * Check if a specified object exist in a given AWS S3 bucket.
236
+ * @returns <Promise<boolean>> - true if the object exist in the given bucket; otherwise false.
237
+ */
238
+ export const checkIfObjectExist = functions
239
+ .region("europe-west1")
240
+ .runWith({
241
+ memory: "1GB"
242
+ })
243
+ .https.onCall(async (data: BucketAndObjectKeyData, context: functions.https.CallableContext): Promise<boolean> => {
244
+ // Check if the user has the coordinator claim.
245
+ if (!context.auth || !context.auth.token.coordinator) logAndThrowError(COMMON_ERRORS.CM_NOT_COORDINATOR_ROLE)
246
+
247
+ if (!data.bucketName || !data.objectKey) logAndThrowError(COMMON_ERRORS.CM_MISSING_OR_WRONG_INPUT_DATA)
248
+
249
+ // Connect to S3 client.
250
+ const S3 = await getS3Client()
251
+
252
+ // Prepare S3 command.
253
+ const command = new HeadObjectCommand({ Bucket: data.bucketName, Key: data.objectKey })
254
+
255
+ try {
256
+ // Execute S3 command.
257
+ const response = await S3.send(command)
258
+
259
+ // Check response.
260
+ if (response.$metadata.httpStatusCode === 200 && !!response.ETag) {
261
+ printLog(
262
+ `The object associated w/ ${data.objectKey} key has been found in the ${data.bucketName} bucket`,
263
+ LogLevel.LOG
264
+ )
265
+
266
+ return true
267
+ }
268
+ } catch (error: any) {
269
+ // eslint-disable-next-line @typescript-eslint/no-shadow
270
+ if (error.$metadata.httpStatusCode === 403) logAndThrowError(SPECIFIC_ERRORS.SE_STORAGE_MISSING_PERMISSIONS)
271
+
272
+ // @todo handle more specific errors here.
273
+
274
+ // nb. do not handle common errors! This method must return false if not found!
275
+ // const commonError = COMMON_ERRORS.CM_INVALID_REQUEST
276
+ // const additionalDetails = error.toString()
277
+
278
+ // logAndThrowError(makeError(
279
+ // commonError.code,
280
+ // commonError.message,
281
+ // additionalDetails
282
+ // ))
283
+ }
284
+
285
+ return false
286
+ })
287
+
288
+ /**
289
+ * Return a pre-signed url for a given object contained inside the provided AWS S3 bucket in order to perform a GET request.
290
+ * @notice the pre-signed url has a predefined expiration expressed in seconds inside the environment
291
+ * configuration of the `backend` package. The value should match the configuration of `phase2cli` package
292
+ * environment to avoid inconsistency between client request and CF.
293
+ */
294
+ export const generateGetObjectPreSignedUrl = functions
295
+ .region("europe-west1")
296
+ .runWith({
297
+ memory: "1GB"
298
+ })
299
+ .https.onCall(async (data: BucketAndObjectKeyData, context: functions.https.CallableContext): Promise<any> => {
300
+ if (!context.auth) logAndThrowError(COMMON_ERRORS.CM_NOT_AUTHENTICATED)
301
+
302
+ if (!data.bucketName || !data.objectKey) logAndThrowError(COMMON_ERRORS.CM_MISSING_OR_WRONG_INPUT_DATA)
303
+
304
+ // Prepare input data.
305
+ const { objectKey, bucketName } = data
306
+
307
+ // Check whether the bucket for which we are generating the pre-signed url is dedicated to a ceremony.
308
+ await checkIfBucketIsDedicatedToCeremony(bucketName)
309
+
310
+ // Connect to S3 client.
311
+ const S3 = await getS3Client()
312
+
313
+ // Prepare S3 command.
314
+ const command = new GetObjectCommand({ Bucket: bucketName, Key: objectKey })
315
+
316
+ try {
317
+ // Execute S3 command.
318
+ const url = await getSignedUrl(S3, command, { expiresIn: Number(process.env.AWS_PRESIGNED_URL_EXPIRATION) })
319
+
320
+ if (url) {
321
+ printLog(`The generated pre-signed url is ${url}`, LogLevel.DEBUG)
322
+
323
+ return url
324
+ }
325
+ } catch (error: any) {
326
+ // eslint-disable-next-line @typescript-eslint/no-shadow
327
+ // @todo handle more errors here.
328
+ // if (error.$metadata.httpStatusCode !== 200) {
329
+ const commonError = COMMON_ERRORS.CM_INVALID_REQUEST
330
+ const additionalDetails = error.toString()
331
+
332
+ logAndThrowError(makeError(commonError.code, commonError.message, additionalDetails))
333
+ // }
334
+ }
335
+ })
336
+
337
+ /**
338
+ * Start a new multi-part upload for a specific object in the given AWS S3 bucket.
339
+ * @notice this operation can be performed by either an authenticated participant or a coordinator.
340
+ */
341
+ export const startMultiPartUpload = functions
342
+ .region("europe-west1")
343
+ .runWith({
344
+ memory: "2GB"
345
+ })
346
+ .https.onCall(async (data: StartMultiPartUploadData, context: functions.https.CallableContext): Promise<any> => {
347
+ if (!context.auth || (!context.auth.token.participant && !context.auth.token.coordinator))
348
+ logAndThrowError(COMMON_ERRORS.CM_NOT_AUTHENTICATED)
349
+
350
+ if (!data.bucketName || !data.objectKey || (context.auth?.token.participant && !data.ceremonyId))
351
+ logAndThrowError(COMMON_ERRORS.CM_MISSING_OR_WRONG_INPUT_DATA)
352
+
353
+ // Prepare data.
354
+ const { bucketName, objectKey, ceremonyId } = data
355
+ const userId = context.auth?.uid
356
+
357
+ // Check if the user is a current contributor.
358
+ if (context.auth?.token.participant && !!ceremonyId) {
359
+ // Check pre-condition.
360
+ await checkPreConditionForCurrentContributorToInteractWithMultiPartUpload(userId!, ceremonyId)
361
+
362
+ // Check whether the bucket where the object for which we are generating the pre-signed url is dedicated to a ceremony.
363
+ await checkIfBucketIsDedicatedToCeremony(bucketName)
364
+
365
+ // Check the validity of the uploaded file.
366
+ await checkUploadingFileValidity(userId!, ceremonyId!, objectKey)
367
+ }
368
+
369
+ // Connect to S3 client.
370
+ const S3 = await getS3Client()
371
+
372
+ // Prepare S3 command.
373
+ const command = new CreateMultipartUploadCommand({
374
+ Bucket: bucketName,
375
+ Key: objectKey,
376
+ ACL: context.auth?.token.participant ? "private" : "public-read"
377
+ })
378
+
379
+ try {
380
+ // Execute S3 command.
381
+ const response = await S3.send(command)
382
+ if (response.$metadata.httpStatusCode === 200 && !!response.UploadId) {
383
+ printLog(
384
+ `The multi-part upload identifier is ${response.UploadId}. Requested by ${userId}`,
385
+ LogLevel.DEBUG
386
+ )
387
+
388
+ return response.UploadId
389
+ }
390
+ } catch (error: any) {
391
+ // eslint-disable-next-line @typescript-eslint/no-shadow
392
+ // @todo handle more errors here.
393
+ if (error.$metadata.httpStatusCode !== 200) {
394
+ const commonError = COMMON_ERRORS.CM_INVALID_REQUEST
395
+ const additionalDetails = error.toString()
396
+
397
+ logAndThrowError(makeError(commonError.code, commonError.message, additionalDetails))
398
+ }
399
+ }
400
+ })
401
+
402
+ /**
403
+ * Generate a new pre-signed url for each chunk related to a started multi-part upload.
404
+ * @notice this operation can be performed by either an authenticated participant or a coordinator.
405
+ * the pre-signed url has a predefined expiration expressed in seconds inside the environment
406
+ * configuration of the `backend` package. The value should match the configuration of `phase2cli` package
407
+ * environment to avoid inconsistency between client request and CF.
408
+ */
409
+ export const generatePreSignedUrlsParts = functions
410
+ .region("europe-west1")
411
+ .runWith({
412
+ memory: "1GB",
413
+ timeoutSeconds: 300
414
+ })
415
+ .https.onCall(
416
+ async (
417
+ data: GeneratePreSignedUrlsPartsData,
418
+ context: functions.https.CallableContext
419
+ ): Promise<Array<string>> => {
420
+ if (!context.auth || (!context.auth.token.participant && !context.auth.token.coordinator))
421
+ logAndThrowError(COMMON_ERRORS.CM_NOT_AUTHENTICATED)
422
+
423
+ if (
424
+ !data.bucketName ||
425
+ !data.objectKey ||
426
+ !data.uploadId ||
427
+ data.numberOfParts <= 0 ||
428
+ (context.auth?.token.participant && !data.ceremonyId)
429
+ )
430
+ logAndThrowError(COMMON_ERRORS.CM_MISSING_OR_WRONG_INPUT_DATA)
431
+
432
+ // Prepare data.
433
+ const { bucketName, objectKey, uploadId, numberOfParts, ceremonyId } = data
434
+ const userId = context.auth?.uid
435
+
436
+ // Check if the user is a current contributor.
437
+ if (context.auth?.token.participant && !!ceremonyId) {
438
+ // Check pre-condition.
439
+ await checkPreConditionForCurrentContributorToInteractWithMultiPartUpload(userId!, ceremonyId)
440
+ }
441
+
442
+ // Connect to S3 client.
443
+ const S3 = await getS3Client()
444
+
445
+ // Prepare state.
446
+ const parts = []
447
+
448
+ for (let i = 0; i < numberOfParts; i += 1) {
449
+ // Prepare S3 command for each chunk.
450
+ const command = new UploadPartCommand({
451
+ Bucket: bucketName,
452
+ Key: objectKey,
453
+ PartNumber: i + 1,
454
+ UploadId: uploadId
455
+ })
456
+
457
+ try {
458
+ // Get the pre-signed url for the specific chunk.
459
+ const url = await getSignedUrl(S3, command, {
460
+ expiresIn: Number(process.env.AWS_PRESIGNED_URL_EXPIRATION)
461
+ })
462
+
463
+ if (url) {
464
+ // Save.
465
+ parts.push(url)
466
+ }
467
+ } catch (error: any) {
468
+ // eslint-disable-next-line @typescript-eslint/no-shadow
469
+ // @todo handle more errors here.
470
+ // if (error.$metadata.httpStatusCode !== 200) {
471
+ const commonError = COMMON_ERRORS.CM_INVALID_REQUEST
472
+ const additionalDetails = error.toString()
473
+
474
+ logAndThrowError(makeError(commonError.code, commonError.message, additionalDetails))
475
+ // }
476
+ }
477
+ }
478
+
479
+ return parts
480
+ }
481
+ )
482
+
483
+ /**
484
+ * Complete a multi-part upload for a specific object in the given AWS S3 bucket.
485
+ * @notice this operation can be performed by either an authenticated participant or a coordinator.
486
+ */
487
+ export const completeMultiPartUpload = functions
488
+ .region("europe-west1")
489
+ .runWith({
490
+ memory: "2GB"
491
+ })
492
+ .https.onCall(async (data: CompleteMultiPartUploadData, context: functions.https.CallableContext): Promise<any> => {
493
+ if (!context.auth || (!context.auth.token.participant && !context.auth.token.coordinator))
494
+ logAndThrowError(COMMON_ERRORS.CM_NOT_AUTHENTICATED)
495
+
496
+ if (
497
+ !data.bucketName ||
498
+ !data.objectKey ||
499
+ !data.uploadId ||
500
+ !data.parts ||
501
+ (context.auth?.token.participant && !data.ceremonyId)
502
+ )
503
+ logAndThrowError(COMMON_ERRORS.CM_MISSING_OR_WRONG_INPUT_DATA)
504
+
505
+ // Prepare data.
506
+ const { bucketName, objectKey, uploadId, parts, ceremonyId } = data
507
+ const userId = context.auth?.uid
508
+
509
+ // Check if the user is a current contributor.
510
+ if (context.auth?.token.participant && !!ceremonyId) {
511
+ // Check pre-condition.
512
+ await checkPreConditionForCurrentContributorToInteractWithMultiPartUpload(userId!, ceremonyId)
513
+
514
+ // Check if the bucket is dedicated to a ceremony.
515
+ await checkIfBucketIsDedicatedToCeremony(bucketName)
516
+ }
517
+
518
+ // Connect to S3.
519
+ const S3 = await getS3Client()
520
+
521
+ // Prepare S3 command.
522
+ const command = new CompleteMultipartUploadCommand({
523
+ Bucket: bucketName,
524
+ Key: objectKey,
525
+ UploadId: uploadId,
526
+ MultipartUpload: { Parts: parts }
527
+ })
528
+
529
+ try {
530
+ // Execute S3 command.
531
+ const response = await S3.send(command)
532
+
533
+ if (response.$metadata.httpStatusCode === 200 && !!response.Location) {
534
+ printLog(
535
+ `Multi-part upload ${data.uploadId} completed. Object location: ${response.Location}`,
536
+ LogLevel.DEBUG
537
+ )
538
+
539
+ return response.Location
540
+ }
541
+ } catch (error: any) {
542
+ // eslint-disable-next-line @typescript-eslint/no-shadow
543
+ // @todo handle more errors here.
544
+ if (error.$metadata.httpStatusCode !== 200) {
545
+ const commonError = COMMON_ERRORS.CM_INVALID_REQUEST
546
+ const additionalDetails = error.toString()
547
+
548
+ logAndThrowError(makeError(commonError.code, commonError.message, additionalDetails))
549
+ }
550
+ }
551
+ })