From ef1eaba2c6b1302f3bec1b7fc3715ea6fda71412 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Fri, 10 Jan 2025 14:31:00 +0100 Subject: [PATCH] feat: proxy resend (#3) * feat: proxy resend * feat: no dev * feat: script to set token * fix: mumbo jumbo import firebase * chore: tmp * fix: module imports * feat: mail * feat: send email to resend * feat: proxy body * feat: proxy text * feat: remove unused email from --- firebase.env.sh | 5 +++ functions/.eslintrc.cjs | 9 ++++ functions/src/app.ts | 39 ++++++++++++++++ functions/src/db.ts | 86 +++++++++++++++++++++++++++++++++++ functions/src/email.ts | 32 +++++++++++++ functions/src/index.ts | 13 ++++++ functions/src/proxy.ts | 99 +++++++++++++++++++++++++++++++++++++++++ functions/src/utils.ts | 5 +++ functions/tsconfig.json | 3 +- 9 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 firebase.env.sh create mode 100644 functions/src/app.ts create mode 100644 functions/src/db.ts create mode 100644 functions/src/email.ts create mode 100644 functions/src/proxy.ts create mode 100644 functions/src/utils.ts diff --git a/firebase.env.sh b/firebase.env.sh new file mode 100644 index 0000000..5fc67e5 --- /dev/null +++ b/firebase.env.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +firebase functions:config:set notifications.token="secret" + +firebase functions:config:set mail.resend.api_key="secret" diff --git a/functions/.eslintrc.cjs b/functions/.eslintrc.cjs index 01b67a0..6823f8d 100644 --- a/functions/.eslintrc.cjs +++ b/functions/.eslintrc.cjs @@ -28,5 +28,14 @@ module.exports = { "quote-props": ["error", "as-needed"], camelcase: "off", "operator-linebreak": "off", + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], }, }; diff --git a/functions/src/app.ts b/functions/src/app.ts new file mode 100644 index 0000000..9a4e44c --- /dev/null +++ b/functions/src/app.ts @@ -0,0 +1,39 @@ +import cors from "cors"; +import express from "express"; +import * as functions from "firebase-functions"; +import {sendEmail} from "./email.js"; +import {proxy} from "./proxy.js"; + +const app = express(); +app.use(express.json()); +app.use(cors({origin: true})); + +const token: string = functions.config().notifications.token; + +const assertToken = ({ + req, + res, +}: { + req: express.Request; + res: express.Response; +}): {valid: boolean} => { + const authorization = req.get("authorization"); + + if (authorization !== `Bearer ${token}`) { + res.status(500).send("Access restricted."); + return {valid: false}; + } + + return {valid: true}; +}; + +app.post("/notifications/email", async (req, res) => { + const {valid} = assertToken({req, res}); + if (!valid) { + return; + } + + await proxy({req, res, fn: sendEmail}); +}); + +export {app}; diff --git a/functions/src/db.ts b/functions/src/db.ts new file mode 100644 index 0000000..fce4701 --- /dev/null +++ b/functions/src/db.ts @@ -0,0 +1,86 @@ +import {firestore} from "firebase-admin"; +import {getFirestore} from "firebase-admin/firestore"; +import DocumentData = firestore.DocumentData; + +interface Query { + status: "pending" | "success" | "error"; + error?: string; +} + +export const readQuery = async (key: string): Promise => { + return await read({key, collection: "query"}); +}; + +export const updateQuery = async ({ + key, + status, + error, +}: { + key: string; + status: "pending" | "success" | "error"; + error?: string; +}) => { + await getFirestore() + .collection("query") + .doc(key) + .update({status, ...(error !== undefined && {error})}); +}; + +export const readCachedResponse = async ( + key: string, +): Promise => { + return await read({key, collection: "cache"}); +}; + +const read = async ({ + key, + collection, +}: { + key: string; + collection: "cache" | "query"; +}): Promise => { + const doc = await getFirestore().collection(collection).doc(key).get(); + + if (!doc.exists) { + return undefined; + } + + return doc.data() as T; +}; + +export const initPendingQuery = async ({ + key, +}: { + key: string; +}): Promise<{success: boolean}> => { + const db = getFirestore(); + const ref = db.collection("query").doc(key); + + try { + await db.runTransaction(async (t) => { + const doc = await t.get(ref); + + if (doc.exists) { + throw new Error("Document already exists."); + } + + t.set(ref, { + status: "pending", + }); + }); + + return {success: true}; + } catch (_err: unknown) { + return {success: false}; + } +}; + +export const writeCacheResponse = async ({ + key, + data, +}: { + key: string; + data: DocumentData; +}) => { + await getFirestore().collection("cache").doc(key).set(data); +}; diff --git a/functions/src/email.ts b/functions/src/email.ts new file mode 100644 index 0000000..912e872 --- /dev/null +++ b/functions/src/email.ts @@ -0,0 +1,32 @@ +import * as express from "express"; +import {firestore} from "firebase-admin"; +import * as functions from "firebase-functions"; +import DocumentData = firestore.DocumentData; + +const resendApiKey: string = functions.config().mail.resend.api_key; + +export const sendEmail = async ({ + req: {body}, +}: { + req: express.Request; +}): Promise => { + const {from, to, subject, html, text} = body; + + const response = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${resendApiKey}`, + }, + body: JSON.stringify({from, to, subject, html, text}), + }); + + if (!response.ok) { + throw new Error( + // eslint-disable-next-line max-len + `Response not ok. Status ${response.status}. Message ${response.statusText}.`, + ); + } + + return (await response.json()) as DocumentData; +}; diff --git a/functions/src/index.ts b/functions/src/index.ts index b8f489d..dd8db08 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -1,7 +1,20 @@ +import {initializeApp} from "firebase-admin/app"; import * as functionsV1 from "firebase-functions/v1"; +import {app} from "./app.js"; import {collectStatuses} from "./statuses/statuses.js"; +initializeApp(); + +const runtimeOpts = { + timeoutSeconds: 30, +}; + export const statuses = functionsV1 .runWith({secrets: ["CRON_CONTROLLER", "MAIL_PWD"]}) .pubsub.schedule("every 15 minutes") .onRun(collectStatuses); + +export const observatory = functionsV1 + .region("europe-west6") + .runWith(runtimeOpts) + .https.onRequest(app); diff --git a/functions/src/proxy.ts b/functions/src/proxy.ts new file mode 100644 index 0000000..cb69fa8 --- /dev/null +++ b/functions/src/proxy.ts @@ -0,0 +1,99 @@ +import {isNullish, nonNullish} from "@dfinity/utils"; +import * as express from "express"; +import {firestore} from "firebase-admin"; +import { + initPendingQuery, + readCachedResponse, + readQuery, + updateQuery, + writeCacheResponse, +} from "./db.js"; +import {waitOneSecond} from "./utils.js"; +import DocumentData = firestore.DocumentData; + +type RequestParams = {req: express.Request}; + +export const proxy = async ({ + req, + res, + fn, +}: RequestParams & { + res: express.Response; + fn: (params: RequestParams) => Promise; +}) => { + const key = req.get("idempotency-key"); + + if (isNullish(key)) { + res.status(500).send( + // eslint-disable-next-line max-len + "An idempotency key is mandatory to provide same result no matter how many times it's applied.", + ); + return; + } + + const query = await readQuery(key); + + if (nonNullish(query)) { + await pollCachedResponse({key, res}); + return; + } + + const {success} = await initPendingQuery({key}); + + if (!success) { + await pollCachedResponse({key, res}); + return; + } + + try { + const data = await fn({req}); + + await Promise.all([ + writeCacheResponse({key, data}), + updateQuery({key, status: "success"}), + ]); + + res.json(data); + } catch (err: Error | unknown) { + const error = + err instanceof Error && err.message !== undefined + ? err.message + : "An unexpected error was proxying the request."; + + await updateQuery({key, status: "error", error}); + + // Note: Since the function does not always return the same error, + // the smart contract will interpret it as unable to replicate the response. + res.status(500).send(err); + } +}; + +const pollCachedResponse = async ({ + key, + res, + attempt = 1, +}: { + key: string; + res: express.Response; + attempt?: number; +}): Promise => { + const cache = await readCachedResponse(key); + + if (nonNullish(cache)) { + res.json(cache); + return; + } + + const query = await readQuery(key); + if (nonNullish(query?.error)) { + res.status(500).send("Proxying the request failed."); + return; + } + + if (attempt < 30) { + await waitOneSecond(); + return await pollCachedResponse({key, res, attempt: attempt + 1}); + } + + res.status(500).send("No cached response found after 30 seconds."); +}; diff --git a/functions/src/utils.ts b/functions/src/utils.ts new file mode 100644 index 0000000..d9d9e9e --- /dev/null +++ b/functions/src/utils.ts @@ -0,0 +1,5 @@ +export const waitOneSecond = (): Promise => { + return new Promise((resolve) => { + setTimeout(resolve, 1000); + }); +}; diff --git a/functions/tsconfig.json b/functions/tsconfig.json index 4de28cb..e1198fa 100644 --- a/functions/tsconfig.json +++ b/functions/tsconfig.json @@ -10,7 +10,8 @@ "target": "esnext", "noImplicitAny": false, "moduleResolution": "node", - "skipLibCheck": true + "skipLibCheck": true, + "allowSyntheticDefaultImports": true }, "compileOnSave": true, "include": ["src"]