♻️ Simplify trpc context (#1322)

This commit is contained in:
Luke Vella 2024-09-06 17:23:48 +01:00 committed by GitHub
parent 92b3115896
commit 316ebe34a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 90 additions and 116 deletions

View file

@ -1,23 +1,13 @@
import * as Sentry from "@sentry/nextjs";
import { TRPCError } from "@trpc/server";
import { createNextApiHandler } from "@trpc/server/adapters/next";
import { Ratelimit } from "@upstash/ratelimit";
import { kv } from "@vercel/kv";
import requestIp from "request-ip";
import { posthog, posthogApiHandler } from "@/app/posthog";
import { createTRPCContext } from "@/trpc/context";
import { posthogApiHandler } from "@/app/posthog";
import { AppRouter, appRouter } from "@/trpc/routers";
import { absoluteUrl, shortUrl } from "@/utils/absolute-url";
import { getServerSession, isEmailBlocked } from "@/utils/auth";
import { isSelfHosted } from "@/utils/constants";
import { getServerSession } from "@/utils/auth";
import { getEmailClient } from "@/utils/emails";
import { composeApiHandlers } from "@/utils/next";
const ratelimit = new Ratelimit({
redis: kv,
limiter: Ratelimit.slidingWindow(5, "1 m"),
});
export const config = {
api: {
externalResolver: true,
@ -27,42 +17,25 @@ export const config = {
const trpcApiHandler = createNextApiHandler<AppRouter>({
router: appRouter,
createContext: async (opts) => {
const res = createTRPCContext(opts, {
async getUser({ req, res }) {
const session = await getServerSession(req, res);
const session = await getServerSession(opts.req, opts.res);
if (!session) {
return null;
}
if (!session) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Unauthorized",
});
}
return {
id: session.user.id,
isGuest: session.user.email === null,
locale: session.user.locale ?? undefined,
getEmailClient: () =>
getEmailClient(session.user.locale ?? undefined),
};
const res = {
user: {
id: session.user.id,
isGuest: session.user.email === null,
locale: session.user.locale ?? undefined,
getEmailClient: () => getEmailClient(session.user.locale ?? undefined),
},
posthogClient: posthog || undefined,
isSelfHosted,
isEmailBlocked,
absoluteUrl,
getEmailClient,
shortUrl,
ratelimit: async () => {
if (!process.env.KV_REST_API_URL) {
return { success: true };
}
const clientIp = requestIp.getClientIp(opts.req);
if (!clientIp) {
return { success: false };
}
return ratelimit.limit(clientIp);
},
});
req: opts.req,
res: opts.res,
};
return res;
},

View file

@ -1,48 +1,13 @@
import { EmailClient } from "@rallly/emails";
import { inferAsyncReturnType, TRPCError } from "@trpc/server";
import { CreateNextContextOptions } from "@trpc/server/adapters/next";
import type { PostHog } from "posthog-node";
import type { NextApiRequest, NextApiResponse } from "next";
export type GetUserFn = (opts: CreateNextContextOptions) => Promise<{
id: string;
isGuest: boolean;
locale?: string;
getEmailClient: (locale?: string) => EmailClient;
} | null>;
export interface TRPCContextParams {
getUser: GetUserFn;
isSelfHosted: boolean;
isEmailBlocked?: (email: string) => boolean;
posthogClient?: PostHog;
getEmailClient: (locale?: string) => EmailClient;
/**
* Takes a relative path and returns an absolute URL to the app
* @param path
* @returns absolute URL
*/
absoluteUrl: (path?: string) => string;
shortUrl: (path?: string) => string;
ratelimit: () => Promise<{ success: boolean }>;
}
export const createTRPCContext = async (
opts: CreateNextContextOptions,
{ getUser, ...params }: TRPCContextParams,
) => {
const user = await getUser(opts);
if (!user) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Request has no session",
});
}
return {
user,
...params,
export type TRPCContext = {
user: {
id: string;
isGuest: boolean;
locale?: string;
getEmailClient: (locale?: string) => EmailClient;
};
req: NextApiRequest;
res: NextApiResponse;
};
export type TRPCContext = inferAsyncReturnType<typeof createTRPCContext>;

View file

@ -1,6 +1,8 @@
import { prisma } from "@rallly/database";
import { z } from "zod";
import { posthog } from "@/app/posthog";
import { isEmailBlocked } from "@/utils/auth";
import { generateOtp } from "@/utils/nanoid";
import { createToken, decryptToken } from "@/utils/session";
@ -24,7 +26,7 @@ export const auth = router({
| { ok: true; token: string }
| { ok: false; reason: "userAlreadyExists" | "emailNotAllowed" }
> => {
if (ctx.isEmailBlocked?.(input.email)) {
if (isEmailBlocked?.(input.email)) {
return { ok: false, reason: "emailNotAllowed" };
}
@ -68,7 +70,7 @@ export const auth = router({
locale: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
.mutation(async ({ input }) => {
const payload = await decryptToken<RegistrationTokenPayload>(input.token);
if (!payload) {
@ -90,7 +92,7 @@ export const auth = router({
},
});
ctx.posthogClient?.capture({
posthog?.capture({
event: "register",
distinctId: user.id,
properties: {

View file

@ -4,6 +4,10 @@ import dayjs from "dayjs";
import * as ics from "ics";
import { z } from "zod";
import { posthog } from "@/app/posthog";
import { absoluteUrl, shortUrl } from "@/utils/absolute-url";
import { getEmailClient } from "@/utils/emails";
import { getTimeZoneAbbreviation } from "../../utils/date";
import { nanoid } from "../../utils/nanoid";
import {
@ -222,9 +226,9 @@ export const polls = router({
},
});
const pollLink = ctx.absoluteUrl(`/poll/${pollId}`);
const pollLink = absoluteUrl(`/poll/${pollId}`);
const participantLink = ctx.shortUrl(`/invite/${pollId}`);
const participantLink = shortUrl(`/invite/${pollId}`);
if (ctx.user.isGuest === false) {
const user = await prisma.user.findUnique({
@ -505,7 +509,7 @@ export const polls = router({
message: "Poll not found",
});
}
const inviteLink = ctx.shortUrl(`/invite/${res.id}`);
const inviteLink = shortUrl(`/invite/${res.id}`);
if (ctx.user.id === res.userId || res.adminUrlId === input.adminToken) {
return { ...res, inviteLink };
@ -921,7 +925,7 @@ export const polls = router({
to: poll.user.email,
props: {
name: poll.user.name,
pollUrl: ctx.absoluteUrl(`/poll/${poll.id}`),
pollUrl: absoluteUrl(`/poll/${poll.id}`),
location: poll.location,
title: poll.title,
attendees: poll.participants
@ -940,12 +944,12 @@ export const polls = router({
});
for (const p of participantsToEmail) {
ctx
.getEmailClient(p.locale ?? undefined)
.queueTemplate("FinalizeParticipantEmail", {
getEmailClient(p.locale ?? undefined).queueTemplate(
"FinalizeParticipantEmail",
{
to: p.email,
props: {
pollUrl: ctx.absoluteUrl(`/invite/${poll.id}`),
pollUrl: absoluteUrl(`/invite/${poll.id}`),
title: poll.title,
hostName: poll.user?.name ?? "",
date,
@ -954,10 +958,11 @@ export const polls = router({
time,
},
attachments: [{ filename: "event.ics", content: event.value }],
});
},
);
}
ctx.posthogClient?.capture({
posthog?.capture({
distinctId: ctx.user.id,
event: "finalize poll",
properties: {

View file

@ -1,6 +1,8 @@
import { prisma } from "@rallly/database";
import { z } from "zod";
import { absoluteUrl } from "@/utils/absolute-url";
import { getEmailClient } from "@/utils/emails";
import { createToken } from "@/utils/session";
import { publicProcedure, router } from "../../trpc";
@ -79,19 +81,21 @@ export const comments = router({
{ watcherId: watcher.id, pollId },
{ ttl: 0 },
);
ctx
.getEmailClient(watcher.user.locale ?? undefined)
.queueTemplate("NewCommentEmail", {
getEmailClient(watcher.user.locale ?? undefined).queueTemplate(
"NewCommentEmail",
{
to: email,
props: {
authorName,
pollUrl: ctx.absoluteUrl(`/poll/${poll.id}`),
disableNotificationsUrl: ctx.absoluteUrl(
pollUrl: absoluteUrl(`/poll/${poll.id}`),
disableNotificationsUrl: absoluteUrl(
`/auth/disable-notifications?token=${token}`,
),
title: poll.title,
},
});
},
);
}
return newComment;

View file

@ -2,6 +2,7 @@ import { prisma } from "@rallly/database";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { absoluteUrl } from "@/utils/absolute-url";
import { createToken } from "@/utils/session";
import { publicProcedure, rateLimitMiddleware, router } from "../../trpc";
@ -121,7 +122,7 @@ export const participants = router({
to: email,
props: {
title: poll.title,
editSubmissionUrl: ctx.absoluteUrl(
editSubmissionUrl: absoluteUrl(
`/invite/${poll.id}?token=${token}`,
),
},
@ -154,8 +155,8 @@ export const participants = router({
to: email,
props: {
participantName: participant.name,
pollUrl: ctx.absoluteUrl(`/poll/${poll.id}`),
disableNotificationsUrl: ctx.absoluteUrl(
pollUrl: absoluteUrl(`/poll/${poll.id}`),
disableNotificationsUrl: absoluteUrl(
`/auth/disable-notifications?token=${token}`,
),
title: poll.title,

View file

@ -1,6 +1,10 @@
import { initTRPC, TRPCError } from "@trpc/server";
import { Ratelimit } from "@upstash/ratelimit";
import { kv } from "@vercel/kv";
import requestIp from "request-ip";
import superjson from "superjson";
import { isSelfHosted } from "@/utils/constants";
import { getSubscriptionStatus } from "@/utils/subscription";
import { TRPCContext } from "./context";
@ -21,7 +25,7 @@ export const middleware = t.middleware;
export const possiblyPublicProcedure = t.procedure.use(
middleware(async ({ ctx, next }) => {
// On self-hosted instances, these procedures require login
if (ctx.isSelfHosted && ctx.user.isGuest) {
if (isSelfHosted && ctx.user.isGuest) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Login is required",
@ -40,7 +44,7 @@ export const proProcedure = t.procedure.use(
});
}
if (ctx.isSelfHosted) {
if (isSelfHosted) {
// Self-hosted instances don't have paid subscriptions
return next();
}
@ -72,13 +76,33 @@ export const privateProcedure = t.procedure.use(
);
export const rateLimitMiddleware = middleware(async ({ ctx, next }) => {
const { success } = await ctx.ratelimit();
if (!success) {
const ratelimit = new Ratelimit({
redis: kv,
limiter: Ratelimit.slidingWindow(5, "1 m"),
});
if (!process.env.KV_REST_API_URL) {
return next();
}
const clientIp = requestIp.getClientIp(ctx.req);
if (!clientIp) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to get client IP",
});
}
const res = await ratelimit.limit(clientIp);
if (!res.success) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "Too many requests",
});
}
return next();
});