Update features and dependencies

This commit is contained in:
Arnaud
2026-07-01 11:10:37 +02:00
parent 9d9dfb46c6
commit 8b57a3fd87
107 changed files with 14654 additions and 458 deletions
@@ -15,11 +15,7 @@ export async function POST(req: NextRequest) {
subject: "Epicure — test email",
html: verifyEmailHtml(`${process.env["BETTER_AUTH_URL"] ?? "http://localhost:3001"}/verify-email?token=test`),
});
return NextResponse.json({
ok: true,
smtp_host: process.env["SMTP_HOST"] ?? null,
smtp_user: process.env["SMTP_USER"] ?? null,
});
return NextResponse.json({ ok: true });
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
+8 -6
View File
@@ -3,9 +3,10 @@ import { z } from "zod";
import { and, eq } from "@epicure/db";
import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
import { checkAndIncrementTierLimit } from "@/lib/tiers";
import { adaptRecipe } from "@/lib/ai/features/adapt-recipe";
import { withUserKey } from "@/lib/ai/resolve-user-key";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
const Schema = z.object({
excludeIngredients: z.array(z.string()).default([]),
@@ -42,9 +43,12 @@ export async function POST(req: NextRequest, { params }: Params) {
return NextResponse.json({ error: "Provide at least one constraint" }, { status: 400 });
}
await checkTierLimit(userId, session!.user.tier as "free" | "pro", "aiCall");
await checkAndIncrementTierLimit(userId, session!.user.tier as "free" | "pro", "aiCall");
const aiConfig = await withUserKey(userId, { provider: parsed.data.provider, model: parsed.data.model });
const [aiConfig, privateBio] = await Promise.all([
withUserKey(userId, { provider: parsed.data.provider, model: parsed.data.model }),
getUserPrivateBio(userId),
]);
const adapted = await adaptRecipe(
{
title: recipe.title,
@@ -57,12 +61,10 @@ export async function POST(req: NextRequest, { params }: Params) {
excludeIngredients: parsed.data.excludeIngredients,
extraConstraints: parsed.data.extraConstraints,
},
aiConfig,
{ ...aiConfig, userContext: privateBio ?? undefined },
(session!.user as { locale?: string }).locale ?? "en"
);
await incrementUsage(userId, "aiCall");
const newId = crypto.randomUUID();
const now = new Date();
+8 -6
View File
@@ -3,9 +3,10 @@ import { z } from "zod";
import { and, eq } from "@epicure/db";
import { db, recipes } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
import { checkAndIncrementTierLimit } from "@/lib/tiers";
import { suggestDrinks } from "@/lib/ai/features/suggest-drinks";
import { withUserKey } from "@/lib/ai/resolve-user-key";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
const Schema = z.object({
count: z.number().int().min(1).max(6).default(4),
@@ -33,9 +34,12 @@ export async function POST(req: NextRequest, { params }: Params) {
const parsed = Schema.safeParse(body ?? {});
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
const aiConfig = await withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model });
const [aiConfig, privateBio] = await Promise.all([
withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model }),
getUserPrivateBio(session!.user.id),
]);
const drinks = await suggestDrinks(
{
title: recipe.title,
@@ -45,11 +49,9 @@ export async function POST(req: NextRequest, { params }: Params) {
ingredients: recipe.ingredients,
},
parsed.data.count,
aiConfig,
{ ...aiConfig, userContext: privateBio ?? undefined },
(session!.user as { locale?: string }).locale ?? "en"
);
await incrementUsage(session!.user.id, "aiCall");
return NextResponse.json({ drinks });
}
@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { checkAndIncrementTierLimit } from "@/lib/tiers";
import { generateRecipe } from "@/lib/ai/features/generate-recipe";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
import { parseQuantity } from "@/lib/parse-quantity";
const Schema = z.object({
title: z.string().min(1).max(200),
provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(),
model: z.string().optional(),
});
export async function POST(req: NextRequest) {
const { session, response } = await requireSession();
if (response) return response;
const body = await req.json() as unknown;
const parsed = Schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Validation error" }, { status: 400 });
}
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
if (limited) return limited;
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
const privateBio = await getUserPrivateBio(session!.user.id);
const recipe = await generateRecipe(parsed.data.title, {
provider: parsed.data.provider,
model: parsed.data.model,
userContext: privateBio ?? undefined,
});
const recipeId = crypto.randomUUID();
await db.insert(recipes).values({
id: recipeId,
authorId: session!.user.id,
title: recipe.title,
description: recipe.description ?? null,
baseServings: recipe.baseServings,
prepMins: recipe.prepMins ?? null,
cookMins: recipe.cookMins ?? null,
difficulty: recipe.difficulty ?? null,
visibility: "private",
aiGenerated: true,
language: "en",
dietaryTags: recipe.dietaryTags ?? {},
tags: [],
});
if (recipe.ingredients?.length) {
await db.insert(recipeIngredients).values(
recipe.ingredients.map((ing, i) => ({
id: crypto.randomUUID(),
recipeId,
rawName: ing.rawName,
quantity: parseQuantity(ing.quantity) ?? null,
unit: ing.unit ?? null,
note: ing.note ?? null,
order: i,
}))
);
}
if (recipe.steps?.length) {
await db.insert(recipeSteps).values(
recipe.steps.map((step, i) => ({
id: crypto.randomUUID(),
recipeId,
instruction: step.instruction,
timerSeconds: step.timerSeconds ?? null,
order: i,
}))
);
}
return NextResponse.json({ id: recipeId });
}
+8 -4
View File
@@ -2,12 +2,14 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
import { checkAndIncrementTierLimit } from "@/lib/tiers";
import { generateRecipe } from "@/lib/ai/features/generate-recipe";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
const Schema = z.object({
prompt: z.string().min(3).max(500),
language: z.string().max(10).default("en"),
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(),
model: z.string().optional(),
});
@@ -25,15 +27,17 @@ export async function POST(req: NextRequest) {
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
if (limited) return limited;
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
const privateBio = await getUserPrivateBio(session!.user.id);
const recipe = await generateRecipe(parsed.data.prompt, {
provider: parsed.data.provider,
model: parsed.data.model,
language: parsed.data.language,
difficulty: parsed.data.difficulty,
userContext: privateBio ?? undefined,
});
await incrementUsage(session!.user.id, "aiCall");
return NextResponse.json(recipe);
}
+10 -10
View File
@@ -3,7 +3,7 @@ import { z } from "zod";
import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers";
import { importFromPhoto } from "@/lib/ai/features/import-photo";
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
@@ -25,16 +25,18 @@ export async function POST(req: NextRequest) {
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 5, 60);
if (limited) return limited;
try {
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Tier limit reached";
return NextResponse.json({ error: msg }, { status: 403 });
}
const userId = session!.user.id;
const locale = (session!.user as { locale?: string }).locale ?? "en";
try {
await checkAndIncrementTierLimit(userId, session!.user.tier as "free" | "pro", "aiCall");
} catch (err: unknown) {
if (err instanceof TierLimitError) {
return NextResponse.json({ error: "AI call limit reached for your tier" }, { status: 403 });
}
throw err;
}
const aiConfig = await getModelConfigForUseCase(userId, "vision");
// Fall back to vision-capable defaults if no explicit model configured
@@ -51,8 +53,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: msg }, { status: 502 });
}
await incrementUsage(userId, "aiCall");
const newRecipeId = crypto.randomUUID();
const now = new Date();
+8 -4
View File
@@ -2,8 +2,9 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
import { checkAndIncrementTierLimit } from "@/lib/tiers";
import { importFromUrl } from "@/lib/ai/features/import-url";
import { validateWebhookUrl } from "@/lib/validate-webhook-url";
const Schema = z.object({
url: z.string().url(),
@@ -21,17 +22,20 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
const ssrfError = await validateWebhookUrl(parsed.data.url);
if (ssrfError) {
return NextResponse.json({ error: ssrfError }, { status: 400 });
}
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
if (limited) return limited;
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
const recipe = await importFromUrl(parsed.data.url, {
provider: parsed.data.provider,
model: parsed.data.model,
});
await incrementUsage(session!.user.id, "aiCall");
return NextResponse.json(recipe);
}
@@ -5,6 +5,7 @@ import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key";
import { generateMealPlan } from "@/lib/ai/features/generate-meal-plan";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
const DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
@@ -15,6 +16,7 @@ const Schema = z.object({
days: z.array(z.enum(DAYS)).min(1).max(7).default([...DAYS]),
usePantry: z.boolean().default(false),
pantryMode: z.boolean().default(false),
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
});
export async function POST(req: NextRequest) {
@@ -32,7 +34,10 @@ export async function POST(req: NextRequest) {
const userId = session!.user.id;
const locale = (session!.user as { locale?: string }).locale ?? "en";
const config = await getDefaultProviderWithKey(userId);
const [config, privateBio] = await Promise.all([
getDefaultProviderWithKey(userId),
getUserPrivateBio(userId),
]);
// pantryMode forces usePantry on so pantry items are always fetched when maximizing pantry use
const effectiveUsePantry = parsed.data.usePantry || parsed.data.pantryMode;
@@ -54,8 +59,9 @@ export async function POST(req: NextRequest) {
pantryItems: pantryItemNames,
days: parsed.data.days,
pantryMode: parsed.data.pantryMode,
difficulty: parsed.data.difficulty,
},
config,
{ ...config, userContext: privateBio ?? undefined },
locale
);
@@ -94,7 +100,7 @@ export async function POST(req: NextRequest) {
id: crypto.randomUUID(),
recipeId,
rawName: ing.rawName,
quantity: ing.quantity ?? null,
quantity: ing.quantity != null ? String(ing.quantity) : null,
unit: ing.unit ?? null,
order: i,
}))
@@ -3,9 +3,10 @@ import { z } from "zod";
import { and, eq } from "@epicure/db";
import { db, recipes } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
import { checkAndIncrementTierLimit } from "@/lib/tiers";
import { suggestPairings } from "@/lib/ai/features/suggest-pairings";
import { withUserKey } from "@/lib/ai/resolve-user-key";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
const Schema = z.object({
count: z.number().int().min(1).max(6).default(4),
@@ -34,9 +35,12 @@ export async function POST(req: NextRequest, { params }: Params) {
const parsed = Schema.safeParse(body ?? {});
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
const aiConfig = await withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model });
const [aiConfig, privateBio] = await Promise.all([
withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model }),
getUserPrivateBio(session!.user.id),
]);
const pairings = await suggestPairings(
{
title: recipe.title,
@@ -46,11 +50,9 @@ export async function POST(req: NextRequest, { params }: Params) {
ingredients: recipe.ingredients,
},
parsed.data.count,
aiConfig,
{ ...aiConfig, userContext: privateBio ?? undefined },
(session!.user as { locale?: string }).locale ?? "en"
);
await incrementUsage(session!.user.id, "aiCall");
return NextResponse.json({ pairings });
}
@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { generateText } from "ai";
import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
import { resolveModel } from "@/lib/ai/factory";
import { db, recipes, eq, and } from "@epicure/db";
import { getUserPrivateBio, buildUserBioContext } from "@/lib/ai/user-bio";
const Schema = z.object({
recipeId: z.string().uuid(),
question: z.string().min(1).max(500),
});
export async function POST(req: NextRequest) {
const { session, response } = await requireSession();
if (response) return response;
const body = await req.json() as unknown;
const parsed = Schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Validation error" }, { status: 400 });
}
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 30, 60);
if (limited) return limited;
const recipe = await db.query.recipes.findFirst({
where: and(eq(recipes.id, parsed.data.recipeId), eq(recipes.authorId, session!.user.id)),
with: {
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
steps: { orderBy: (t, { asc }) => asc(t.order) },
},
});
if (!recipe) {
return NextResponse.json({ error: "Recipe not found" }, { status: 404 });
}
const ingredientList = recipe.ingredients
.map((ing) => [ing.quantity, ing.unit, ing.rawName, ing.note ? `(${ing.note})` : ""].filter(Boolean).join(" "))
.join("\n");
const stepList = recipe.steps
.map((s, i) => `${i + 1}. ${s.instruction}`)
.join("\n");
const recipeContext = `
Recipe: ${recipe.title}
${recipe.description ? `Description: ${recipe.description}` : ""}
Difficulty: ${recipe.difficulty ?? "unknown"}
Prep: ${recipe.prepMins ?? 0}min | Cook: ${recipe.cookMins ?? 0}min | Servings: ${recipe.baseServings}
INGREDIENTS:
${ingredientList || "None listed"}
STEPS:
${stepList || "None listed"}
`.trim();
const [config, privateBio] = await Promise.all([
getModelConfigForUseCase(session!.user.id, "text"),
getUserPrivateBio(session!.user.id),
]);
const model = resolveModel(config);
const bioContext = buildUserBioContext(privateBio);
const { text } = await generateText({
model,
system: `You are a helpful culinary assistant. Answer questions about the following recipe concisely and accurately. If a question is not related to the recipe or cooking, politely redirect. Keep answers under 200 words.
${recipeContext}${bioContext}`,
prompt: parsed.data.question,
});
return NextResponse.json({ answer: text });
}
@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { generateObject } from "ai";
import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
import { resolveModel } from "@/lib/ai/factory";
import { getUserPrivateBio, buildUserBioContext } from "@/lib/ai/user-bio";
const InputSchema = z.object({
prompt: z.string().max(300).optional(),
});
const IdeasSchema = z.object({
ideas: z.array(z.object({
title: z.string(),
description: z.string(),
tags: z.array(z.string()).max(4),
difficulty: z.enum(["easy", "medium", "hard"]),
totalMins: z.number().int().optional(),
})).length(6),
});
export async function POST(req: NextRequest) {
const { session, response } = await requireSession();
if (response) return response;
const body = await req.json() as unknown;
const parsed = InputSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Validation error" }, { status: 400 });
}
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
if (limited) return limited;
const [config, privateBio] = await Promise.all([
getModelConfigForUseCase(session!.user.id, "text"),
getUserPrivateBio(session!.user.id),
]);
const model = resolveModel(config);
const bioContext = buildUserBioContext(privateBio);
const userContext = bioContext
? `Consider the following user preferences when suggesting ideas:${bioContext}\n\n`
: "";
const prompt = parsed.data.prompt?.trim()
? `${userContext}Generate 6 diverse recipe ideas based on: "${parsed.data.prompt}". Include a mix of difficulty levels.`
: `${userContext}Generate 6 diverse, creative recipe ideas. Include different cuisines, difficulty levels, and meal types.`;
const { object } = await generateObject({
model,
schema: IdeasSchema,
prompt,
});
return NextResponse.json(object.ideas);
}
+2 -4
View File
@@ -4,7 +4,7 @@ import { db, recipes, eq, and, or } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key";
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
import { checkAndIncrementTierLimit } from "@/lib/tiers";
import { scaleRecipe } from "@/lib/ai/features/scale-recipe";
const Schema = z.object({
@@ -40,7 +40,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
const aiConfig = await getDefaultProviderWithKey(session!.user.id);
@@ -56,7 +56,5 @@ export async function POST(req: NextRequest) {
(session!.user as { locale?: string }).locale ?? "en"
);
await incrementUsage(session!.user.id, "aiCall");
return NextResponse.json({ ingredients: scaledIngredients });
}
+13 -3
View File
@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/api-auth";
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers";
import { applyRateLimit } from "@/lib/rate-limit";
import { substituteIngredient } from "@/lib/ai/features/substitute-ingredient";
const Schema = z.object({
@@ -19,7 +20,17 @@ export async function POST(req: NextRequest) {
const parsed = Schema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
const rateLimitRes = await applyRateLimit(`ai:substitute:${session!.user.id}`, 10, 60);
if (rateLimitRes) return rateLimitRes;
try {
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
} catch (err) {
if (err instanceof TierLimitError) {
return NextResponse.json({ error: "AI call limit reached for your tier" }, { status: 403 });
}
throw err;
}
const context = parsed.data.recipeTitle
? `recipe "${parsed.data.recipeTitle}"`
@@ -31,6 +42,5 @@ export async function POST(req: NextRequest) {
{ provider: parsed.data.provider, model: parsed.data.model }
);
await incrementUsage(session!.user.id, "aiCall");
return NextResponse.json({ substitutions });
}
@@ -3,7 +3,7 @@ import { z } from "zod";
import { and, eq } from "@epicure/db";
import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
import { checkAndIncrementTierLimit } from "@/lib/tiers";
import { translateRecipe } from "@/lib/ai/features/translate-recipe";
const Schema = z.object({
@@ -35,7 +35,7 @@ export async function POST(req: NextRequest, { params }: Params) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
const translation = await translateRecipe(
{
@@ -48,8 +48,6 @@ export async function POST(req: NextRequest, { params }: Params) {
{ provider: parsed.data.provider, model: parsed.data.model }
);
await incrementUsage(session!.user.id, "aiCall");
// Save as new draft recipe
const newId = crypto.randomUUID();
const now = new Date();
@@ -3,9 +3,10 @@ import { z } from "zod";
import { and, eq } from "@epicure/db";
import { db, recipes } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
import { checkAndIncrementTierLimit } from "@/lib/tiers";
import { suggestVariations } from "@/lib/ai/features/suggest-variations";
import { withUserKey } from "@/lib/ai/resolve-user-key";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
const Schema = z.object({
count: z.number().int().min(1).max(5).default(3),
@@ -37,9 +38,12 @@ export async function POST(req: NextRequest, { params }: Params) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
const aiConfig = await withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model });
const [aiConfig, privateBio] = await Promise.all([
withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model }),
getUserPrivateBio(session!.user.id),
]);
const variations = await suggestVariations(
{
title: recipe.title,
@@ -48,12 +52,10 @@ export async function POST(req: NextRequest, { params }: Params) {
steps: recipe.steps,
},
parsed.data.count,
aiConfig,
{ ...aiConfig, userContext: privateBio ?? undefined },
parsed.data.directions,
(session!.user as { locale?: string }).locale ?? "en"
);
await incrementUsage(session!.user.id, "aiCall");
return NextResponse.json({ variations });
}
+9 -1
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto";
import { z } from "zod";
import { db, apiKeys, eq } from "@epicure/db";
import { db, apiKeys, eq, sql } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
const CreateApiKeyBody = z.object({
@@ -38,6 +38,14 @@ export async function POST(req: NextRequest) {
);
}
const [row] = await db
.select({ count: sql<number>`count(*)::int` })
.from(apiKeys)
.where(eq(apiKeys.userId, session!.user.id));
if ((row?.count ?? 0) >= 10) {
return NextResponse.json({ error: "API key limit reached (max 10)" }, { status: 403 });
}
const rawKey = "ek_" + crypto.randomBytes(32).toString("hex");
const keyHash = crypto.createHash("sha256").update(rawKey).digest("hex");
const id = crypto.randomUUID();
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, collections, collectionRecipes, recipes, eq, and } from "@epicure/db";
import { db, collections, collectionRecipes, recipes, eq, and, or, ne } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
type Params = { params: Promise<{ id: string }> };
@@ -50,7 +50,12 @@ export async function PUT(req: NextRequest, { params }: Params) {
}
if (data.addRecipeId) {
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, data.addRecipeId) });
const recipe = await db.query.recipes.findFirst({
where: and(
eq(recipes.id, data.addRecipeId),
or(eq(recipes.authorId, session!.user.id), ne(recipes.visibility, "private"))
),
});
if (recipe) {
await db.insert(collectionRecipes).values({ collectionId: id, recipeId: data.addRecipeId }).onConflictDoNothing();
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { db, recipes, users, userFollows, eq } from "@epicure/db";
import { db, recipes, users, userFollows, eq, and, ne } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { desc, inArray } from "@epicure/db";
@@ -42,7 +42,7 @@ export async function GET(req: NextRequest) {
})
.from(recipes)
.innerJoin(users, eq(recipes.authorId, users.id))
.where((t) => inArray(t.authorId, followedIds))
.where((t) => and(inArray(t.authorId, followedIds), ne(recipes.visibility, "private")))
.orderBy(desc(recipes.createdAt))
.limit(limit)
.offset(offset);
+2 -2
View File
@@ -7,8 +7,8 @@ const Schema = z.object({
items: z.array(z.object({
rawName: z.string().min(1).max(200),
quantity: z.string().optional(),
unit: z.string().optional(),
})).min(1),
unit: z.string().max(50).optional(),
})).min(1).max(100),
});
export async function POST(req: NextRequest) {
+1 -1
View File
@@ -39,10 +39,10 @@ export async function POST(request: Request) {
.onConflictDoUpdate({
target: pushSubscriptions.endpoint,
set: {
userId: session!.user.id,
p256dh: body.keys.p256dh,
auth: body.keys.auth,
},
setWhere: eq(pushSubscriptions.userId, session!.user.id),
});
return NextResponse.json({ ok: true });
@@ -30,7 +30,8 @@ export async function GET(req: NextRequest, { params }: Params) {
counts[row.type] = row.cnt;
}
// Check for session to return user's reactions
// Optional auth — GET reactions is public; session present means also return user's own reactions
// response intentionally ignored: unauthenticated callers get counts only with empty userReactions
const { session } = await requireSession();
let userReactions: string[] = [];
@@ -6,7 +6,7 @@ import { requireSession } from "@/lib/api-auth";
type Params = { params: Promise<{ id: string }> };
const Schema = z.object({
servings: z.number().int().min(1).optional(),
servings: z.number().int().min(1).max(1000).optional(),
notes: z.string().max(2000).optional(),
deductFromPantry: z.boolean().default(true),
});
@@ -56,7 +56,7 @@ export async function POST(_req: NextRequest, { params }: Params) {
})),
});
await db.update(recipes).set({ nutritionData: result }).where(eq(recipes.id, id));
await db.update(recipes).set({ nutritionData: result }).where(and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)));
return NextResponse.json({ nutrition: result });
}
+14 -11
View File
@@ -4,15 +4,17 @@ import { eq, and, max } from "@epicure/db";
import { z } from "zod";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { dispatchWebhook } from "@/lib/webhooks";
import { parseQuantity } from "@/lib/parse-quantity";
const UpdateRecipeSchema = z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().optional(),
description: z.string().max(2000).optional(),
baseServings: z.number().int().min(1).max(100).optional(),
visibility: z.enum(["private", "unlisted", "public"]).optional(),
difficulty: z.enum(["easy", "medium", "hard"]).nullable().optional(),
prepMins: z.number().int().min(0).nullable().optional(),
cookMins: z.number().int().min(0).nullable().optional(),
prepMins: z.number().int().min(0).max(1440).nullable().optional(),
cookMins: z.number().int().min(0).max(1440).nullable().optional(),
tags: z.array(z.string().min(1).max(50)).max(20).optional(),
dietaryTags: z.object({
vegan: z.boolean().optional(),
vegetarian: z.boolean().optional(),
@@ -23,17 +25,17 @@ const UpdateRecipeSchema = z.object({
kosher: z.boolean().optional(),
}).optional(),
ingredients: z.array(z.object({
rawName: z.string().min(1),
quantity: z.string().optional(),
unit: z.string().optional(),
note: z.string().optional(),
rawName: z.string().min(1).max(200),
quantity: z.union([z.number(), z.string().max(50)]).optional().transform(parseQuantity),
unit: z.string().max(50).optional(),
note: z.string().max(500).optional(),
order: z.number().int().default(0),
})).optional(),
})).max(100).optional(),
steps: z.array(z.object({
instruction: z.string().min(1),
timerSeconds: z.number().int().optional(),
instruction: z.string().min(1).max(2000),
timerSeconds: z.number().int().min(0).max(86400).optional(),
order: z.number().int(),
})).optional(),
})).max(100).optional(),
});
type Params = { params: Promise<{ id: string }> };
@@ -119,6 +121,7 @@ export async function PUT(req: NextRequest, { params }: Params) {
if (data.difficulty !== undefined) updates.difficulty = data.difficulty ?? undefined;
if (data.prepMins !== undefined) updates.prepMins = data.prepMins ?? undefined;
if (data.cookMins !== undefined) updates.cookMins = data.cookMins ?? undefined;
if (data.tags !== undefined) updates.tags = data.tags;
if (data.dietaryTags !== undefined) updates.dietaryTags = data.dietaryTags;
await tx.update(recipes).set(updates).where(eq(recipes.id, id));
@@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// --- Mock dependencies ---
const mockSession = {
user: { id: "user-1", name: "Test User", email: "test@test.com", tier: "free", role: "user" },
};
vi.mock("@/lib/api-auth", () => ({
requireSessionOrApiKey: vi.fn(),
requireSession: vi.fn(),
}));
vi.mock("@/lib/tiers", () => ({
checkTierLimit: vi.fn(),
incrementUsage: vi.fn(),
TierLimitError: class TierLimitError extends Error {},
}));
vi.mock("@/lib/webhooks", () => ({
dispatchWebhook: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
applyRateLimit: vi.fn().mockResolvedValue(null),
}));
const { mockTransaction, mockInsertValues, mockSelectChain } = vi.hoisted(() => {
const mockInsertValues = vi.fn().mockResolvedValue(undefined);
const mockSelectChain = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
offset: vi.fn().mockResolvedValue([]),
};
return { mockTransaction: vi.fn(), mockInsertValues, mockSelectChain };
});
vi.mock("@epicure/db", () => ({
db: {
select: vi.fn(() => mockSelectChain),
insert: vi.fn(() => ({ values: mockInsertValues })),
transaction: mockTransaction,
query: {
recipes: { findFirst: vi.fn().mockResolvedValue({ id: "new-id", title: "Test Recipe" }) },
},
},
recipes: { id: "id", authorId: "author_id", title: "title", visibility: "visibility" },
recipeIngredients: {},
recipeSteps: {},
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
desc: vi.fn((a) => ({ a, op: "desc" })),
and: vi.fn((...args) => ({ args, op: "and" })),
count: vi.fn(() => ({ op: "count" })),
ilike: vi.fn((a, b) => ({ a, b, op: "ilike" })),
or: vi.fn((...args) => ({ args, op: "or" })),
sql: vi.fn(),
}));
const { requireSessionOrApiKey } = await import("@/lib/api-auth");
import { GET, POST } from "../route";
function makeRequest(method: string, body?: unknown, search = "") {
const url = `http://localhost/api/v1/recipes${search}`;
return new NextRequest(url, {
method,
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireSessionOrApiKey).mockResolvedValue({ session: mockSession as never, response: null });
mockTransaction.mockImplementation(async (fn: (tx: unknown) => Promise<unknown>) => {
const tx = {
insert: vi.fn(() => ({ values: vi.fn().mockResolvedValue(undefined) })),
};
return fn(tx);
});
});
describe("GET /api/v1/recipes", () => {
it("returns 200 with empty list", async () => {
mockSelectChain.offset.mockResolvedValue([]);
const res = await GET(makeRequest("GET"));
expect(res.status).toBe(200);
const body = await res.json() as { data: unknown[] };
expect(Array.isArray(body.data)).toBe(true);
});
it("returns 401 when not authenticated", async () => {
vi.mocked(requireSessionOrApiKey).mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
} as never);
const res = await GET(makeRequest("GET"));
expect(res.status).toBe(401);
});
});
describe("POST /api/v1/recipes", () => {
const validRecipe = {
title: "Test Recipe",
baseServings: 4,
visibility: "private",
ingredients: [{ rawName: "flour", quantity: "2", unit: "cups" }],
steps: [{ instruction: "Mix everything" }],
};
it("returns 201 on valid recipe creation", async () => {
const res = await POST(makeRequest("POST", validRecipe));
expect(res.status).toBe(201);
const body = await res.json() as { id: string };
expect(body.id).toBeTruthy();
});
it("returns 400 on invalid body (missing title)", async () => {
const res = await POST(makeRequest("POST", { baseServings: 4 }));
expect(res.status).toBe(400);
});
it("returns 400 when title is empty", async () => {
const res = await POST(makeRequest("POST", { ...validRecipe, title: "" }));
expect(res.status).toBe(400);
});
it("returns 400 when title is too long", async () => {
const res = await POST(makeRequest("POST", { ...validRecipe, title: "x".repeat(201) }));
expect(res.status).toBe(400);
});
it("returns 403 when tier limit reached", async () => {
const { checkTierLimit } = await import("@/lib/tiers");
const { TierLimitError } = await import("@/lib/tiers");
vi.mocked(checkTierLimit).mockRejectedValue(new TierLimitError("recipe", "free"));
const res = await POST(makeRequest("POST", validRecipe));
expect(res.status).toBe(403);
});
it("returns 401 when not authenticated", async () => {
vi.mocked(requireSessionOrApiKey).mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
} as never);
const res = await POST(makeRequest("POST", validRecipe));
expect(res.status).toBe(401);
});
});
@@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
const mockSession = {
user: { id: "user-1", name: "Test", email: "t@t.com", tier: "free" },
};
const { mockRequireSession } = vi.hoisted(() => ({
mockRequireSession: vi.fn(),
}));
vi.mock("@/lib/api-auth", () => ({
requireSession: mockRequireSession,
}));
vi.mock("@epicure/db", () => ({
db: {
delete: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })),
update: vi.fn(() => ({
set: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })),
})),
},
recipes: { id: "id", authorId: "author_id", visibility: "visibility" },
eq: vi.fn(),
and: vi.fn(),
inArray: vi.fn(),
}));
import { DELETE, PATCH } from "../route";
function makeRequest(method: string, body: unknown) {
return new NextRequest(`http://localhost/api/v1/recipes/bulk`, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.clearAllMocks();
mockRequireSession.mockResolvedValue({ session: mockSession, response: null });
});
describe("DELETE /api/v1/recipes/bulk", () => {
it("returns 200 on valid ids", async () => {
const res = await DELETE(makeRequest("DELETE", { ids: ["r1", "r2"] }));
expect(res.status).toBe(200);
const body = await res.json() as { ok: boolean };
expect(body.ok).toBe(true);
});
it("returns 400 when ids is empty array", async () => {
const res = await DELETE(makeRequest("DELETE", { ids: [] }));
expect(res.status).toBe(400);
});
it("returns 400 when body is invalid", async () => {
const res = await DELETE(makeRequest("DELETE", { notIds: "wrong" }));
expect(res.status).toBe(400);
});
it("returns 401 when not authenticated", async () => {
mockRequireSession.mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
});
const res = await DELETE(makeRequest("DELETE", { ids: ["r1"] }));
expect(res.status).toBe(401);
});
});
describe("PATCH /api/v1/recipes/bulk", () => {
it("returns 200 on valid visibility update", async () => {
const res = await PATCH(makeRequest("PATCH", { ids: ["r1"], visibility: "public" }));
expect(res.status).toBe(200);
});
it("returns 400 when visibility is missing", async () => {
const res = await PATCH(makeRequest("PATCH", { ids: ["r1"] }));
expect(res.status).toBe(400);
});
it("returns 400 when ids is empty", async () => {
const res = await PATCH(makeRequest("PATCH", { ids: [], visibility: "public" }));
expect(res.status).toBe(400);
});
it("returns 401 when not authenticated", async () => {
mockRequireSession.mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
});
const res = await PATCH(makeRequest("PATCH", { ids: ["r1"], visibility: "public" }));
expect(res.status).toBe(401);
});
});
+6 -6
View File
@@ -13,22 +13,22 @@ const BulkUpdateSchema = z.object({
});
export async function DELETE(req: NextRequest) {
const session = await requireSession();
if (session instanceof NextResponse) return session;
const { session, response } = await requireSession();
if (response) return response;
const body = BulkDeleteSchema.safeParse(await req.json());
if (!body.success) return NextResponse.json({ error: "Invalid request" }, { status: 400 });
await db
.delete(recipes)
.where(and(inArray(recipes.id, body.data.ids), eq(recipes.authorId, session.user.id)));
.where(and(inArray(recipes.id, body.data.ids), eq(recipes.authorId, session!.user.id)));
return NextResponse.json({ ok: true });
}
export async function PATCH(req: NextRequest) {
const session = await requireSession();
if (session instanceof NextResponse) return session;
const { session, response } = await requireSession();
if (response) return response;
const body = BulkUpdateSchema.safeParse(await req.json());
if (!body.success) return NextResponse.json({ error: "Invalid request" }, { status: 400 });
@@ -39,7 +39,7 @@ export async function PATCH(req: NextRequest) {
await db
.update(recipes)
.set({ visibility, updatedAt: new Date() })
.where(and(inArray(recipes.id, ids), eq(recipes.authorId, session.user.id)));
.where(and(inArray(recipes.id, ids), eq(recipes.authorId, session!.user.id)));
return NextResponse.json({ ok: true });
}
+24 -52
View File
@@ -3,56 +3,21 @@ import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
import { eq, desc, and } from "@epicure/db";
import { z } from "zod";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers";
import { dispatchWebhook } from "@/lib/webhooks";
const UNICODE_FRACTIONS: Record<string, number> = {
"½": 0.5, "⅓": 1/3, "⅔": 2/3, "¼": 0.25, "¾": 0.75,
"⅕": 0.2, "⅖": 0.4, "⅗": 0.6, "⅘": 0.8,
"⅙": 1/6, "⅚": 5/6, "⅛": 0.125, "⅜": 0.375, "⅝": 0.625, "⅞": 0.875,
};
function parseQuantity(v: string | number | undefined): string | undefined {
if (v === undefined || v === "") return undefined;
const s = String(v).trim();
if (!s) return undefined;
if (UNICODE_FRACTIONS[s] !== undefined) return String(UNICODE_FRACTIONS[s]);
for (const [frac, val] of Object.entries(UNICODE_FRACTIONS)) {
if (s.endsWith(frac)) {
const whole = s.slice(0, -frac.length).trim();
if (!whole) return String(val);
const w = parseFloat(whole);
if (!isNaN(w)) return String(w + val);
}
}
const mixed = s.match(/^(\d+)\s+(\d+)\s*\/\s*(\d+)$/);
if (mixed) {
const den = parseInt(mixed[3]!);
if (den !== 0) return String(parseInt(mixed[1]!) + parseInt(mixed[2]!) / den);
}
const slash = s.match(/^(\d+)\s*\/\s*(\d+)$/);
if (slash) {
const den = parseInt(slash[2]!);
if (den !== 0) return String(parseInt(slash[1]!) / den);
}
const n = parseFloat(s);
return isNaN(n) ? undefined : String(n);
}
import { parseQuantity } from "@/lib/parse-quantity";
const CreateRecipeSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().optional(),
description: z.string().max(2000).optional(),
baseServings: z.number().int().min(1).max(100).default(4),
visibility: z.enum(["private", "unlisted", "public"]).default("private"),
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
prepMins: z.number().int().min(0).optional(),
cookMins: z.number().int().min(0).optional(),
prepMins: z.number().int().min(0).max(1440).optional(),
cookMins: z.number().int().min(0).max(1440).optional(),
tags: z.array(z.string().min(1).max(50)).max(20).default([]),
aiGenerated: z.boolean().optional(),
language: z.string().max(10).optional(),
dietaryTags: z.object({
vegan: z.boolean().optional(),
vegetarian: z.boolean().optional(),
@@ -63,17 +28,17 @@ const CreateRecipeSchema = z.object({
kosher: z.boolean().optional(),
}).optional(),
ingredients: z.array(z.object({
rawName: z.string().min(1),
rawName: z.string().min(1).max(200),
quantity: z.union([z.number(), z.string()]).optional().transform(parseQuantity),
unit: z.string().optional(),
note: z.string().optional(),
unit: z.string().max(50).optional(),
note: z.string().max(500).optional(),
order: z.number().int().default(0),
})).default([]),
})).max(100).default([]),
steps: z.array(z.object({
instruction: z.string().min(1),
timerSeconds: z.number().int().optional(),
instruction: z.string().min(1).max(2000),
timerSeconds: z.number().int().min(0).max(86400).optional(),
order: z.number().int().optional(),
})).default([]),
})).max(100).default([]),
});
export async function GET(req: NextRequest) {
@@ -109,7 +74,14 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "recipe");
try {
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "recipe");
} catch (err) {
if (err instanceof TierLimitError) {
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
}
throw err;
}
const id = crypto.randomUUID();
const now = new Date();
@@ -126,8 +98,10 @@ export async function POST(req: NextRequest) {
difficulty: data.difficulty,
prepMins: data.prepMins,
cookMins: data.cookMins,
tags: data.tags,
dietaryTags: data.dietaryTags ?? {},
aiGenerated: data.aiGenerated ?? false,
language: data.language,
createdAt: now,
updatedAt: now,
});
@@ -159,8 +133,6 @@ export async function POST(req: NextRequest) {
}
});
await incrementUsage(session!.user.id, "recipe");
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) });
void dispatchWebhook(session!.user.id, "recipe.created", { id, title: data.title });
return NextResponse.json(recipe, { status: 201 });
+1 -1
View File
@@ -18,7 +18,7 @@ export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl;
// --- Parse & validate required param ---
const q = searchParams.get("q")?.trim() ?? "";
const q = (searchParams.get("q") ?? "").trim().slice(0, 200);
if (!q) {
return NextResponse.json(
{ error: "Query parameter 'q' is required and must not be empty." },
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/api-auth";
import { createPresignedUploadUrl } from "@/lib/storage";
import { db, recipes, eq, and } from "@epicure/db";
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/avif"] as const;
type AllowedType = (typeof ALLOWED_TYPES)[number];
@@ -23,6 +24,12 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
const owned = await db.query.recipes.findFirst({
where: and(eq(recipes.id, parsed.data.recipeId), eq(recipes.authorId, session!.user.id)),
columns: { id: true },
});
if (!owned) return NextResponse.json({ error: "Not found" }, { status: 404 });
const ext = parsed.data.contentType.split("/")[1] ?? "jpg";
const key = `recipes/${parsed.data.recipeId}/photos/${session!.user.id}-${crypto.randomUUID()}.${ext}`;
const url = await createPresignedUploadUrl(key, parsed.data.contentType);
+2
View File
@@ -7,6 +7,8 @@ import { z } from "zod";
const PatchSchema = z.object({
name: z.string().min(1).max(100).optional(),
locale: z.string().max(10).optional(),
bio: z.string().max(500).optional().nullable(),
privateBio: z.string().max(2000).optional().nullable(),
});
export async function PATCH(req: Request) {
+4 -4
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, webhooks, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { validateWebhookUrl } from "@/lib/validate-webhook-url";
const VALID_EVENTS = ["recipe.created", "recipe.updated", "recipe.published", "recipe.deleted"] as const;
@@ -66,10 +67,9 @@ export async function PATCH(
}
if (parsed.data.url) {
try {
new URL(parsed.data.url);
} catch {
return NextResponse.json({ error: "Invalid URL" }, { status: 400 });
const ssrfError = await validateWebhookUrl(parsed.data.url);
if (ssrfError) {
return NextResponse.json({ error: ssrfError }, { status: 400 });
}
}
+5 -5
View File
@@ -3,6 +3,7 @@ import crypto from "node:crypto";
import { z } from "zod";
import { db, webhooks, eq } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { validateWebhookUrl } from "@/lib/validate-webhook-url";
const VALID_EVENTS = ["recipe.created", "recipe.updated", "recipe.published", "recipe.deleted"] as const;
@@ -49,11 +50,10 @@ export async function POST(req: NextRequest) {
);
}
// Validate URL format
try {
new URL(parsed.data.url);
} catch {
return NextResponse.json({ error: "Invalid URL" }, { status: 400 });
// Validate URL — enforce https/http only and block SSRF targets
const ssrfError = await validateWebhookUrl(parsed.data.url);
if (ssrfError) {
return NextResponse.json({ error: ssrfError }, { status: 400 });
}
const secret = crypto.randomBytes(32).toString("hex");
+80 -6
View File
@@ -1,19 +1,93 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto";
// Stripe webhook stub — wire up when adding Stripe
// Verifies stripe-signature header (using raw body), handles:
// Stripe webhook handler — verifies stripe-signature header using HMAC-SHA256.
// Handles:
// - checkout.session.completed → upgrade user to pro
// - customer.subscription.deleted → downgrade user to free
const STRIPE_TOLERANCE_SECONDS = 300; // 5 minutes
function verifyStripeSignature(
rawBody: string,
sigHeader: string,
secret: string
): { valid: boolean; payload: string | null } {
// sigHeader format: "t=<timestamp>,v1=<hmac>[,v1=<hmac>...]"
const parts = sigHeader.split(",");
const tPart = parts.find((p) => p.startsWith("t="));
const v1Parts = parts.filter((p) => p.startsWith("v1="));
if (!tPart || v1Parts.length === 0) {
return { valid: false, payload: null };
}
const timestamp = tPart.slice(2);
const tsNum = parseInt(timestamp, 10);
if (isNaN(tsNum)) return { valid: false, payload: null };
// Reject stale webhooks
const nowSec = Math.floor(Date.now() / 1000);
if (Math.abs(nowSec - tsNum) > STRIPE_TOLERANCE_SECONDS) {
return { valid: false, payload: null };
}
const signedPayload = `${timestamp}.${rawBody}`;
const expected = crypto
.createHmac("sha256", secret)
.update(signedPayload, "utf8")
.digest();
const matched = v1Parts.some((v1Part) => {
const provided = v1Part.slice(3); // strip "v1="
let providedBuf: Buffer;
try {
providedBuf = Buffer.from(provided, "hex");
} catch {
return false;
}
if (providedBuf.length !== expected.length) return false;
return crypto.timingSafeEqual(expected, providedBuf);
});
return { valid: matched, payload: matched ? rawBody : null };
}
export async function POST(req: NextRequest) {
const body = await req.text();
const sig = req.headers.get("stripe-signature");
const webhookSecret = process.env["STRIPE_WEBHOOK_SECRET"];
if (!sig || !process.env["STRIPE_WEBHOOK_SECRET"]) {
if (!sig || !webhookSecret) {
return NextResponse.json({ error: "Stripe not configured" }, { status: 400 });
}
// TODO: const event = stripe.webhooks.constructEvent(body, sig, process.env["STRIPE_WEBHOOK_SECRET"]);
// For now just return 200 to acknowledge receipt
console.log("[stripe-webhook] received event, sig:", sig.slice(0, 20));
const { valid } = verifyStripeSignature(body, sig, webhookSecret);
if (!valid) {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
let event: { type: string; data: { object: Record<string, unknown> } };
try {
event = JSON.parse(body) as typeof event;
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
// TODO: wire up DB calls when Stripe billing is fully configured
switch (event.type) {
case "checkout.session.completed":
// upgrade user to pro
console.log("[stripe-webhook] checkout.session.completed", event.data.object["id"]);
break;
case "customer.subscription.deleted":
// downgrade user to free
console.log("[stripe-webhook] customer.subscription.deleted", event.data.object["id"]);
break;
default:
// ignore unhandled event types
break;
}
return NextResponse.json({ received: true });
}