Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions app/api/ai/analyze/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { withAuth } from "@/lib/withAuth";
import { limiters, enforce } from "@/lib/rate-limit";
import { analyzeDeal } from "@/lib/ai";

export const POST = withAuth(async (req, user) => {
const { id } = await req.json(); // { id: "<deal-id>" }

const { ok, headers } = await enforce(limiters.aiAnalyze, user.id);
if (!ok) return new Response("Too many requests", { status: 429, headers });

const result = await analyzeDeal(id);
return NextResponse.json({ data: result }, { status: 200, headers });
}).__ratelimit("aiAnalyze");
13 changes: 13 additions & 0 deletions app/api/auth/forgot-password/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { sendReset } from "@/lib/mailer";
import { limiters, enforce } from "@/lib/rate-limit";

export async function POST(req: Request) {
const { email } = await req.json();

const { ok, headers } = await enforce(limiters.authForgot, email);
if (!ok) return new Response("Too many requests", { status: 429, headers });

await sendReset(email);
return NextResponse.json({ ok: true }, { status: 200, headers });
}
13 changes: 13 additions & 0 deletions app/api/auth/session/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { limiters, enforce } from "@/lib/rate-limit";

export async function GET(req: Request) {
const session = await auth();

const key = session?.user?.id ?? req.headers.get("x-forwarded-for") ?? undefined;
const { ok, headers } = await enforce(limiters.authSession, key);
if (!ok) return new Response("Too many requests", { status: 429, headers });

return NextResponse.json({ data: session ?? null }, { headers });
}
17 changes: 17 additions & 0 deletions app/api/auth/signin/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { signIn } from "@/auth";
import { limiters, enforce } from "@/lib/rate-limit";

