Add MVP version

This commit is contained in:
KMKoushik
2024-03-24 17:43:56 +11:00
parent 9032efa9b2
commit bbc64b5392
49 changed files with 3249 additions and 298 deletions

View File

@@ -1,5 +1,7 @@
import { postRouter } from "~/server/api/routers/post";
import { domainRouter } from "~/server/api/routers/domain";
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
import { apiRouter } from "./routers/api";
import { emailRouter } from "./routers/email";
/**
* This is the primary router for your server.
@@ -7,7 +9,9 @@ import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
* All routers added in /api/routers should be manually added here.
*/
export const appRouter = createTRPCRouter({
post: postRouter,
domain: domainRouter,
apiKey: apiRouter,
email: emailRouter,
});
// export type definition of API

View File

@@ -0,0 +1,42 @@
import { z } from "zod";
import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
teamProcedure,
} from "~/server/api/trpc";
import { db } from "~/server/db";
import { addApiKey } from "~/server/service/api-service";
import { createDomain, getDomain } from "~/server/service/domain-service";
export const apiRouter = createTRPCRouter({
createToken: teamProcedure
.input(
z.object({ name: z.string(), permission: z.enum(["FULL", "SENDING"]) })
)
.mutation(async ({ ctx, input }) => {
return addApiKey({
name: input.name,
permission: input.permission,
teamId: ctx.team.id,
});
}),
getApiKeys: teamProcedure.query(async ({ ctx }) => {
const keys = await ctx.db.apiKey.findMany({
where: {
teamId: ctx.team.id,
},
select: {
id: true,
name: true,
permission: true,
partialToken: true,
lastUsed: true,
},
});
return keys;
}),
});

View File

@@ -0,0 +1,34 @@
import { z } from "zod";
import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
teamProcedure,
} from "~/server/api/trpc";
import { db } from "~/server/db";
import { createDomain, getDomain } from "~/server/service/domain-service";
export const domainRouter = createTRPCRouter({
createDomain: teamProcedure
.input(z.object({ name: z.string() }))
.mutation(async ({ ctx, input }) => {
return createDomain(ctx.team.id, input.name);
}),
domains: teamProcedure.query(async ({ ctx }) => {
const domains = await db.domain.findMany({
where: {
teamId: ctx.team.id,
},
});
return domains;
}),
getDomain: teamProcedure
.input(z.object({ id: z.number() }))
.query(async ({ ctx, input }) => {
return getDomain(input.id);
}),
});

View File

@@ -0,0 +1,41 @@
import { z } from "zod";
import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
teamProcedure,
} from "~/server/api/trpc";
import { db } from "~/server/db";
import { createDomain, getDomain } from "~/server/service/domain-service";
export const emailRouter = createTRPCRouter({
emails: teamProcedure.query(async ({ ctx }) => {
const emails = await db.email.findMany({
where: {
teamId: ctx.team.id,
},
});
return emails;
}),
getEmail: teamProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const email = await db.email.findUnique({
where: {
id: input.id,
},
include: {
emailEvents: {
orderBy: {
createdAt: "desc",
},
},
},
});
return email;
}),
});

View File

@@ -1,42 +0,0 @@
import { z } from "zod";
import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
} from "~/server/api/trpc";
export const postRouter = createTRPCRouter({
hello: publicProcedure
.input(z.object({ text: z.string() }))
.query(({ input }) => {
return {
greeting: `Hello ${input.text}`,
};
}),
create: protectedProcedure
.input(z.object({ name: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
// simulate a slow db call
await new Promise((resolve) => setTimeout(resolve, 1000));
return ctx.db.post.create({
data: {
name: input.name,
createdBy: { connect: { id: ctx.session.user.id } },
},
});
}),
getLatest: protectedProcedure.query(({ ctx }) => {
return ctx.db.post.findFirst({
orderBy: { createdAt: "desc" },
where: { createdBy: { id: ctx.session.user.id } },
});
}),
getSecretMessage: protectedProcedure.query(() => {
return "you can now see this secret message!";
}),
});

View File

@@ -9,7 +9,7 @@
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { ZodError } from "zod";
import { z, ZodError } from "zod";
import { getServerAuthSession } from "~/server/auth";
import { db } from "~/server/db";
@@ -106,3 +106,21 @@ export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
},
});
});
export const teamProcedure = protectedProcedure.use(
async ({ ctx, next, input }) => {
const teamUser = await db.teamUser.findFirst({
where: { userId: ctx.session.user.id },
include: { team: true },
});
if (!teamUser) {
throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" });
}
return next({
ctx: {
team: teamUser.team,
session: { ...ctx.session, user: ctx.session.user },
},
});
}
);

