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
36 changes: 31 additions & 5 deletions app/api/checkout/route.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { CartItem } from "@/types/merch";
import { checkoutSchema } from "@/lib/validations";
import { checkoutRateLimiter, getClientIdentifier } from "@/lib/rate-limit";
import { ZodError } from "zod";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2025-12-15.clover",
});

export async function POST(request: NextRequest) {
try {
const { items } = (await request.json()) as { items: CartItem[] };

if (!items || items.length === 0) {
return NextResponse.json({ error: "No items in cart" }, { status: 400 });
const identifier = getClientIdentifier(request);
const rateLimit = checkoutRateLimiter.check(identifier);

if (!rateLimit.allowed) {
return NextResponse.json(
{
error: "Rate limit exceeded. Please try again later.",
retryAfter: Math.ceil((rateLimit.resetTime - Date.now()) / 1000)
},
{
status: 429,
headers: {
"X-RateLimit-Limit": "10",
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": new Date(rateLimit.resetTime).toISOString(),
}
}
);
}

const body = await request.json();
const validatedData = checkoutSchema.parse(body);
const { items } = validatedData;

const origin = request.headers.get("origin") || "http://localhost:3000";

const lineItems: Stripe.Checkout.SessionCreateParams.LineItem[] = items.map(
Expand Down Expand Up @@ -68,6 +88,12 @@ export async function POST(request: NextRequest) {

return NextResponse.json({ url: session.url });
} catch (error) {
if (error instanceof ZodError) {
return NextResponse.json(
{ error: "Invalid cart data", details: error.errors },
{ status: 400 }
);
}
console.error("Stripe checkout error:", error);
return NextResponse.json(
{ error: "Failed to create checkout session" },
Expand Down
56 changes: 44 additions & 12 deletions app/api/love-notes/send/route.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,59 @@
import { NextRequest, NextResponse } from "next/server";
import { Resend } from "resend";
import { loveNoteEmailSchema } from "@/lib/validations";
import { emailRateLimiter, getClientIdentifier } from "@/lib/rate-limit";
import { ZodError } from "zod";

// Set RESEND_API_KEY in .env.local (local) or in Vercel/hosting env vars (production).
// Get your key at https://resend.com/api-keys
const resend = new Resend(process.env.RESEND_API_KEY);

function escapeHtml(unsafe: string): string {
return unsafe
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}

export async function POST(request: NextRequest) {
try {
const { recipientEmail, senderName } = (await request.json()) as {
recipientEmail: string;
senderName: string;
};

if (!recipientEmail || !senderName) {
const identifier = getClientIdentifier(request);
const rateLimit = emailRateLimiter.check(identifier);

if (!rateLimit.allowed) {
return NextResponse.json(
{ error: "Missing recipientEmail or senderName" },
{ status: 400 }
{
error: "Rate limit exceeded. Please try again later.",
retryAfter: Math.ceil((rateLimit.resetTime - Date.now()) / 1000)
},
{
status: 429,
headers: {
"X-RateLimit-Limit": "5",
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": new Date(rateLimit.resetTime).toISOString(),
}
}
);
}

const body = await request.json();

const validatedData = loveNoteEmailSchema.parse(body);
const { recipientEmail, senderName } = validatedData;

const safeSenderName = escapeHtml(senderName);
const safeRecipientEmail = recipientEmail;

const { error } = await resend.emails.send({
from: "V1 @ Michigan <valentines@v1michigan.com>",
to: recipientEmail,
to: safeRecipientEmail,
subject: "You've received a Valentine! 💌",
html: `
<div style="font-family: Georgia, serif; max-width: 480px; margin: 0 auto; padding: 32px; text-align: center;">
<h1 style="color: #E11D48; font-size: 24px; margin-bottom: 16px;">You've got a Valentine's note!</h1>
<p style="color: #4B5563; font-size: 16px; line-height: 1.6;">
Hey there :) You have received a letter from <strong>${senderName}</strong>.
Hey there :) You have received a letter from <strong>${safeSenderName}</strong>.
Login at <a href="https://v1michigan.com/valentines" style="color: #E11D48;">v1michigan.com/valentines</a> to see it!
</p>
<p style="color: #9CA3AF; font-size: 14px; margin-top: 24px;">
Expand All @@ -47,6 +73,12 @@ export async function POST(request: NextRequest) {

return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof ZodError) {
return NextResponse.json(
{ error: "Invalid input", details: error.errors },
{ status: 400 }
);
}
console.error("Email send error:", error);
return NextResponse.json(
{ error: "Failed to send email" },
Expand Down
5 changes: 4 additions & 1 deletion components/people-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ async function getPeople(searchQuery?: string) {
.select('id, name, short-bio, full-bio, tags, linkedin, twitter, instagram, website, role, image-path');

if (searchQuery?.trim()) {
query = query.ilike('name', `%${searchQuery.trim()}%`);
const sanitized = searchQuery.trim().slice(0, 100);
if (sanitized.length > 0) {
query = query.ilike('name', `%${sanitized}%`);
}
}

const { data, error } = await query;
Expand Down
82 changes: 82 additions & 0 deletions lib/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
type RateLimitRecord = {
count: number;
resetTime: number;
};

export class RateLimiter {
private limitMap: Map<string, RateLimitRecord>;
private windowMs: number;
private maxRequests: number;

constructor(windowMs: number, maxRequests: number) {
this.limitMap = new Map();
this.windowMs = windowMs;
this.maxRequests = maxRequests;
}

check(identifier: string): { allowed: boolean; remaining: number; resetTime: number } {
const now = Date.now();
const record = this.limitMap.get(identifier);

if (!record || now > record.resetTime) {
const newRecord = { count: 1, resetTime: now + this.windowMs };
this.limitMap.set(identifier, newRecord);
this.cleanup();
return {
allowed: true,
remaining: this.maxRequests - 1,
resetTime: newRecord.resetTime
};
}

if (record.count >= this.maxRequests) {
return {
allowed: false,
remaining: 0,
resetTime: record.resetTime
};
}

record.count++;
return {
allowed: true,
remaining: this.maxRequests - record.count,
resetTime: record.resetTime
};
}

private cleanup(): void {
const now = Date.now();
const entriesToDelete: string[] = [];

for (const [key, record] of this.limitMap.entries()) {
if (now > record.resetTime) {
entriesToDelete.push(key);
}
}

if (entriesToDelete.length > 100) {
entriesToDelete.forEach(key => this.limitMap.delete(key));
}
}

reset(identifier: string): void {
this.limitMap.delete(identifier);
}

clear(): void {
this.limitMap.clear();
}
}

export function getClientIdentifier(request: Request): string {
const headers = request.headers;
const forwarded = headers.get("x-forwarded-for");
const realIp = headers.get("x-real-ip");
const ip = forwarded?.split(",")[0].trim() || realIp || "unknown";
return ip;
}

export const emailRateLimiter = new RateLimiter(60 * 1000, 5);
export const checkoutRateLimiter = new RateLimiter(60 * 1000, 10);
export const apiRateLimiter = new RateLimiter(60 * 1000, 100);
128 changes: 128 additions & 0 deletions lib/validations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { z } from "zod";

export const loveNoteEmailSchema = z.object({
recipientEmail: z
.string()
.email("Invalid email address")
.max(254, "Email address too long")
.toLowerCase()
.trim(),
senderName: z
.string()
.min(1, "Sender name is required")
.max(100, "Sender name too long")
.trim()
.refine((val) => val.length > 0, "Sender name cannot be empty"),
});

export const loveNoteSchema = z.object({
recipientName: z
.string()
.min(1, "Recipient name is required")
.max(100, "Recipient name too long")
.trim(),
recipientEmail: z
.string()
.email("Invalid email address")
.max(254, "Email address too long")
.toLowerCase()
.trim(),
messageText: z
.string()
.min(1, "Message is required")
.max(400, "Message too long (max 400 characters)")
.trim(),
backgroundColor: z
.string()
.regex(/^#[0-9A-Fa-f]{6}$/, "Invalid color format")
.default("#E11D48"),
templateId: z.string().uuid().optional().nullable(),
});

export const profileUpdateSchema = z.object({
name: z
.string()
.min(1, "Name is required")
.max(100, "Name too long")
.trim(),
role: z
.string()
.max(100, "Role too long")
.trim(),
shortBio: z
.string()
.max(500, "Short bio too long (max 500 characters)")
.trim()
.optional(),
fullBio: z
.string()
.max(2000, "Full bio too long (max 2000 characters)")
.trim()
.optional(),
tags: z
.array(
z
.string()
.max(50, "Tag too long")
.regex(/^[a-zA-Z0-9\s-]+$/, "Tags can only contain letters, numbers, spaces, and hyphens")
)
.max(20, "Too many tags (max 20)")
.optional(),
linkedin: z
.string()
.url("Invalid LinkedIn URL")
.max(255)
.optional()
.or(z.literal("")),
twitter: z
.string()
.url("Invalid Twitter URL")
.max(255)
.optional()
.or(z.literal("")),
instagram: z
.string()
.url("Invalid Instagram URL")
.max(255)
.optional()
.or(z.literal("")),
website: z
.string()
.url("Invalid website URL")
.max(255)
.optional()
.or(z.literal("")),
});

export const searchQuerySchema = z.object({
q: z
.string()
.max(100, "Search query too long")
.trim()
.optional(),
});

export const checkoutSchema = z.object({
items: z
.array(
z.object({
product: z.object({
id: z.string(),
name: z.string().max(100),
price: z.number().int().positive().max(1000000),
image: z.string().max(500),
}),
size: z.string().max(20),
color: z.string().max(50),
quantity: z.number().int().positive().max(99),
})
)
.min(1, "Cart cannot be empty")
.max(50, "Too many items in cart"),
});

export type LoveNoteEmail = z.infer<typeof loveNoteEmailSchema>;
export type LoveNote = z.infer<typeof loveNoteSchema>;
export type ProfileUpdate = z.infer<typeof profileUpdateSchema>;
export type SearchQuery = z.infer<typeof searchQuerySchema>;
export type CheckoutRequest = z.infer<typeof checkoutSchema>;
Loading