export async function POST(req: Request) {
const { email, password } = await req.json();

/* ─ rate-limit ─ */
const { ok, headers } = await enforce(limiters.authSignin, email);
if (!ok) return new Response("Too many sign-in attempts", { status: 429, headers });

const session = await signIn(email, password);
if (!session) {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
return NextResponse.json({ data: session }, { status: 200, headers });
}
27 changes: 27 additions & 0 deletions app/api/deals/[id]/comment/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { withAuth } from "@/lib/withAuth";
import { limiters, enforce } from "@/lib/rate-limit";


export const PATCH = withAuth(async (req, user) => {
const { id } = (req as any).params as { id: string };
const body = await req.json();

const { ok, headers } = await enforce(limiters.dealsUpdate, user.id);
if (!ok) return new Response("Too many requests", { status: 429, headers });

const deal = await prisma.deal.update({ where: { id }, data: body });
return NextResponse.json({ data: deal }, { headers });
}).__ratelimit("dealsUpdate");


export const DELETE = withAuth(async (req, user) => {
const { id } = (req as any).params as { id: string };

const { ok, headers } = await enforce(limiters.dealsDelete, user.id);
if (!ok) return new Response("Too many requests", { status: 429, headers });

await prisma.deal.delete({ where: { id } });
return new Response(null, { status: 204, headers });
}).__ratelimit("dealsDelete");
15 changes: 15 additions & 0 deletions app/api/deals/[id]/status/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { withAuth } from "@/lib/withAuth";
import { limiters, enforce } from "@/lib/rate-limit";

export const POST = withAuth(async (req, user) => {
const { id } = (req as any).params as { id: string };
const { status } = await req.json(); // { status: "OPEN" | … }

const { ok, headers } = await enforce(limiters.dealsStatus, user.id);
if (!ok) return new Response("Too many requests", { status: 429, headers });

const deal = await prisma.deal.update({ where: { id }, data: { status } });
return NextResponse.json({ data: deal }, { headers });
}).__ratelimit("dealsStatus");
26 changes: 26 additions & 0 deletions app/api/deals/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { withAuth } from "@/lib/withAuth";
import { limiters, enforce } from "@/lib/rate-limit";


async function listDeals() {
const deals = await prisma.deal.findMany({
orderBy: { createdAt: "desc" },
take: 50,
});
return NextResponse.json({ data: deals });
}
export const GET = withAuth(listDeals).__ratelimit("dealsRead");

export const POST = withAuth(async (req, user) => {
const values = await req.json();

const { ok, headers } = await enforce(limiters.dealsCreate, user.id);
if (!ok) return new Response("Too many requests", { status: 429, headers });

const deal = await prisma.deal.create({
data: { ...values, userId: user.id },
});
return NextResponse.json({ data: deal }, { status: 201, headers });
}).__ratelimit("dealsCreate");
16 changes: 16 additions & 0 deletions app/api/notifications/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { withAuth } from "@/lib/withAuth";
import { limiters, enforce } from "@/lib/rate-limit";

export const GET = withAuth(async (_req, user) => {
const { ok, headers } = await enforce(limiters.notifications, user.id);
if (!ok) return new Response("Too many requests", { status: 429, headers });

const notes = await prisma.notification.findMany({
where: { recipientId: user.id },
orderBy: { createdAt: "desc" },
take: 25,
});
return NextResponse.json({ data: notes }, { headers });
}).__ratelimit("notifications");
17 changes: 17 additions & 0 deletions app/api/search/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { withAuth } from "@/lib/withAuth";

// 50 requests / minute
async function handler(req: Request) {
const q = new URL(req.url).searchParams.get("q") ?? "";

const matches = await prisma.deal.findMany({
where: { title: { contains: q, mode: "insensitive" } },
take: 25,
});

return NextResponse.json({ data: matches });
}

export const GET = withAuth(handler).__ratelimit("search");
15 changes: 15 additions & 0 deletions app/api/upload/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { withAuth } from "@/lib/withAuth";
import { limiters, enforce } from "@/lib/rate-limit";
import { uploadFile } from "@/lib/upload";

export const POST = withAuth(async (req, user) => {
const form = await req.formData();
const file = form.get("file") as File;

const { ok, headers } = await enforce(limiters.fileUpload, user.id);
if (!ok) return new Response("Too many uploads", { status: 429, headers });

const url = await uploadFile(file, user.id);
return NextResponse.json({ url }, { status: 201, headers });
}).__ratelimit("fileUpload");
9 changes: 9 additions & 0 deletions app/types/ratelimit.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { limiters } from "@/lib/rate-limit";


declare global {
interface Function {
__ratelimit?: (name: keyof typeof limiters) => any;
}
}
export {};
7 changes: 7 additions & 0 deletions lib/ai.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export async function analyzeDeal(id: string) {
return {
id,
summary: "AI analysis coming soon",
score: Math.floor(Math.random() * 100),
};
}
7 changes: 7 additions & 0 deletions lib/mailer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/* Swap in Resend, Postmark, SES, etc. later */
export async function sendReset(email: string) {
console.log(`[dev] sending password-reset link to ${email}`);
// simulate latency
await new Promise((r) => setTimeout(r, 400));
return true;
}
38 changes: 38 additions & 0 deletions lib/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

export const limiters = {
global: new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(50, "1 m") }),
dealsRead: new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(50, "1 m") }),
search: new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(50, "1 m") }),
comments: new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(50, "1 m") }),
authSignin: new Ratelimit({ redis, limiter: Ratelimit.fixedWindow (5, "1 m") }),
authForgot: new Ratelimit({ redis, limiter: Ratelimit.fixedWindow (5, "1 h") }),
authSession: new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(30, "1 m") }),
dealsCreate: new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(10, "1 m") }),
dealsUpdate: new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(20, "1 m") }),
dealsDelete: new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(5, "1 m") }),
dealsStatus: new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(30, "1 m") }),
aiAnalyze: new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(5, "1 m") }),
fileUpload: new Ratelimit({ redis, limiter: Ratelimit.fixedWindow (10, "1 h") }),
notifications: new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(30, "1 m") }),
admin: new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(5, "1 m") }),
};

export async function enforce(limiter: Ratelimit, key: string | undefined) {
const id = key ?? "anonymous";
const { success, limit, remaining, reset } = await limiter.limit(id);
return {
ok: success,
headers: {
"X-RateLimit-Limit": String(limit),
"X-RateLimit-Remaining": String(remaining),
"X-RateLimit-Reset": String(reset),
},
};
}
7 changes: 7 additions & 0 deletions lib/upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/* Replace with S3, Cloudflare R2, or whatever you prefer */
export async function uploadFile(file: File, userId: string): Promise<string> {
// NOTE: File is a web-standard File object in Next 13/14 routes.
// Here we just pretend it uploads and return a fake URL.
const safeName = encodeURIComponent(file.name);
return `https://example.com/uploads/${userId}/${Date.now()}_${safeName}`;
}
120 changes: 55 additions & 65 deletions lib/withAuth.ts
Original file line number Diff line number Diff line change
@@ -1,87 +1,77 @@
import { auth } from "@/auth";
import { getUserById } from "./queries";
import { User } from "@prisma/client";
/* eslint-disable @typescript-eslint/no-misused-promises */
import { auth } from "@/auth";
import { getUserById } from "./queries";
import { User } from "@prisma/client";
import { limiters, enforce } from "@/lib/rate-limit";

