Improve self host support (#28)

* Add docker setup for self hosting

* Add ses settings tables
This commit is contained in:
KM Koushik
2024-06-10 17:40:42 +10:00
committed by GitHub
parent 6128f26a78
commit 18b523912d
24 changed files with 708 additions and 169 deletions

View File

@@ -0,0 +1,37 @@
import { z } from "zod";
import { env } from "~/env";
import { createTRPCRouter, adminProcedure } from "~/server/api/trpc";
import { SesSettingsService } from "~/server/service/ses-settings-service";
export const adminRouter = createTRPCRouter({
getSesSettings: adminProcedure.query(async () => {
return SesSettingsService.getAllSettings();
}),
addSesSettings: adminProcedure
.input(
z.object({
region: z.string(),
unsendUrl: z.string().url(),
})
)
.mutation(async ({ input }) => {
return SesSettingsService.createSesSetting({
region: input.region,
unsendUrl: input.unsendUrl,
});
}),
getSetting: adminProcedure
.input(
z.object({
region: z.string().optional().nullable(),
})
)
.query(async ({ input }) => {
return SesSettingsService.getSetting(
input.region ?? env.AWS_DEFAULT_REGION
);
}),
});

View File

@@ -10,6 +10,7 @@
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { ZodError } from "zod";
import { env } from "~/env";
import { getServerAuthSession } from "~/server/auth";
import { db } from "~/server/db";
@@ -123,3 +124,13 @@ export const teamProcedure = protectedProcedure.use(async ({ ctx, next }) => {
},
});
});
/**
* To manage application settings, for hosted version, authenticated users will be considered as admin
*/
export const adminProcedure = protectedProcedure.use(async ({ ctx, next }) => {
if (env.NEXT_PUBLIC_IS_CLOUD && ctx.session.user.email !== env.ADMIN_EMAIL) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next();
});

View File

@@ -1,5 +1,6 @@
import { PrismaAdapter } from "@auth/prisma-adapter";
import {
AuthOptions,
getServerSession,
type DefaultSession,
type NextAuthOptions,
@@ -36,6 +37,42 @@ declare module "next-auth" {
}
}
/**
* Auth providers
*/
const providers: Provider[] = [
GitHubProvider({
clientId: env.GITHUB_ID,
clientSecret: env.GITHUB_SECRET,
allowDangerousEmailAccountLinking: true,
}),
];
if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
providers.push(
GoogleProvider({
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
allowDangerousEmailAccountLinking: true,
})
);
}
if (env.FROM_EMAIL) {
providers.push(
EmailProvider({
from: env.FROM_EMAIL,
async sendVerificationRequest({ identifier: email, url, token }) {
await sendSignUpEmail(email, token, url);
},
async generateVerificationToken() {
return Math.random().toString(36).substring(2, 7).toLowerCase();
},
})
);
}
/**
* Options for NextAuth.js used to configure adapters, providers, callbacks, etc.
*
@@ -56,36 +93,18 @@ export const authOptions: NextAuthOptions = {
pages: {
signIn: "/login",
},
providers: [
/**
* ...add more providers here.
*
* Most other providers require a bit more work than the Discord provider. For example, the
* GitHub provider requires you to add the `refresh_token_expires_in` field to the Account
* model. Refer to the NextAuth.js docs for the provider you want to use. Example:
*
* @see https://next-auth.js.org/providers/github
*/
GitHubProvider({
clientId: env.GITHUB_ID,
clientSecret: env.GITHUB_SECRET,
allowDangerousEmailAccountLinking: true,
}),
GoogleProvider({
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
allowDangerousEmailAccountLinking: true,
}),
EmailProvider({
from: "no-reply@splitpro.app",
async sendVerificationRequest({ identifier: email, url, token }) {
await sendSignUpEmail(email, token, url);
},
async generateVerificationToken() {
return Math.random().toString(36).substring(2, 7).toLowerCase();
},
}),
],
events: {
createUser: async ({ user }) => {
// No waitlist for self hosting
if (!env.NEXT_PUBLIC_IS_CLOUD) {
await db.user.update({
where: { id: user.id },
data: { isBetaUser: true },
});
}
},
},
providers,
};
/**
@@ -97,6 +116,7 @@ export const getServerAuthSession = () => getServerSession(authOptions);
import { createHash } from "crypto";
import { sendSignUpEmail } from "./mailer";
import { Provider } from "next-auth/providers/index";
/**
* Hashes a token using SHA-256.

View File

@@ -1,7 +1,14 @@
import { env } from "~/env";
import { Unsend } from "unsend";
const unsend = new Unsend(env.UNSEND_API_KEY);
let unsend: Unsend | undefined;
const getClient = () => {
if (!unsend) {
unsend = new Unsend(env.UNSEND_API_KEY);
}
return unsend;
};
export async function sendSignUpEmail(
email: string,
@@ -28,10 +35,10 @@ async function sendMail(
text: string,
html: string
) {
if (env.UNSEND_API_KEY && env.UNSEND_URL) {
const resp = await unsend.emails.send({
if (env.UNSEND_API_KEY && env.UNSEND_URL && env.FROM_EMAIL) {
const resp = await getClient().emails.send({
to: email,
from: "no-reply@auth.unsend.dev",
from: env.FROM_EMAIL,
subject,
text,
html,

View File

@@ -10,6 +10,8 @@ const rateLimitCache = new TTLCache({
max: env.API_RATE_LIMIT,
});
console.log(env.DATABASE_URL);
/**
* Gets the team from the token. Also will check if the token is valid.
*/