View File

@@ -5,7 +5,7 @@ import {
type NextAuthOptions,
} from "next-auth";
import { type Adapter } from "next-auth/adapters";
import DiscordProvider from "next-auth/providers/discord";
import GitHubProvider from "next-auth/providers/github";
import { env } from "~/env";
import { db } from "~/server/db";
@@ -19,16 +19,15 @@ import { db } from "~/server/db";
declare module "next-auth" {
interface Session extends DefaultSession {
user: {
id: string;
id: number;
// ...other properties
// role: UserRole;
} & DefaultSession["user"];
}
// interface User {
// // ...other properties
// // role: UserRole;
// }
interface User {
id: number;
}
}
/**
@@ -57,6 +56,10 @@ export const authOptions: NextAuthOptions = {
*
* @see https://next-auth.js.org/providers/github
*/
GitHubProvider({
clientId: env.GITHUB_ID,
clientSecret: env.GITHUB_SECRET,
}),
],
};
@@ -66,3 +69,15 @@ export const authOptions: NextAuthOptions = {
* @see https://next-auth.js.org/configuration/nextjs
*/
export const getServerAuthSession = () => getServerSession(authOptions);
import { createHash } from "crypto";
/**
* Hashes a token using SHA-256.
*
* @param {string} token - The token to be hashed.
* @returns {string} The hashed token.
*/
export function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}

View File

@@ -0,0 +1,95 @@
import { JsonValue } from "@prisma/client/runtime/library";
import { db } from "../db";
import { APP_SETTINGS } from "~/utils/constants";
import { createTopic, subscribeEndpoint } from "./sns";
import { env } from "~/env";
import { AppSettingsService } from "~/server/service/app-settings-service";
import { addWebhookConfiguration } from "../ses";
import { EventType } from "@aws-sdk/client-sesv2";
const GENERAL_EVENTS: EventType[] = [
"BOUNCE",
"COMPLAINT",
"DELIVERY",
"DELIVERY_DELAY",
"REJECT",
"RENDERING_FAILURE",
"SEND",
"SUBSCRIPTION",
];
export async function setupAws() {
AppSettingsService.initializeCache();
let snsTopicArn = await AppSettingsService.getSetting(
APP_SETTINGS.SNS_TOPIC_ARN
);
console.log("Setting up AWS");
if (!snsTopicArn) {
console.log("SNS topic not present, creating...");
snsTopicArn = await createUnsendSNSTopic();
}
await setupSESConfiguration();
}
async function createUnsendSNSTopic() {
const topicArn = await createTopic(env.SNS_TOPIC);
if (!topicArn) {
console.error("Failed to create SNS topic");
return;
}
await subscribeEndpoint(
topicArn,
`${env.APP_URL ?? env.NEXTAUTH_URL}/api/ses_callback`
);
return await AppSettingsService.setSetting(
APP_SETTINGS.SNS_TOPIC_ARN,
topicArn
);
}
async function setupSESConfiguration() {
const topicArn = (
await AppSettingsService.getSetting(APP_SETTINGS.SNS_TOPIC_ARN)
)?.toString();
if (!topicArn) {
return;
}
console.log("Setting up SES webhook configuration");
await setWebhookConfiguration(
APP_SETTINGS.SES_CONFIGURATION_GENERAL,
topicArn,
GENERAL_EVENTS
);
await setWebhookConfiguration(
APP_SETTINGS.SES_CONFIGURATION_CLICK_TRACKING,
topicArn,
[...GENERAL_EVENTS, "CLICK"]
);
await setWebhookConfiguration(
APP_SETTINGS.SES_CONFIGURATION_OPEN_TRACKING,
topicArn,
[...GENERAL_EVENTS, "OPEN"]
);
}
async function setWebhookConfiguration(
setting: string,
topicArn: string,
eventTypes: EventType[]
) {
const sesConfigurationGeneral = await AppSettingsService.getSetting(setting);
if (!sesConfigurationGeneral) {
console.log(`Setting up SES webhook configuration for ${setting}`);
const status = await addWebhookConfiguration(setting, topicArn, eventTypes);
await AppSettingsService.setSetting(setting, status.toString());
}
}

View File

@@ -0,0 +1,39 @@
import {
SNSClient,
CreateTopicCommand,
SubscribeCommand,
} from "@aws-sdk/client-sns";
import { env } from "~/env";
function getSnsClient(region = "us-east-1") {
return new SNSClient({
region: region,
credentials: {
accessKeyId: env.AWS_ACCESS_KEY,
secretAccessKey: env.AWS_SECRET_KEY,
},
});
}
export async function createTopic(topic: string) {
const client = getSnsClient();
const command = new CreateTopicCommand({
Name: topic,
});
const data = await client.send(command);
return data.TopicArn;
}
export async function subscribeEndpoint(topicArn: string, endpointUrl: string) {
const subscribeCommand = new SubscribeCommand({
Protocol: "https",
TopicArn: topicArn,
Endpoint: endpointUrl,
});
const client = getSnsClient();
const data = await client.send(subscribeCommand);
console.log(data.SubscriptionArn);
return data.SubscriptionArn;
}