async function getUser(req: Request) {
const sessionToken = await auth();
if (!sessionToken) {
return undefined;
}
async function getUser(): Promise<User | undefined> {
const session = await auth();
if (!session) return undefined;

try {
const foundUser = await getUserById(sessionToken.user.id!);
return foundUser;
} catch (error) {
console.log(error);
return undefined;
}
const dbUser = await getUserById(session.user.id!); // returns User|null
return dbUser ?? undefined;
}

/**
* Higher-order function to wrap API routes with authentication checks.
* It verifies the user session and fetches user data before executing the route.
*
* @param handler - The API route function to wrap. It receives the authenticated User object as its second argument, followed by the original arguments.
* @returns An asynchronous function that takes the original API route arguments, performs authentication, and then executes the handler. Returns the handler's result or an error object.
*/

type RLFunc<F> = F & {
__ratelimit: (name: keyof typeof limiters) => RLFunc<F>;
};

export function withAuth(
handler: (request: Request, user: User) => Promise<Response>,
) {
return async (request: Request) => {
// Note: Assuming getUser() can work without the request object
// or the request object is needed by getUser internally.
// If getUser doesn't need request, it can be called as getUser()
const user = await getUser(request);
handler: (req: Request, user: User) => Promise<Response>,
): RLFunc<(req: Request) => Promise<Response>> {
const wrapped = (async (req: Request) => {
const user = await getUser();
if (!user) {
return Response.json(
{ error: "Unauthorized" },
{
status: 401,
},
);
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const lim = (handler as any).__rl as keyof typeof limiters | undefined;
if (lim) {
const { ok, headers } = await enforce(limiters[lim], user.id);
if (!ok) return new Response("Too many requests", { status: 429, headers });
}
return handler(request, user);

return handler(req, user);
}) as RLFunc<(req: Request) => Promise<Response>>;

wrapped.__ratelimit = (name) => {
(handler as any).__rl = name;
return wrapped;
};

return wrapped;
}

/**
* Higher-order function to wrap Server Actions with authentication checks.
* It verifies the user session and fetches user data before executing the action.
*
* @template TArgs - Tuple type representing the arguments of the server action.
* @template TReturn - Return type of the server action.
* @param handler - The server action function to wrap. It receives the authenticated User object as its first argument, followed by the original arguments.
* @returns An asynchronous function that takes the original server action arguments, performs authentication, and then executes the handler. Returns the handler's result or an error object.
*/
export function withAuthServerAction<TArgs extends any[], TReturn>(
// The handler takes User as the first arg, then the original action's args
handler: (user: User, ...args: TArgs) => Promise<TReturn>,
) {
// The returned function takes the original action's args
return async (...args: TArgs): Promise<TReturn | { error: string }> => {
// Fetch the user using the existing getUser logic (which uses auth())
// No request object is passed here as server actions don't inherently have one
const user = await getUser(undefined as any); // Pass undefined or adjust getUser if it doesn't need request
if (!user) {
// Return an error object, common pattern for server actions
return { error: "Unauthorized" };
// Alternatively, could throw: throw new Error("Unauthorized");
): RLFunc<(...args: TArgs) => Promise<TReturn | { error: string }>> {
let limiter: keyof typeof limiters | undefined;

const action = (async (...args: TArgs) => {
const user = await getUser();
if (!user) return { error: "Unauthorized" };

if (limiter) {
const { ok } = await enforce(limiters[limiter], user.id);
if (!ok) return { error: "Too many requests" };
}

try {
// Call the original handler with the authenticated user and the rest of the arguments
return await handler(user, ...args);
} catch (error) {
// Catch errors from the handler execution
console.error("Error in authenticated server action:", error);
// Return an error object if the handler fails
} catch (err) {
console.error("[Action error]", err);
return {
error:
error instanceof Error
? error.message
: "An unexpected error occurred during the action",
err instanceof Error ? err.message : "Unexpected error in action",
};
// Alternatively, rethrow: throw error;
}
}) as RLFunc<(...args: TArgs) => Promise<any>>;

action.__ratelimit = (name) => {
limiter = name;
return action;
};

return action;
}
Loading