View File

@@ -15,7 +15,7 @@ const boss = new pgBoss({
});
let started = false;
async function getBoss() {
export async function getBoss() {
if (!started) {
await boss.start();
await boss.work(

View File

@@ -0,0 +1,195 @@
import { SesSetting } from "@prisma/client";
import { db } from "../db";
import { env } from "~/env";
import { customAlphabet } from "nanoid";
import * as sns from "~/server/aws/sns";
import * as ses from "~/server/aws/ses";
import { EventType } from "@aws-sdk/client-sesv2";
const nanoid = customAlphabet("1234567890abcdef", 10);
const GENERAL_EVENTS: EventType[] = [
"BOUNCE",
"COMPLAINT",
"DELIVERY",
"DELIVERY_DELAY",
"REJECT",
"RENDERING_FAILURE",
"SEND",
"SUBSCRIPTION",
];
export class SesSettingsService {
private static cache: Record<string, SesSetting> = {};
public static getSetting(region = env.AWS_DEFAULT_REGION): SesSetting | null {
if (this.cache[region]) {
return this.cache[region] as SesSetting;
}
return null;
}
public static getAllSettings() {
return Object.values(this.cache);
}
/**
* Creates a new setting in AWS for the given region and unsendUrl
*
* @param region
* @param unsendUrl
*/
public static async createSesSetting({
region,
unsendUrl,
}: {
region: string;
unsendUrl: string;
}) {
if (this.cache[region]) {
throw new Error(`SesSetting for region ${region} already exists`);
}
const unsendUrlValidation = await isValidUnsendUrl(unsendUrl);
if (!unsendUrlValidation.isValid) {
throw new Error(
`Unsend URL ${unsendUrl} is not valid, status: ${unsendUrlValidation.code} ${unsendUrlValidation.error}`
);
}
const idPrefix = nanoid(10);
const setting = await db.sesSetting.create({
data: {
region,
callbackUrl: `${unsendUrl}/api/ses_callback`,
topic: `${idPrefix}-${region}-unsend`,
idPrefix,
},
});
await createSettingInAws(setting);
this.invalidateCache();
}
public static async init() {
const settings = await db.sesSetting.findMany();
settings.forEach((setting) => {
this.cache[setting.region] = setting;
});
}
static invalidateCache() {
this.cache = {};
this.init();
}
}
async function createSettingInAws(setting: SesSetting) {
await registerTopicInAws(setting).then(registerConfigurationSet);
}
/**
* Creates a new topic in AWS and subscribes the callback URL to it
*/
async function registerTopicInAws(setting: SesSetting) {
const topicArn = await sns.createTopic(setting.topic);
if (!topicArn) {
throw new Error("Failed to create SNS topic");
}
await sns.subscribeEndpoint(
topicArn,
`${setting.callbackUrl}/api/ses_callback`
);
return await db.sesSetting.update({
where: {
id: setting.id,
},
data: {
topicArn,
},
});
}
/**
* Creates a new configuration set in AWS for given region
* Totally consist of 4 configs.
* 1. General - for general events
* 2. Click - for click tracking
* 3. Open - for open tracking
* 4. Full - for click and open tracking
*/
async function registerConfigurationSet(setting: SesSetting) {
if (!setting.topicArn) {
throw new Error("Setting does not have a topic ARN");
}
const configGeneral = `${setting.idPrefix}-${setting.region}-unsend-general`;
const generalStatus = await ses.addWebhookConfiguration(
configGeneral,
setting.topicArn,
GENERAL_EVENTS
);
const configClick = `${setting.idPrefix}-${setting.region}-unsend-click`;
const clickStatus = await ses.addWebhookConfiguration(
configClick,
setting.topicArn,
[...GENERAL_EVENTS, "CLICK"]
);
const configOpen = `${setting.idPrefix}-${setting.region}-unsend-open`;
const openStatus = await ses.addWebhookConfiguration(
configOpen,
setting.topicArn,
[...GENERAL_EVENTS, "OPEN"]
);
const configFull = `${setting.idPrefix}-${setting.region}-unsend-full`;
const fullStatus = await ses.addWebhookConfiguration(
configFull,
setting.topicArn,
[...GENERAL_EVENTS, "CLICK", "OPEN"]
);
return await db.sesSetting.update({
where: {
id: setting.id,
},
data: {
configGeneral,
configGeneralSuccess: generalStatus,
configClick,
configClickSuccess: clickStatus,
configOpen,
configOpenSuccess: openStatus,
configFull,
configFullSuccess: fullStatus,
},
});
}
async function isValidUnsendUrl(url: string) {
try {
const response = await fetch(`${url}/api/ses_callback`, {
method: "POST",
body: JSON.stringify({ fromUnsend: true }),
});
return {
isValid: response.status === 200,
code: response.status,
error: response.statusText,
};
} catch (e) {
return {
isValid: false,
code: 500,
error: e,
};
}
}