View File

@@ -0,0 +1,59 @@
import { ApiPermission } from "@prisma/client";
import { db } from "../db";
import { randomBytes } from "crypto";
import { hashToken } from "../auth";
export async function addApiKey({
name,
permission,
teamId,
}: {
name: string;
permission: ApiPermission;
teamId: number;
}) {
try {
const token = `us_${randomBytes(20).toString("hex")}`;
const hashedToken = hashToken(token);
await db.apiKey.create({
data: {
name,
permission: permission,
teamId,
tokenHash: hashedToken,
partialToken: `${token.slice(0, 8)}...${token.slice(-5)}`,
},
});
return token;
} catch (error) {
console.error("Error adding API key:", error);
throw error;
}
}
export async function retrieveApiKey(token: string) {
const hashedToken = hashToken(token);
try {
const apiKey = await db.apiKey.findUnique({
where: {
tokenHash: hashedToken,
},
select: {
id: true,
name: true,
permission: true,
teamId: true,
partialToken: true,
},
});
if (!apiKey) {
throw new Error("API Key not found");
}
return apiKey;
} catch (error) {
console.error("Error retrieving API key:", error);
throw error;
}
}

View File

@@ -0,0 +1,38 @@
import { db } from "../db";
import { JsonValue } from "@prisma/client/runtime/library";
export class AppSettingsService {
private static cache: Record<string, JsonValue> = {};
public static async getSetting(key: string) {
if (!this.cache[key]) {
const setting = await db.appSetting.findUnique({
where: { key },
});
if (setting) {
this.cache[key] = setting.value;
} else {
return null;
}
}
return this.cache[key];
}
public static async setSetting(key: string, value: string) {
await db.appSetting.upsert({
where: { key },
update: { value },
create: { key, value },
});
this.cache[key] = value;
return value;
}
public static async initializeCache(): Promise<void> {
const settings = await db.appSetting.findMany();
settings.forEach((setting) => {
this.cache[setting.key] = setting.value;
});
}
}

View File

@@ -0,0 +1,69 @@
import { addDomain, getDomainIdentity } from "~/server/ses";
import { db } from "~/server/db";
export async function createDomain(teamId: number, name: string) {
console.log("Creating domain:", name);
const publicKey = await addDomain(name);
const domain = await db.domain.create({
data: {
name,
publicKey,
teamId,
},
});
return domain;
}
export async function getDomain(id: number) {
let domain = await db.domain.findUnique({
where: {
id,
},
});
if (!domain) {
throw new Error("Domain not found");
}
if (domain.status !== "SUCCESS") {
const domainIdentity = await getDomainIdentity(domain.name, domain.region);
const dkimStatus = domainIdentity.DkimAttributes?.Status;
const spfDetails = domainIdentity.MailFromAttributes?.MailFromDomainStatus;
const verificationError = domainIdentity.VerificationInfo?.ErrorType;
const verificationStatus = domainIdentity.VerificationStatus;
const lastCheckedTime =
domainIdentity.VerificationInfo?.LastCheckedTimestamp;
console.log(domainIdentity);
if (
domain.dkimStatus !== dkimStatus ||
domain.spfDetails !== spfDetails ||
domain.status !== verificationStatus
) {
domain = await db.domain.update({
where: {
id,
},
data: {
dkimStatus,
spfDetails,
status: verificationStatus ?? "NOT_STARTED",
},
});
}
return {
...domain,
dkimStatus,
spfDetails,
verificationError,
lastCheckedTime,
};
}
return domain;
}

View File

@@ -0,0 +1,49 @@
import { EmailContent } from "~/types";
import { db } from "../db";
import { sendEmailThroughSes } from "../ses";
export async function sendEmail(
emailContent: EmailContent & { teamId: number }
) {
const { to, from, subject, text, html, teamId } = emailContent;
const domains = await db.domain.findMany({ where: { teamId } });
const fromDomain = from.split("@")[1];
if (!fromDomain) {
throw new Error("From email is not valid");
}
const domain = domains.find((domain) => domain.name === fromDomain);
if (!domain) {
throw new Error("Domain not found. Add domain to unsend first");
}
if (domain.status !== "SUCCESS") {
throw new Error("Domain is not verified");
}
const messageId = await sendEmailThroughSes({
to,
from,
subject,
text,
html,
region: domain.region,
});
if (messageId) {
return await db.email.create({
data: {
to,
from,
subject,
text,
html,
id: messageId,
teamId,
domainId: domain.id,
},
});
}
}

