Skip to content

Commit

Permalink
feat: proxy resend (#3)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
peterpeterparker authored Jan 10, 2025
1 parent d8e5b15 commit ef1eaba
Show file tree
Hide file tree
Showing 9 changed files with 290 additions and 1 deletion.
5 changes: 5 additions & 0 deletions firebase.env.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/bin/sh

firebase functions:config:set notifications.token="secret"

firebase functions:config:set mail.resend.api_key="secret"
9 changes: 9 additions & 0 deletions functions/.eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: "^_",
},
],
},
};
39 changes: 39 additions & 0 deletions functions/src/app.ts
Original file line number Diff line number Diff line change
@@ -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};
86 changes: 86 additions & 0 deletions functions/src/db.ts
Original file line number Diff line number Diff line change
@@ -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<Query | undefined> => {
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<unknown | undefined> => {
return await read({key, collection: "cache"});
};

const read = async <T>({
key,
collection,
}: {
key: string;
collection: "cache" | "query";
}): Promise<T | undefined> => {
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);
};
32 changes: 32 additions & 0 deletions functions/src/email.ts
Original file line number Diff line number Diff line change
@@ -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<DocumentData> => {
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;
};
13 changes: 13 additions & 0 deletions functions/src/index.ts
Original file line number Diff line number Diff line change
@@ -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);
99 changes: 99 additions & 0 deletions functions/src/proxy.ts
Original file line number Diff line number Diff line change
@@ -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<DocumentData>;
}) => {
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<void> => {
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.");
};
5 changes: 5 additions & 0 deletions functions/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const waitOneSecond = (): Promise<void> => {
return new Promise<void>((resolve) => {
setTimeout(resolve, 1000);
});
};
3 changes: 2 additions & 1 deletion functions/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"target": "esnext",
"noImplicitAny": false,
"moduleResolution": "node",
"skipLibCheck": true
"skipLibCheck": true,
"allowSyntheticDefaultImports": true
},
"compileOnSave": true,
"include": ["src"]
Expand Down

0 comments on commit ef1eaba

Please sign in to comment.