@stagtion/backend
Version:
MPC Phase 2 backend for Firebase services management
475 lines (407 loc) • 17 kB
text/typescript
import {
DocumentData,
QuerySnapshot,
DocumentSnapshot,
QueryDocumentSnapshot,
Timestamp,
WhereFilterOp
} from "firebase-admin/firestore"
import admin from "firebase-admin"
import dotenv from "dotenv"
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
import { createWriteStream } from "node:fs"
import { pipeline } from "node:stream"
import { promisify } from "node:util"
import { readFileSync } from "fs"
import mime from "mime-types"
import { encode } from "html-entities"
import { setTimeout } from "timers/promises"
import {
commonTerms,
getCircuitsCollectionPath,
getContributionsCollectionPath,
getTimeoutsCollectionPath,
CeremonyState,
finalContributionIndex,
CircuitDocument
} from "@stagtion/actions"
import fetch from "@adobe/node-fetch-retry"
import path from "path"
import os from "os"
import { SSMClient } from "@aws-sdk/client-ssm"
import { EC2Client } from "@aws-sdk/client-ec2"
import { COMMON_ERRORS, logAndThrowError, SPECIFIC_ERRORS } from "./errors"
import { getS3Client } from "./services"
dotenv.config()
/**
* Get a specific document from database.
* @dev this method differs from the one in the `actions` package because we need to use
* the admin SDK here; therefore the Firestore instances are not interchangeable between admin
* and user instance.
* @param collection <string> - the name of the collection.
* @param documentId <string> - the unique identifier of the document in the collection.
* @returns <Promise<DocumentSnapshot<DocumentData>>> - the requested document w/ relative data.
*/
export const getDocumentById = async (
collection: string,
documentId: string
): Promise<DocumentSnapshot<DocumentData>> => {
// Prepare Firestore db instance.
const firestore = admin.firestore()
// Get document.
const doc = await firestore.collection(collection).doc(documentId).get()
// Return only if doc exists; otherwise throw error.
return doc.exists ? doc : logAndThrowError(COMMON_ERRORS.CM_INEXISTENT_DOCUMENT)
}
/**
* Get the current server timestamp.
* @dev the value is in milliseconds.
* @returns <number> - the timestamp of the server (ms).
*/
export const getCurrentServerTimestampInMillis = (): number => Timestamp.now().toMillis()
/**
* Interrupt the current execution for a specified amount of time.
* @param ms <number> - the amount of time expressed in milliseconds.
*/
export const sleep = async (ms: number): Promise<void> => setTimeout(ms)
/**
* Query for ceremony circuits.
* @notice the order by sequence position is fundamental to maintain parallelism among contributions for different circuits.
* @param ceremonyId <string> - the unique identifier of the ceremony.
* @returns Promise<Array<FirebaseDocumentInfo>> - the ceremony' circuits documents ordered by sequence position.
*/
export const getCeremonyCircuits = async (ceremonyId: string): Promise<Array<QueryDocumentSnapshot<DocumentData>>> => {
// Prepare Firestore db instance.
const firestore = admin.firestore()
// Execute query.
const querySnap = await firestore.collection(getCircuitsCollectionPath(ceremonyId)).get()
if (!querySnap.docs) logAndThrowError(SPECIFIC_ERRORS.SE_CONTRIBUTE_NO_CEREMONY_CIRCUITS)
return querySnap.docs.sort(
(a: DocumentData, b: DocumentData) => a.data().sequencePosition - b.data().sequencePosition
)
}
/**
* Query for ceremony circuit contributions.
* @param ceremonyId <string> - the unique identifier of the ceremony.
* @param circuitId <string> - the unique identifier of the circuitId.
* @returns Promise<Array<FirebaseDocumentInfo>> - the contributions of the ceremony circuit.
*/
export const getCeremonyCircuitContributions = async (
ceremonyId: string,
circuitId: string
): Promise<Array<QueryDocumentSnapshot<DocumentData>>> => {
// Prepare Firestore db instance.
const firestore = admin.firestore()
// Execute query.
const querySnap = await firestore.collection(getContributionsCollectionPath(ceremonyId, circuitId)).get()
if (!querySnap.docs) logAndThrowError(SPECIFIC_ERRORS.SE_FINALIZE_NO_CEREMONY_CONTRIBUTIONS)
return querySnap.docs
}
/**
* Query not expired timeouts.
* @notice a timeout is considered valid (aka not expired) if and only if the timeout end date
* value is less than current timestamp.
* @param ceremonyId <string> - the unique identifier of the ceremony.
* @param participantId <string> - the unique identifier of the participant.
* @returns <Promise<QuerySnapshot<DocumentData>>>
*/
export const queryNotExpiredTimeouts = async (
ceremonyId: string,
participantId: string
): Promise<QuerySnapshot<DocumentData>> => {
// Prepare Firestore db.
const firestoreDb = admin.firestore()
// Execute and return query result.
return firestoreDb
.collection(getTimeoutsCollectionPath(ceremonyId, participantId))
.where(commonTerms.collections.timeouts.fields.endDate, ">=", getCurrentServerTimestampInMillis())
.get()
}
/**
* Query for opened ceremonies.
* @param firestoreDatabase <Firestore> - the Firestore service instance associated to the current Firebase application.
* @returns <Promise<Array<FirebaseDocumentInfo>>>
*/
export const queryOpenedCeremonies = async (): Promise<Array<QueryDocumentSnapshot<DocumentData>>> => {
const querySnap = await admin
.firestore()
.collection(commonTerms.collections.ceremonies.name)
.where(commonTerms.collections.ceremonies.fields.state, "==", CeremonyState.OPENED)
.where(commonTerms.collections.ceremonies.fields.endDate, ">=", getCurrentServerTimestampInMillis())
.get()
if (!querySnap.docs) logAndThrowError(SPECIFIC_ERRORS.SE_CONTRIBUTE_NO_OPENED_CEREMONIES)
return querySnap.docs
}
/**
* Get ceremony circuit document by sequence position.
* @param ceremonyId <string> - the unique identifier of the ceremony.
* @param sequencePosition <number> - the sequence position of the circuit.
* @returns Promise<QueryDocumentSnapshot<DocumentData>>
*/
export const getCircuitDocumentByPosition = async (
ceremonyId: string,
sequencePosition: number
): Promise<QueryDocumentSnapshot<DocumentData>> => {
// Query for all ceremony circuits.
const circuits = await getCeremonyCircuits(ceremonyId)
// Apply a filter using the sequence position.
const matchedCircuits = circuits.filter(
(circuit: DocumentData) => circuit.data().sequencePosition === sequencePosition
)
if (matchedCircuits.length !== 1) logAndThrowError(COMMON_ERRORS.CM_NO_CIRCUIT_FOR_GIVEN_SEQUENCE_POSITION)
return matchedCircuits.at(0)!
}
/**
* Create a temporary file path in the virtual memory of the cloud function.
* @dev useful when downloading files from AWS S3 buckets for processing within cloud functions.
* @param completeFilename <string> - the complete file name (name + ext).
* @returns <string> - the path to the local temporary location.
*/
export const createTemporaryLocalPath = (completeFilename: string): string => path.join(os.tmpdir(), completeFilename)
/**
* Download an artifact from the AWS S3 bucket.
* @dev this method uses streams.
* @param bucketName <string> - the name of the bucket.
* @param objectKey <string> - the unique key to identify the object inside the given AWS S3 bucket.
* @param localFilePath <string> - the local path where the file will be stored.
*/
export const downloadArtifactFromS3Bucket = async (bucketName: string, objectKey: string, localFilePath: string) => {
// Prepare AWS S3 client instance.
const client = await getS3Client()
// Prepare command.
const command = new GetObjectCommand({ Bucket: bucketName, Key: objectKey })
// Generate a pre-signed url for downloading the file.
const url = await getSignedUrl(client, command, { expiresIn: Number(process.env.AWS_PRESIGNED_URL_EXPIRATION) })
// Execute download request.
// @ts-ignore
const response: any = await fetch(url, {
method: "GET",
headers: {
"Access-Control-Allow-Origin": "*"
}
})
if (response.status !== 200 || !response.ok) logAndThrowError(SPECIFIC_ERRORS.SE_STORAGE_DOWNLOAD_FAILED)
// Write the file locally using streams.
const writeStream = createWriteStream(localFilePath)
const streamPipeline = promisify(pipeline)
await streamPipeline(response.body, writeStream)
writeStream.on("finish", () => {
writeStream.end()
})
}
/**
* Upload a new artifact to the AWS S3 bucket.
* @dev this method uses streams.
* @param bucketName <string> - the name of the bucket.
* @param objectKey <string> - the unique key to identify the object inside the given AWS S3 bucket.
* @param localFilePath <string> - the local path where the file to be uploaded is stored.
*/
export const uploadFileToBucket = async (
bucketName: string,
objectKey: string,
localFilePath: string,
isPublic: boolean = false
) => {
// Prepare AWS S3 client instance.
const client = await getS3Client()
// Extract content type.
const contentType = mime.lookup(localFilePath) || ""
// Prepare command.
const command = new PutObjectCommand({
Bucket: bucketName,
Key: objectKey,
ContentType: contentType,
ACL: isPublic ? "public-read" : "private"
})
// Generate a pre-signed url for uploading the file.
const url = await getSignedUrl(client, command, { expiresIn: Number(process.env.AWS_PRESIGNED_URL_EXPIRATION) })
// Execute upload request.
// @ts-ignore
const response = await fetch(url, {
method: "PUT",
body: readFileSync(localFilePath),
headers: { "Content-Type": contentType }
})
if (response.status !== 200 || !response.ok) logAndThrowError(SPECIFIC_ERRORS.SE_STORAGE_UPLOAD_FAILED)
}
export const uploadFileToBucketNoFile = async (
bucketName: string,
objectKey: string,
data: string,
isPublic: boolean = false
) => {
// Prepare AWS S3 client instance.
const client = await getS3Client()
// Prepare command.
const command = new PutObjectCommand({
Bucket: bucketName,
Key: objectKey,
ContentType: "text/plain",
ACL: isPublic ? "public-read" : "private"
})
// Generate a pre-signed url for uploading the file.
const url = await getSignedUrl(client, command, { expiresIn: Number(process.env.AWS_PRESIGNED_URL_EXPIRATION) })
// Execute upload request.
// @ts-ignore
const response = await fetch(url, {
method: "PUT",
body: data,
headers: { "Content-Type": "text/plain" }
})
if (response.status !== 200 || !response.ok) logAndThrowError(SPECIFIC_ERRORS.SE_STORAGE_UPLOAD_FAILED)
}
/**
* Upload an artifact from the AWS S3 bucket.
* @param bucketName <string> - the name of the bucket.
* @param objectKey <string> - the unique key to identify the object inside the given AWS S3 bucket.
*/
export const deleteObject = async (bucketName: string, objectKey: string) => {
// Prepare AWS S3 client instance.
const client = await getS3Client()
// Prepare command.
const command = new DeleteObjectCommand({ Bucket: bucketName, Key: objectKey })
// Execute command.
const data = await client.send(command)
if (data.$metadata.httpStatusCode !== 204) logAndThrowError(SPECIFIC_ERRORS.SE_STORAGE_DELETE_FAILED)
}
/**
* Query ceremonies by state and (start/end) date value.
* @param state <string> - the state of the ceremony.
* @param needToCheckStartDate <boolean> - flag to discriminate when to check startDate (true) or endDate (false).
* @param check <WhereFilerOp> - the type of filter (query check - e.g., '<' or '>').
* @returns <Promise<admin.firestore.QuerySnapshot<admin.firestore.DocumentData>>> - the queried ceremonies after filtering operation.
*/
export const queryCeremoniesByStateAndDate = async (
state: string,
needToCheckStartDate: boolean,
check: WhereFilterOp
): Promise<admin.firestore.QuerySnapshot<admin.firestore.DocumentData>> =>
admin
.firestore()
.collection(commonTerms.collections.ceremonies.name)
.where(commonTerms.collections.ceremonies.fields.state, "==", state)
.where(
needToCheckStartDate
? commonTerms.collections.ceremonies.fields.startDate
: commonTerms.collections.ceremonies.fields.endDate,
check,
getCurrentServerTimestampInMillis()
)
.get()
/**
* Return the document associated with the final contribution for a ceremony circuit.
* @dev this method is useful during ceremony finalization.
* @param ceremonyId <string> -
* @param circuitId <string> -
* @returns Promise<QueryDocumentSnapshot<DocumentData>> - the final contribution for the ceremony circuit.
*/
export const getFinalContribution = async (
ceremonyId: string,
circuitId: string
): Promise<QueryDocumentSnapshot<DocumentData>> => {
// Get contributions for the circuit.
const contributions = await getCeremonyCircuitContributions(ceremonyId, circuitId)
// Match the final one.
const matchContribution = contributions.filter(
(contribution: DocumentData) => contribution.data().zkeyIndex === finalContributionIndex
)
if (!matchContribution) logAndThrowError(SPECIFIC_ERRORS.SE_FINALIZE_NO_FINAL_CONTRIBUTION)
// Get the final contribution.
// nb. there must be only one final contributions x circuit.
const finalContribution = matchContribution.at(0)!
return finalContribution
}
/**
* Helper function to HTML encode circuit data.
* @param circuitDocument <CircuitDocument> - the circuit document to be encoded.
* @returns <CircuitDocument> - the circuit document encoded.
*/
export const htmlEncodeCircuitData = (circuitDocument: CircuitDocument): CircuitDocument => ({
...circuitDocument,
description: encode(circuitDocument.description),
name: encode(circuitDocument.name),
prefix: encode(circuitDocument.prefix)
})
/**
* Fetch the variables related to GitHub anti-sybil checks
* @returns <any> - the GitHub variables.
*/
export const getGitHubVariables = (): any => {
if (
!process.env.GITHUB_MINIMUM_FOLLOWERS ||
!process.env.GITHUB_MINIMUM_FOLLOWING ||
!process.env.GITHUB_MINIMUM_PUBLIC_REPOS ||
!process.env.GITHUB_MINIMUM_AGE
)
logAndThrowError(COMMON_ERRORS.CM_WRONG_CONFIGURATION)
return {
minimumFollowers: Number(process.env.GITHUB_MINIMUM_FOLLOWERS),
minimumFollowing: Number(process.env.GITHUB_MINIMUM_FOLLOWING),
minimumPublicRepos: Number(process.env.GITHUB_MINIMUM_PUBLIC_REPOS),
minimumAge: Number(process.env.GITHUB_MINIMUM_AGE)
}
}
/**
* Fetch the variables related to EC2 verification
* @returns <any> - the AWS EC2 variables.
*/
export const getAWSVariables = (): any => {
if (
!process.env.AWS_ACCESS_KEY_ID ||
!process.env.AWS_SECRET_ACCESS_KEY ||
!process.env.AWS_INSTANCE_PROFILE_ARN ||
!process.env.AWS_AMI_ID ||
!process.env.AWS_SNS_TOPIC_ARN
)
logAndThrowError(COMMON_ERRORS.CM_WRONG_CONFIGURATION)
return {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
region: process.env.AWS_REGION || "eu-central-1",
instanceProfileArn: process.env.AWS_INSTANCE_PROFILE_ARN!,
amiId: process.env.AWS_AMI_ID!,
snsTopic: process.env.AWS_SNS_TOPIC_ARN!
}
}
/**
* Create an EC2 client object
* @returns <Promise<EC2Client>> an EC2 client
*/
export const createEC2Client = async (): Promise<EC2Client> => {
const { accessKeyId, secretAccessKey, region } = getAWSVariables()
const ec2: EC2Client = new EC2Client({
credentials: {
accessKeyId,
secretAccessKey
},
region
})
return ec2
}
/**
* Create an SSM client object
* @returns <Promise<SSMClient>> an SSM client
*/
export const createSSMClient = async (): Promise<SSMClient> => {
const { accessKeyId, secretAccessKey, region } = getAWSVariables()
const ssm: SSMClient = new SSMClient({
credentials: {
accessKeyId,
secretAccessKey
},
region
})
return ssm
}
/**
* Get the instance id of the EC2 instance associated with the circuit
* @param circuitId <string> - the circuit id
* @returns <Promise<string>> - the EC2 instance id
*/
export const getEC2InstanceId = async (circuitId: string): Promise<string> => {
const circuitDoc = await getDocumentById(commonTerms.collections.circuits.name, circuitId)
const circuitData = circuitDoc.data()
const { vmInstanceId } = circuitData!
return vmInstanceId
}