Files
drinktracker/src/app/api/recipes/route.ts
JP e0c1814cf1 Put images behind the session and make routes private by default
The /minio-images rewrite proxied straight onto the MinIO bucket with no
authentication, and the bucket carries an anonymous read policy. Verified
from the public internet: GET /minio-images?list-type=2 returned a full
ListBucketResult naming all 33 objects, and any key then fetched 200. Every
menu scan, drink photo and bar photo was enumerable and downloadable by
anyone. Replaced with a route handler that requires a session and streams
via the existing getImage(). The URL shape is unchanged, so stored
imageUrls, uploadImage/getImageUrl and every <img> keep working.

Nothing uses next/image and it must stay that way here: the optimizer
fetches server-side without the session cookie.

Route protection was an allowlist that had to be updated by hand for each
new page, and had already been missed for /bar, /bartender and /recommend,
which rendered to logged-out visitors. /recipes was in the middleware
matcher but not the authorized() list, so it fell through too. Inverted
both to a denylist so new pages are private by default. /api stays out of
the matcher because authorized() answers with an HTML redirect and API
clients need the JSON 401 those routes already return.

Also fixes a cross-user write: POST /api/recipes took sourceDrinkId from
the client with no ownership check, and the drink detail page loaded its
recipes relation unfiltered, so one user could attach an arbitrary recipe
to another user's drink where it rendered permanently and the owner could
not delete it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 20:20:39 +00:00

110 lines
3.4 KiB
TypeScript

import { NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { recipeCreateSchema } from "@/lib/validators"
import { fuzzyMatchIngredients, recalculateMissingCount } from "@/lib/ingredient-matcher"
import type { Prisma } from "@prisma/client"
export async function GET() {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const [recipes, barItems] = await Promise.all([
prisma.recipe.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: "desc" },
include: {
sourceDrink: {
select: { name: true, type: true },
},
},
}),
prisma.barItem.findMany({
where: {
userId: session.user.id,
quantity: { not: "EMPTY" },
},
select: { name: true },
}),
])
// Re-process ingredient availability against current bar inventory
const processedRecipes = recipes.map((recipe) => {
if (barItems.length > 0 && Array.isArray(recipe.ingredients)) {
const ingredients = recipe.ingredients as { name: string; amount: string; available: boolean }[]
const matched = fuzzyMatchIngredients(ingredients, barItems)
return {
...recipe,
ingredients: matched,
missingCount: recalculateMissingCount(matched),
}
}
return recipe
})
return NextResponse.json({ recipes: processedRecipes })
} catch (error) {
console.error("GET /api/recipes error:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}
export async function POST(request: Request) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const body = await request.json()
const parsed = recipeCreateSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: "Validation failed", issues: parsed.error.issues },
{ status: 400 }
)
}
// sourceDrinkId comes from the client and is a foreign key onto Drink. Without
// this check a user could attach a recipe to someone else's drink, where it
// would render on their drink page and they could not delete it.
if (parsed.data.sourceDrinkId) {
const ownsDrink = await prisma.drink.findFirst({
where: { id: parsed.data.sourceDrinkId, userId: session.user.id },
select: { id: true },
})
if (!ownsDrink) {
return NextResponse.json({ error: "Drink not found" }, { status: 404 })
}
}
const recipe = await prisma.recipe.create({
data: {
userId: session.user.id,
title: parsed.data.title,
ingredients: parsed.data.ingredients as unknown as Prisma.InputJsonValue,
steps: parsed.data.steps as unknown as Prisma.InputJsonValue,
garnish: parsed.data.garnish || null,
glassware: parsed.data.glassware || null,
sourceDrinkId: parsed.data.sourceDrinkId || null,
notes: parsed.data.notes || null,
},
})
return NextResponse.json(recipe, { status: 201 })
} catch (error) {
console.error("POST /api/recipes error:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}