diff --git a/CHANGELOG.md b/CHANGELOG.md index d3d5a78..54efd8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. +## 0.32.0 — 2026-07-14 17:10 + +### Security +- Fixed a stored-XSS hole in public recipe pages' embedded structured data, and tightened the production Content-Security-Policy so script injection like it can't execute even if a similar bug slips in again. +- Avatar photos now require proof you actually own the upload — previously any URL was accepted, including another user's uploaded photo key. +- Changing your password now signs out every other active session, and so does resetting a forgotten password. +- Fixed a rate-limit bypass on public share-link throttling caused by trusting a spoofable header. +- Webhook signing secrets are now encrypted at rest, matching how API keys and other secrets are already stored. +- Login/2FA/password-reset rate limiting now shares Redis instead of quietly resetting per server instance. +- Photo/avatar uploads are now size-capped by the storage server itself, not just a number the client could lie about. +- A private recipe's title could leak via its page's browser-tab title to any signed-in user who had the link — fixed to respect the same visibility rules as the page itself. +- Blocking/unblocking a user is now rate-limited, matching follow/unfollow. +- AI calls made with your own API key (Settings → AI Keys) no longer count against your monthly AI-call limit. + ## 0.31.0 — 2026-07-14 16:35 ### Added diff --git a/apps/web/app/(app)/recipes/[id]/page.tsx b/apps/web/app/(app)/recipes/[id]/page.tsx index efc8af0..35585be 100644 --- a/apps/web/app/(app)/recipes/[id]/page.tsx +++ b/apps/web/app/(app)/recipes/[id]/page.tsx @@ -45,7 +45,14 @@ type Params = { params: Promise<{ id: string }> }; export async function generateMetadata({ params }: Params): Promise { const { id } = await params; - const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) }); + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return { title: "Recipe" }; + const recipe = await db.query.recipes.findFirst({ + where: and( + eq(recipes.id, id), + or(eq(recipes.authorId, session.user.id), inArray(recipes.visibility, ["public", "unlisted"])) + ), + }); return { title: recipe?.title ?? "Recipe" }; } diff --git a/apps/web/app/api/v1/ai/adapt/[id]/route.ts b/apps/web/app/api/v1/ai/adapt/[id]/route.ts index 26c68c0..382c784 100644 --- a/apps/web/app/api/v1/ai/adapt/[id]/route.ts +++ b/apps/web/app/api/v1/ai/adapt/[id]/route.ts @@ -65,7 +65,7 @@ export async function POST(req: NextRequest, { params }: Params) { }, { ...aiConfig, userContext: privateBio ?? undefined }, (session!.user as { locale?: string }).locale ?? "en" - ) + ), { skipQuota: aiConfig.isByok } ); if (!result.ok) return result.response; const adapted = result.data; diff --git a/apps/web/app/api/v1/ai/batch-cook/generate/route.ts b/apps/web/app/api/v1/ai/batch-cook/generate/route.ts index aa9064d..1ba0b4f 100644 --- a/apps/web/app/api/v1/ai/batch-cook/generate/route.ts +++ b/apps/web/app/api/v1/ai/batch-cook/generate/route.ts @@ -55,7 +55,7 @@ export async function POST(req: NextRequest) { }, { ...config, userContext: privateBio ?? undefined }, locale - ) + ), { skipQuota: config.isByok } ); if (!result.ok) return result.response; const plan = result.data; diff --git a/apps/web/app/api/v1/ai/cooking-chat/route.ts b/apps/web/app/api/v1/ai/cooking-chat/route.ts index b0fe7f8..950db7c 100644 --- a/apps/web/app/api/v1/ai/cooking-chat/route.ts +++ b/apps/web/app/api/v1/ai/cooking-chat/route.ts @@ -33,7 +33,8 @@ export async function POST(req: NextRequest) { getUserPrivateBio(session!.user.id), ]); if (!configResult.ok) return configResult.response; - const model = resolveModel(configResult.data); + const aiConfig = configResult.data; + const model = resolveModel(aiConfig); const bioContext = buildUserBioContext(privateBio); const locale = (session!.user as { locale?: string }).locale ?? "en"; const lang = LANG[locale] ?? "English"; @@ -43,7 +44,7 @@ export async function POST(req: NextRequest) { model, system: `You are Epicure, a helpful culinary assistant answering general cooking questions — not tied to any specific recipe (techniques, substitutions, timing, equipment, food safety, etc). If asked who you are or what model/AI you're built on, say you're Epicure — never name the underlying model or provider. If a question has nothing to do with cooking or food, politely redirect. Keep answers under 200 words. Respond in ${lang}.${bioContext}`, prompt: parsed.data.question, - }) + }), { skipQuota: aiConfig.isByok } ); if (!result.ok) return result.response; diff --git a/apps/web/app/api/v1/ai/drinks/[id]/route.ts b/apps/web/app/api/v1/ai/drinks/[id]/route.ts index 894a6ac..cda4b78 100644 --- a/apps/web/app/api/v1/ai/drinks/[id]/route.ts +++ b/apps/web/app/api/v1/ai/drinks/[id]/route.ts @@ -55,7 +55,7 @@ export async function POST(req: NextRequest, { params }: Params) { parsed.data.count, { ...aiConfig, userContext: privateBio ?? undefined }, (session!.user as { locale?: string }).locale ?? "en" - ) + ), { skipQuota: aiConfig.isByok } ); if (!result.ok) return result.response; diff --git a/apps/web/app/api/v1/ai/generate-from-idea/route.ts b/apps/web/app/api/v1/ai/generate-from-idea/route.ts index d208e8d..e74491b 100644 --- a/apps/web/app/api/v1/ai/generate-from-idea/route.ts +++ b/apps/web/app/api/v1/ai/generate-from-idea/route.ts @@ -2,9 +2,10 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { requireSessionOrApiKey } from "@/lib/api-auth"; import { applyRateLimit } from "@/lib/rate-limit"; -import { withAiQuota } from "@/lib/ai/ai-error"; +import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error"; import { generateRecipe } from "@/lib/ai/features/generate-recipe"; import { getUserPrivateBio } from "@/lib/ai/user-bio"; +import { withUserKey } from "@/lib/ai/resolve-user-key"; import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db"; import { parseQuantity } from "@/lib/parse-quantity"; @@ -32,13 +33,18 @@ export async function POST(req: NextRequest) { const privateBio = await getUserPrivateBio(session!.user.id); const locale = (session!.user as { locale?: string }).locale ?? "en"; + const configResult = await resolveAiConfigOrError(() => + withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model }) + ); + if (!configResult.ok) return configResult.response; + const aiConfig = configResult.data; + const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () => generateRecipe(parsed.data.title, { - provider: parsed.data.provider, - model: parsed.data.model, + ...aiConfig, userContext: privateBio ?? undefined, language: LANG[locale] ?? "English", - }) + }), { skipQuota: aiConfig.isByok } ); if (!result.ok) return result.response; const recipe = result.data; diff --git a/apps/web/app/api/v1/ai/generate/route.ts b/apps/web/app/api/v1/ai/generate/route.ts index 5dd2661..1ff9f30 100644 --- a/apps/web/app/api/v1/ai/generate/route.ts +++ b/apps/web/app/api/v1/ai/generate/route.ts @@ -2,9 +2,10 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { requireSessionOrApiKey } from "@/lib/api-auth"; import { applyRateLimit } from "@/lib/rate-limit"; -import { withAiQuota } from "@/lib/ai/ai-error"; +import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error"; import { generateRecipe } from "@/lib/ai/features/generate-recipe"; import { getUserPrivateBio } from "@/lib/ai/user-bio"; +import { withUserKey } from "@/lib/ai/resolve-user-key"; const Schema = z.object({ prompt: z.string().min(3).max(500), @@ -29,14 +30,19 @@ export async function POST(req: NextRequest) { const privateBio = await getUserPrivateBio(session!.user.id); + const configResult = await resolveAiConfigOrError(() => + withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model }) + ); + if (!configResult.ok) return configResult.response; + const aiConfig = configResult.data; + const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () => generateRecipe(parsed.data.prompt, { - provider: parsed.data.provider, - model: parsed.data.model, + ...aiConfig, language: parsed.data.language, difficulty: parsed.data.difficulty, userContext: privateBio ?? undefined, - }) + }), { skipQuota: aiConfig.isByok } ); if (!result.ok) return result.response; diff --git a/apps/web/app/api/v1/ai/import-photo/route.ts b/apps/web/app/api/v1/ai/import-photo/route.ts index b37e39d..3cac04a 100644 --- a/apps/web/app/api/v1/ai/import-photo/route.ts +++ b/apps/web/app/api/v1/ai/import-photo/route.ts @@ -39,7 +39,8 @@ export async function POST(req: NextRequest) { } const result = await withAiQuota(userId, session!.user.tier as "free" | "pro", () => - importFromPhoto(parsed.data.imageBase64, parsed.data.mimeType, aiConfig, locale) + importFromPhoto(parsed.data.imageBase64, parsed.data.mimeType, aiConfig, locale), + { skipQuota: aiConfig.isByok } ); if (!result.ok) return result.response; const recipe = result.data; diff --git a/apps/web/app/api/v1/ai/import-url/route.ts b/apps/web/app/api/v1/ai/import-url/route.ts index 029b5f7..f2f14bb 100644 --- a/apps/web/app/api/v1/ai/import-url/route.ts +++ b/apps/web/app/api/v1/ai/import-url/route.ts @@ -2,9 +2,10 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { requireSessionOrApiKey } from "@/lib/api-auth"; import { applyRateLimit } from "@/lib/rate-limit"; -import { withAiQuota } from "@/lib/ai/ai-error"; +import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error"; import { importFromUrl } from "@/lib/ai/features/import-url"; import { validateWebhookUrl } from "@/lib/validate-webhook-url"; +import { withUserKey } from "@/lib/ai/resolve-user-key"; const Schema = z.object({ url: z.string().url(), @@ -30,11 +31,14 @@ export async function POST(req: NextRequest) { const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60); if (limited) return limited; + const configResult = await resolveAiConfigOrError(() => + withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model }) + ); + if (!configResult.ok) return configResult.response; + const aiConfig = configResult.data; + const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () => - importFromUrl(parsed.data.url, { - provider: parsed.data.provider, - model: parsed.data.model, - }) + importFromUrl(parsed.data.url, aiConfig), { skipQuota: aiConfig.isByok } ); if (!result.ok) return result.response; diff --git a/apps/web/app/api/v1/ai/meal-plan/generate/route.ts b/apps/web/app/api/v1/ai/meal-plan/generate/route.ts index b0535ea..4577b61 100644 --- a/apps/web/app/api/v1/ai/meal-plan/generate/route.ts +++ b/apps/web/app/api/v1/ai/meal-plan/generate/route.ts @@ -88,7 +88,7 @@ export async function POST(req: NextRequest) { }, { ...config, userContext: privateBio ?? undefined }, locale - ) + ), { skipQuota: config.isByok } ); if (!result.ok) return result.response; const plan = result.data; diff --git a/apps/web/app/api/v1/ai/pairings/[id]/route.ts b/apps/web/app/api/v1/ai/pairings/[id]/route.ts index 3f2d061..1d9e368 100644 --- a/apps/web/app/api/v1/ai/pairings/[id]/route.ts +++ b/apps/web/app/api/v1/ai/pairings/[id]/route.ts @@ -56,7 +56,7 @@ export async function POST(req: NextRequest, { params }: Params) { parsed.data.count, { ...aiConfig, userContext: privateBio ?? undefined }, (session!.user as { locale?: string }).locale ?? "en" - ) + ), { skipQuota: aiConfig.isByok } ); if (!result.ok) return result.response; diff --git a/apps/web/app/api/v1/ai/recipe-chat/route.ts b/apps/web/app/api/v1/ai/recipe-chat/route.ts index 140aec3..810b1c4 100644 --- a/apps/web/app/api/v1/ai/recipe-chat/route.ts +++ b/apps/web/app/api/v1/ai/recipe-chat/route.ts @@ -68,7 +68,8 @@ ${stepList || "None listed"} getUserPrivateBio(session!.user.id), ]); if (!configResult.ok) return configResult.response; - const model = resolveModel(configResult.data); + const aiConfig = configResult.data; + const model = resolveModel(aiConfig); const bioContext = buildUserBioContext(privateBio); const locale = (session!.user as { locale?: string }).locale ?? "en"; const lang = LANG[locale] ?? "English"; @@ -80,7 +81,7 @@ ${stepList || "None listed"} ${recipeContext}${bioContext}`, prompt: parsed.data.question, - }) + }), { skipQuota: aiConfig.isByok } ); if (!result.ok) return result.response; diff --git a/apps/web/app/api/v1/ai/recipe-ideas/route.ts b/apps/web/app/api/v1/ai/recipe-ideas/route.ts index 8819957..c770e73 100644 --- a/apps/web/app/api/v1/ai/recipe-ideas/route.ts +++ b/apps/web/app/api/v1/ai/recipe-ideas/route.ts @@ -42,7 +42,8 @@ export async function POST(req: NextRequest) { getUserPrivateBio(session!.user.id), ]); if (!configResult.ok) return configResult.response; - const model = resolveModel(configResult.data); + const aiConfig = configResult.data; + const model = resolveModel(aiConfig); const bioContext = buildUserBioContext(privateBio); const userContext = bioContext @@ -62,7 +63,7 @@ export async function POST(req: NextRequest) { schema: IdeasSchema, system: `Respond in ${lang}.`, prompt, - }) + }), { skipQuota: aiConfig.isByok } ); if (!result.ok) return result.response; diff --git a/apps/web/app/api/v1/ai/scale/route.ts b/apps/web/app/api/v1/ai/scale/route.ts index 2a59500..23101e3 100644 --- a/apps/web/app/api/v1/ai/scale/route.ts +++ b/apps/web/app/api/v1/ai/scale/route.ts @@ -55,7 +55,7 @@ export async function POST(req: NextRequest) { recipe.baseServings, aiConfig, (session!.user as { locale?: string }).locale ?? "en" - ) + ), { skipQuota: aiConfig.isByok } ); if (!result.ok) return result.response; diff --git a/apps/web/app/api/v1/ai/substitute/route.ts b/apps/web/app/api/v1/ai/substitute/route.ts index efaae29..d8af73a 100644 --- a/apps/web/app/api/v1/ai/substitute/route.ts +++ b/apps/web/app/api/v1/ai/substitute/route.ts @@ -5,6 +5,7 @@ import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error"; import { applyRateLimit } from "@/lib/rate-limit"; import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key"; import { substituteIngredient } from "@/lib/ai/features/substitute-ingredient"; +import type { AiConfig } from "@/lib/ai/factory"; const Schema = z.object({ ingredient: z.string().min(1).max(200), @@ -28,7 +29,7 @@ export async function POST(req: NextRequest) { ? `recipe "${parsed.data.recipeTitle}"` : "a general recipe"; - let aiConfig; + let aiConfig: AiConfig; if (parsed.data.provider) { aiConfig = { provider: parsed.data.provider, model: parsed.data.model }; } else { @@ -40,7 +41,8 @@ export async function POST(req: NextRequest) { const locale = (session!.user as { locale?: string }).locale ?? "en"; const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () => - substituteIngredient(parsed.data.ingredient, context, aiConfig, locale) + substituteIngredient(parsed.data.ingredient, context, aiConfig, locale), + { skipQuota: aiConfig.isByok } ); if (!result.ok) return result.response; diff --git a/apps/web/app/api/v1/ai/translate/[id]/route.ts b/apps/web/app/api/v1/ai/translate/[id]/route.ts index 64ee870..a3dc367 100644 --- a/apps/web/app/api/v1/ai/translate/[id]/route.ts +++ b/apps/web/app/api/v1/ai/translate/[id]/route.ts @@ -3,8 +3,9 @@ import { z } from "zod"; import { and, eq } from "@epicure/db"; import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db"; import { requireSessionOrApiKey } from "@/lib/api-auth"; -import { withAiQuota } from "@/lib/ai/ai-error"; +import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error"; import { translateRecipe } from "@/lib/ai/features/translate-recipe"; +import { withUserKey } from "@/lib/ai/resolve-user-key"; const Schema = z.object({ targetLanguage: z.string().min(2).max(50), @@ -35,6 +36,12 @@ export async function POST(req: NextRequest, { params }: Params) { return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 }); } + const configResult = await resolveAiConfigOrError(() => + withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model }) + ); + if (!configResult.ok) return configResult.response; + const aiConfig = configResult.data; + const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () => translateRecipe( { @@ -44,8 +51,8 @@ export async function POST(req: NextRequest, { params }: Params) { steps: recipe.steps, }, parsed.data.targetLanguage, - { provider: parsed.data.provider, model: parsed.data.model } - ) + aiConfig + ), { skipQuota: aiConfig.isByok } ); if (!result.ok) return result.response; const translation = result.data; diff --git a/apps/web/app/api/v1/ai/variations/[id]/route.ts b/apps/web/app/api/v1/ai/variations/[id]/route.ts index 88a54f7..d7f990e 100644 --- a/apps/web/app/api/v1/ai/variations/[id]/route.ts +++ b/apps/web/app/api/v1/ai/variations/[id]/route.ts @@ -59,7 +59,7 @@ export async function POST(req: NextRequest, { params }: Params) { { ...aiConfig, userContext: privateBio ?? undefined }, parsed.data.directions, (session!.user as { locale?: string }).locale ?? "en" - ) + ), { skipQuota: aiConfig.isByok } ); if (!result.ok) return result.response; diff --git a/apps/web/app/api/v1/upload/avatar-presign/route.ts b/apps/web/app/api/v1/upload/avatar-presign/route.ts index 575f4f9..da48afd 100644 --- a/apps/web/app/api/v1/upload/avatar-presign/route.ts +++ b/apps/web/app/api/v1/upload/avatar-presign/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { requireSession } from "@/lib/api-auth"; -import { createPresignedUploadUrl } from "@/lib/storage"; +import { createPresignedUploadPost } from "@/lib/storage"; import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers"; const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/avif"] as const; @@ -39,7 +39,7 @@ export async function POST(req: NextRequest) { const ext = contentType.split("/")[1] ?? "jpg"; const key = `user-avatars/${session!.user.id}/${crypto.randomUUID()}.${ext}`; - const url = await createPresignedUploadUrl(key, contentType); + const { url, fields } = await createPresignedUploadPost(key, contentType, MAX_FILE_SIZE); - return NextResponse.json({ url, key }); + return NextResponse.json({ url, fields, key }); } diff --git a/apps/web/app/api/v1/upload/presign/route.ts b/apps/web/app/api/v1/upload/presign/route.ts index c760544..e321423 100644 --- a/apps/web/app/api/v1/upload/presign/route.ts +++ b/apps/web/app/api/v1/upload/presign/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { requireSession } from "@/lib/api-auth"; -import { createPresignedUploadUrl } from "@/lib/storage"; +import { createPresignedUploadPost } from "@/lib/storage"; import { db, recipes, eq, and } from "@epicure/db"; import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers"; @@ -53,7 +53,7 @@ export async function POST(req: NextRequest) { const ext = contentType.split("/")[1] ?? "jpg"; const folder = purpose === "review" ? "reviews" : "photos"; const key = `recipes/${recipeId}/${folder}/${session!.user.id}-${crypto.randomUUID()}.${ext}`; - const url = await createPresignedUploadUrl(key, contentType); + const { url, fields } = await createPresignedUploadPost(key, contentType, MAX_FILE_SIZE); - return NextResponse.json({ url, key }); + return NextResponse.json({ url, fields, key }); } diff --git a/apps/web/app/api/v1/users/[username]/block/route.ts b/apps/web/app/api/v1/users/[username]/block/route.ts index 63bd0be..63dde2b 100644 --- a/apps/web/app/api/v1/users/[username]/block/route.ts +++ b/apps/web/app/api/v1/users/[username]/block/route.ts @@ -1,12 +1,17 @@ import { NextRequest, NextResponse } from "next/server"; import { db, users, userBlocks, userFollows, eq, and, or } from "@epicure/db"; import { requireSession } from "@/lib/api-auth"; +import { applyRateLimit } from "@/lib/rate-limit"; type Params = { params: Promise<{ username: string }> }; export async function POST(_req: NextRequest, { params }: Params) { const { session, response } = await requireSession(); if (response) return response; + + const limited = await applyRateLimit(`rl:block:${session!.user.id}`, 30, 60); + if (limited) return limited; + const { username } = await params; const target = await db.query.users.findFirst({ where: eq(users.username, username) }); @@ -31,6 +36,10 @@ export async function POST(_req: NextRequest, { params }: Params) { export async function DELETE(_req: NextRequest, { params }: Params) { const { session, response } = await requireSession(); if (response) return response; + + const limited = await applyRateLimit(`rl:block:${session!.user.id}`, 30, 60); + if (limited) return limited; + const { username } = await params; const target = await db.query.users.findFirst({ where: eq(users.username, username) }); diff --git a/apps/web/app/api/v1/users/me/route.ts b/apps/web/app/api/v1/users/me/route.ts index 6e6a3a0..8e9c2e1 100644 --- a/apps/web/app/api/v1/users/me/route.ts +++ b/apps/web/app/api/v1/users/me/route.ts @@ -5,6 +5,7 @@ import { db, users, eq } from "@epicure/db"; import { z } from "zod"; import { gravatarUrl } from "@/lib/gravatar"; import { USERNAME_PATTERN, isUsernameTaken } from "@/lib/username"; +import { isOwnedAvatarKey, getPublicUrl } from "@/lib/storage"; const PatchSchema = z.object({ name: z.string().min(1).max(100).optional(), @@ -13,9 +14,12 @@ const PatchSchema = z.object({ privateBio: z.string().max(2000).optional().nullable(), isPrivate: z.boolean().optional(), username: z.string().trim().toLowerCase().regex(USERNAME_PATTERN, "3-20 characters, lowercase letters, numbers, and underscores only").optional(), - // A custom-uploaded avatar URL, or null to revert to the initials fallback - // (or Gravatar, if useGravatar is on — see below). - avatarUrl: z.string().url().max(2048).optional().nullable(), + // The storage key returned by /api/v1/upload/avatar-presign (not a URL) — + // the server validates it was actually issued to this user and builds the + // public URL itself, so a client can't point avatarUrl at an arbitrary or + // another user's object. `null` reverts to the initials fallback (or + // Gravatar, if useGravatar is on — see below). + avatarKey: z.string().max(500).optional().nullable(), // Off by default (Settings → Profile) — Gravatar is looked up by an MD5 // hash of the user's email, sent to a third party. useGravatar: z.boolean().optional(), @@ -32,7 +36,7 @@ export async function PATCH(req: Request) { return NextResponse.json({ error: "Username already taken" }, { status: 409 }); } - const { avatarUrl, useGravatar, ...rest } = body.data; + const { avatarKey, useGravatar, ...rest } = body.data; const updates: Partial = { ...rest }; // useGravatar is only ever toggled from the settings form, never alongside @@ -45,13 +49,16 @@ export async function PATCH(req: Request) { } } - if (avatarUrl !== undefined) { - if (avatarUrl === null) { + if (avatarKey !== undefined) { + if (avatarKey === null) { const gravatarOptedIn = useGravatar ?? (await getUseGravatar(session.user.id)); updates.avatarUrl = gravatarOptedIn ? gravatarUrl(session.user.email) : null; updates.hasCustomAvatar = false; } else { - updates.avatarUrl = avatarUrl; + if (!isOwnedAvatarKey(avatarKey, session.user.id)) { + return NextResponse.json({ error: "Invalid avatar key" }, { status: 400 }); + } + updates.avatarUrl = getPublicUrl(avatarKey); updates.hasCustomAvatar = true; } } diff --git a/apps/web/app/api/v1/webhooks/route.ts b/apps/web/app/api/v1/webhooks/route.ts index 397fbdd..f653255 100644 --- a/apps/web/app/api/v1/webhooks/route.ts +++ b/apps/web/app/api/v1/webhooks/route.ts @@ -5,6 +5,7 @@ import { db, webhooks, eq } from "@epicure/db"; import { requireSession } from "@/lib/api-auth"; import { validateWebhookUrl } from "@/lib/validate-webhook-url"; import { WEBHOOK_EVENTS } from "@/lib/webhooks"; +import { encrypt } from "@/lib/encrypt"; const CreateWebhookBody = z.object({ url: z.string().min(1).max(2048), @@ -64,7 +65,7 @@ export async function POST(req: NextRequest) { userId: session!.user.id, url: parsed.data.url, events: parsed.data.events, - secret, + secret: encrypt(secret), active: true, createdAt: now, }); diff --git a/apps/web/app/r/[id]/page.tsx b/apps/web/app/r/[id]/page.tsx index 06248b4..6f8fbf4 100644 --- a/apps/web/app/r/[id]/page.tsx +++ b/apps/web/app/r/[id]/page.tsx @@ -82,7 +82,7 @@ export default async function PublicRecipePage({ params }: Params) { return ( <> -