176
apps/web/src/server/ses.ts Normal file
View File

@@ -0,0 +1,176 @@
import {
SESv2Client,
CreateEmailIdentityCommand,
DeleteEmailIdentityCommand,
GetEmailIdentityCommand,
PutEmailIdentityMailFromAttributesCommand,
SendEmailCommand,
CreateConfigurationSetEventDestinationCommand,
CreateConfigurationSetCommand,
EventType,
} from "@aws-sdk/client-sesv2";
import { generateKeyPairSync } from "crypto";
import { env } from "~/env";
import { EmailContent } from "~/types";
import { APP_SETTINGS } from "~/utils/constants";
function getSesClient(region = "us-east-1") {
return new SESv2Client({
region: region,
credentials: {
accessKeyId: env.AWS_ACCESS_KEY,
secretAccessKey: env.AWS_SECRET_KEY,
},
});
}
function generateKeyPair() {
const { privateKey, publicKey } = generateKeyPairSync("rsa", {
modulusLength: 2048, // Length of your key in bits
publicKeyEncoding: {
type: "spki", // Recommended to be 'spki' by the Node.js docs
format: "pem",
},
privateKeyEncoding: {
type: "pkcs8", // Recommended to be 'pkcs8' by the Node.js docs
format: "pem",
},
});
const base64PrivateKey = privateKey
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replace(/\n/g, "");
const base64PublicKey = publicKey
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replace(/\n/g, "");
return { privateKey: base64PrivateKey, publicKey: base64PublicKey };
}
export async function addDomain(domain: string, region = "us-east-1") {
const sesClient = getSesClient(region);
const { privateKey, publicKey } = generateKeyPair();
const command = new CreateEmailIdentityCommand({
EmailIdentity: domain,
DkimSigningAttributes: {
DomainSigningSelector: "unsend",
DomainSigningPrivateKey: privateKey,
},
ConfigurationSetName: APP_SETTINGS.SES_CONFIGURATION_GENERAL,
});
const response = await sesClient.send(command);
const emailIdentityCommand = new PutEmailIdentityMailFromAttributesCommand({
EmailIdentity: domain,
MailFromDomain: `send.${domain}`,
});
const emailIdentityResponse = await sesClient.send(emailIdentityCommand);
if (
response.$metadata.httpStatusCode !== 200 ||
emailIdentityResponse.$metadata.httpStatusCode !== 200
) {
throw new Error("Failed to create email identity");
}
return publicKey;
}
export async function getDomainIdentity(domain: string, region = "us-east-1") {
const sesClient = getSesClient(region);
const command = new GetEmailIdentityCommand({
EmailIdentity: domain,
});
const response = await sesClient.send(command);
return response;
}
export async function sendEmailThroughSes({
to,
from,
subject,
text,
html,
region = "us-east-1",
}: EmailContent & {
region?: string;
}) {
const sesClient = getSesClient(region);
const command = new SendEmailCommand({
FromEmailAddress: from,
Destination: {
ToAddresses: [to],
},
Content: {
// EmailContent
Simple: {
// Message
Subject: {
// Content
Data: subject, // required
Charset: "UTF-8",
},
Body: {
// Body
Text: {
Data: text, // required
Charset: "UTF-8",
},
Html: {
Data: html, // required
Charset: "UTF-8",
},
},
},
},
});
try {
const response = await sesClient.send(command);
console.log("Email sent! Message ID:", response.MessageId);
return response.MessageId;
} catch (error) {
console.error("Failed to send email", error);
throw new Error("Failed to send email");
}
}
export async function addWebhookConfiguration(
configName: string,
topicArn: string,
eventTypes: EventType[],
region = "us-east-1"
) {
const sesClient = getSesClient(region);
const configSetCommand = new CreateConfigurationSetCommand({
ConfigurationSetName: configName,
});
const configSetResponse = await sesClient.send(configSetCommand);
if (configSetResponse.$metadata.httpStatusCode !== 200) {
throw new Error("Failed to create configuration set");
}
const command = new CreateConfigurationSetEventDestinationCommand({
ConfigurationSetName: configName, // required
EventDestinationName: "unsend_destination", // required
EventDestination: {
// EventDestinationDefinition
Enabled: true,
MatchingEventTypes: eventTypes,
SnsDestination: {
TopicArn: topicArn,
},
},
});
const response = await sesClient.send(command);
return response.$metadata.httpStatusCode === 200;
}