Improve self host support (#28)
* Add docker setup for self hosting * Add ses settings tables
This commit is contained in:
37
apps/web/src/server/api/routers/admin.ts
Normal file
37
apps/web/src/server/api/routers/admin.ts
Normal 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
|
||||
);
|
||||
}),
|
||||
});
|
@@ -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();
|
||||
});
|
||||
|
@@ -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.
|
||||
|
@@ -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,
|
||||
|
@@ -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.
|
||||
*/
|
||||
|
@@ -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(
|
||||
|
195
apps/web/src/server/service/ses-settings-service.ts
Normal file
195
apps/web/src/server/service/ses-settings-service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user