diff --git a/serverless/.env.example b/serverless/.env.example index 99aa18b..c130e24 100644 --- a/serverless/.env.example +++ b/serverless/.env.example @@ -1,7 +1,5 @@ -# Environment variables declared in this file are automatically made available to Prisma. -# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema - -# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. -# See the documentation for all the connection string options: https://pris.ly/d/connection-strings - -DATABASE_URL="mongodb+srv://root:randompassword@cluster0.ab1cd.mongodb.net/mydb?retryWrites=true&w=majority" \ No newline at end of file +DATABASE_URL="mongodb+srv://root:randompassword@cluster0.ab1cd.mongodb.net/mydb?retryWrites=true&w=majority" +BUNNY_CDN_ACCESS_KEY="" +CONTRACT_ADDRESS="" +PRIVATE_KEY="" +JSON_RPC="" \ No newline at end of file diff --git a/serverless/prisma/schema.prisma b/serverless/prisma/schema.prisma index 3bd0b5d..bb301b2 100644 --- a/serverless/prisma/schema.prisma +++ b/serverless/prisma/schema.prisma @@ -26,3 +26,11 @@ model tokens { tokenId Int verified Boolean } + +model zones { + id String @id @default(auto()) @map("_id") @db.ObjectId + zoneId Int // The returned id from the creation call + name String // The assigned name at the time of creation + hostname String // The target domain that's assigned as hostname + sourceDomain String // The origin URL +} diff --git a/serverless/serverless.yaml b/serverless/serverless.yaml index aaa3af3..bc43573 100644 --- a/serverless/serverless.yaml +++ b/serverless/serverless.yaml @@ -11,6 +11,7 @@ provider: runtime: nodejs18.x stage: ${opt:stage, 'prd'} region: ${opt:region, 'us-west-2'} + timeout: 40 apiGateway: minimumCompressionSize: 1024 shouldStartNameWithService: true @@ -43,6 +44,13 @@ custom: concurrency: 10 functions: + submitAppInfo: + handler: src/functions/apps/handler.submitAppInfo + events: + - http: + path: app + method: post + cors: true submitBuildInfo: # Deployment: diff --git a/serverless/src/functions/apps/handler.ts b/serverless/src/functions/apps/handler.ts new file mode 100644 index 0000000..d9b147f --- /dev/null +++ b/serverless/src/functions/apps/handler.ts @@ -0,0 +1,107 @@ +import { APIGatewayProxyResult, APIGatewayEvent } from 'aws-lambda'; +import { formatJSONResponse } from '@libs/api-gateway'; +import * as dotenv from 'dotenv'; +import { v4 } from 'uuid'; +import { prisma } from '@libs/prisma'; +import { + BunnyCdn, + BunnyCdnError, + CreatePullZoneMethodArgs, +} from '@libs/bunnyCDN'; + +export const submitAppInfo = async ( + event: APIGatewayEvent +): Promise => { + try { + // Check the parameters and environment variables + dotenv.config(); + if (event.body === null || process.env.BUNNY_CDN_ACCESS_KEY == undefined) { + return formatJSONResponse({ + status: 422, + message: 'Required parameters were not passed.', + }); + } + + // Set up constants + const bunnyCdn = new BunnyCdn(process.env.BUNNY_CDN_ACCESS_KEY); + const data = JSON.parse(event.body); + const appInfo = { + apId: 'null', + createdAt: new Date().toISOString(), + sourceDomain: data.sourceDomain, + hostname: data.targetDomain, + }; + + let maxTries = 5; + let pullZone: { + id: any; + name?: string; + originUrl?: string; + hostname?: string; + }; + + do { + let id = v4(); + let requestArgs: CreatePullZoneMethodArgs = { + zoneId: id, // this is technically the zone name. It should be unique. + originUrl: appInfo.sourceDomain, + }; + + try { + pullZone = await bunnyCdn.createPullZone(requestArgs); + appInfo.apId = id; + } catch (error) { + maxTries -= 1; + if ( + error instanceof BunnyCdnError && + error.name === 'pullzone.name_taken' + ) { + continue; + } else if (maxTries == 0) { + throw 'Max number of tries for creating pullzone was reached.'; + } else { + throw error; + } + } + } while (maxTries > 0); + + // Create custom hostname + await bunnyCdn + .addCustomHostname({ + pullZoneId: pullZone!.id, + hostname: appInfo.hostname, + }) + .catch((e) => { + throw e; + }); + + // Add record to the database, if it's not been already added + const zoneRecord = await prisma.zones.findMany({ + where: { + zoneId: pullZone!.id, + name: appInfo.apId, + sourceDomain: appInfo.sourceDomain, + }, + }); + + if (zoneRecord.length == 0) { + await prisma.zones.create({ + data: { + zoneId: pullZone!.id, + name: appInfo.apId, + hostname: appInfo.hostname, + sourceDomain: appInfo.sourceDomain, + }, + }); + } + + return formatJSONResponse({ + appInfo, + }); + } catch (e) { + return formatJSONResponse({ + status: 500, + message: e, + }); + } +}; diff --git a/serverless/src/functions/apps/index.ts b/serverless/src/functions/apps/index.ts new file mode 100644 index 0000000..5db01f0 --- /dev/null +++ b/serverless/src/functions/apps/index.ts @@ -0,0 +1,13 @@ +import { handlerPath } from '@libs/handler-resolver'; + +export const submitAppInfo = { + handler: `${handlerPath(__dirname)}/handler.submitAppInfo`, + events: [ + { + http: { + method: 'post', + path: 'app', + }, + }, + ], +}; diff --git a/serverless/src/libs/bunnyCDN.ts b/serverless/src/libs/bunnyCDN.ts new file mode 100644 index 0000000..301a606 --- /dev/null +++ b/serverless/src/libs/bunnyCDN.ts @@ -0,0 +1,244 @@ +import axios, { AxiosRequestConfig, AxiosError } from 'axios'; + +type BunnyCdnErrorOptions = { + name: string; + message: string; +}; + +export class BunnyCdnError extends Error { + public name: string; + + constructor({ name, message }: BunnyCdnErrorOptions) { + super(message); + + this.name = name; + this.message = message; + } +} +const BUNNY_CDN_API_URL = 'https://api.bunny.net'; + +export class BunnyCdn { + constructor(private accessKey: string) {} + + private enforceHttps = (url: string) => { + if (url.startsWith('https://')) { + return url; + } + + if (url.startsWith('http://')) { + return url.replace('http://', 'https://'); + } + + return `https://${url}`; + }; + + private fetchBunny = async (endpoint: string, init: AxiosRequestConfig) => { + const headers = { + AccessKey: this.accessKey, + }; + + const response = await axios.request({ + url: `${BUNNY_CDN_API_URL}${endpoint}`, + method: 'POST', + headers, + validateStatus: (status) => status < 500, + timeout: 20_000, + ...init, + }); + const data = response.data; + + if (data.ErrorKey || data.Message) { + throw new BunnyCdnError({ name: data.ErrorKey, message: data.Message }); + } + + return data; + }; + + public async getPullZone(options: GetPullZoneMethodArgs) { + const data = (await this.fetchBunny(`/pullzone/${options.pullZoneId}`, { + method: 'GET', + })) as PullZoneData; + + return { + id: data.Id, + name: data.Name, + originUrl: data.OriginUrl, + hostnames: data.Hostnames, + }; + } + + public async createPullZone(options: CreatePullZoneMethodArgs) { + const httpsOriginUrl = this.enforceHttps(options.originUrl); + + const data = (await this.fetchBunny(`/pullzone`, { + data: { + Name: options.zoneId, + Type: 0, + OriginUrl: httpsOriginUrl, + UseStaleWhileOffline: true, + }, + })) as PullZoneData; + + const systemHostname: HostnameInterface[] = data.Hostnames.filter( + (hostname) => hostname.IsSystemHostname === true + ); + + return { + id: data.Id, + name: data.Name, + originUrl: data.OriginUrl, + hostname: systemHostname[0].Value, + }; + } + + public async updatePullZone(options: UpdatePullZoneMethodArgs) { + const httpsOriginUrl = this.enforceHttps(options.originUrl); + + await this.fetchBunny(`/pullzone/${options.pullZoneId}`, { + data: { + OriginUrl: httpsOriginUrl, + }, + }); + + return true; + } + + public async deletePullZone(options: DeletePullZoneMethodArgs) { + await this.fetchBunny(`/pullzone/${options.pullZoneId}`, { + method: 'DELETE', + }); + + return true; + } + + public async addCustomHostname(options: AddCustomHostnameMethodArgs) { + await this.fetchBunny(`/pullzone/${options.pullZoneId}/addHostname`, { + data: { + Hostname: options.hostname, + }, + }); + + return true; + } + + public async removeCustomHostname(options: AddCustomHostnameMethodArgs) { + await this.fetchBunny(`/pullzone/${options.pullZoneId}/removeHostname`, { + method: 'DELETE', + data: { + Hostname: options.hostname, + }, + }); + + return true; + } + + public async loadFreeCertificate(options: LoadFreeCertificateMethodArgs) { + await this.fetchBunny( + `/pullzone/loadFreeCertificate?hostname=${options.hostname}`, + { + method: 'GET', + } + ); + + return true; + } + + public async setForceSSL(options: SetForceSSLMethodArgs) { + await this.fetchBunny(`/pullzone/${options.pullZoneId}/setForceSSL`, { + data: { + Hostname: options.hostname, + ForceSSL: options.shouldForceSSL ?? true, + }, + }); + + return true; + } + + public async purgePullZoneCache(options: PurgePullZoneCacheMethodArgs) { + await this.fetchBunny(`/pullzone/${options.pullZoneId}/purgeCache`, {}); + + return true; + } +} + +export type ErrorData = { + ErrorKey: string; + Field: string; + Message: string; +}; + +export type GetPullZoneMethodArgs = { + pullZoneId: string; +}; + +export type CreatePullZoneMethodArgs = { + zoneId: string; + originUrl: string; +}; + +export type UpdatePullZoneMethodArgs = { + pullZoneId: string; + originUrl: string; +}; + +export type DeletePullZoneMethodArgs = { + pullZoneId: string; +}; + +export type AddCustomHostnameMethodArgs = { + pullZoneId: string; + hostname: string; +}; + +export type LoadFreeCertificateMethodArgs = { + hostname: string; +}; + +export type RemoveCustomHostnameMethodArgs = { + pullZoneId: string; + hostname: string; +}; + +export type SetForceSSLMethodArgs = { + pullZoneId: string; + hostname: string; + shouldForceSSL?: boolean; +}; + +export type PurgePullZoneCacheMethodArgs = { + pullZoneId: string; +}; + +type HostnameInterface = { + /** The unique ID of the hostname */ + Id: number; + /** The hostname value for the domain name */ + Value: string; + /** Determines if the Force SSL feature is enabled */ + ForceSSL: string; + /** Determines if this is a system hostname controlled by bunny.net */ + IsSystemHostname: boolean; + /** Determines if the hostname has an SSL certificate configured */ + HasCertificate: true; +}; + +type PullZoneData = { + Id: number; + Name: string; + OriginUrl: string; + Hostnames: HostnameInterface[]; +}; + +export type FetchPullZoneArgs = { + name: string; +}; + +export const fetchPullZone = async ({ name }: FetchPullZoneArgs) => { + const hostname = `https://${name}.b-cdn.net`; + + const response = await axios.head(hostname, { timeout: 20_000 }); + + if (response.headers['cdn-pullzone']) { + return { hostname, id: response.headers['cdn-pullzone'] }; + } +};