diff --git a/next.config.mjs b/next.config.mjs index 3c59148..897acf9 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,33 +1,10 @@ /** @type {import('next').NextConfig} */ const nextConfig = { output: "standalone", - images: { - remotePatterns: [ - { - protocol: "http", - hostname: "localhost", - port: "9000", - pathname: "/drink-images/**", - }, - { - protocol: "https", - hostname: "*.amazonaws.com", - pathname: "/**", - }, - ], - }, - async rewrites() { - // Proxy image requests to MinIO so URLs work from any device - const minioHost = process.env.MINIO_ENDPOINT || "localhost"; - const minioPort = process.env.MINIO_PORT || "9000"; - const minioBucket = process.env.MINIO_BUCKET || "drink-images"; - return [ - { - source: "/minio-images/:path*", - destination: `http://${minioHost}:${minioPort}/${minioBucket}/:path*`, - }, - ]; - }, + // No `images.remotePatterns` and no rewrite to MinIO: images are served by the + // session-gated route at src/app/minio-images/[...key]/route.ts. The rewrite this + // replaced was an unauthenticated proxy onto the bucket. Nothing uses next/image, + // and it must stay that way - the optimizer fetches without the session cookie. async headers() { return [ { @@ -69,13 +46,11 @@ const nextConfig = { // only ever receive as a URL at sign-in. [ "img-src 'self' data: blob:", - "http://localhost:9000", - "https://*.amazonaws.com", "https://lh3.googleusercontent.com", "https://avatars.githubusercontent.com", ].join(" "), "font-src 'self'", - "connect-src 'self' http://localhost:9000", + "connect-src 'self'", "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'", diff --git a/src/app/(app)/drinks/[id]/page.tsx b/src/app/(app)/drinks/[id]/page.tsx index 97cf889..7aae1aa 100644 --- a/src/app/(app)/drinks/[id]/page.tsx +++ b/src/app/(app)/drinks/[id]/page.tsx @@ -48,6 +48,9 @@ export default async function DrinkDetailPage({ orderBy: { createdAt: "desc" }, }, recipes: { + // Defence in depth: the POST route now verifies drink ownership, but this + // relation would otherwise render any user's recipe on this drink's page. + where: { userId: session.user.id }, orderBy: { createdAt: "desc" }, }, }, diff --git a/src/app/api/recipes/route.ts b/src/app/api/recipes/route.ts index 22cd8b7..42b4d79 100644 --- a/src/app/api/recipes/route.ts +++ b/src/app/api/recipes/route.ts @@ -72,6 +72,19 @@ export async function POST(request: Request) { ) } + // 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, diff --git a/src/app/minio-images/[...key]/route.ts b/src/app/minio-images/[...key]/route.ts new file mode 100644 index 0000000..3b8d2b7 --- /dev/null +++ b/src/app/minio-images/[...key]/route.ts @@ -0,0 +1,56 @@ +import { auth } from "@/lib/auth" +import { getImage } from "@/lib/s3" + +/** + * Serves images out of object storage behind the session. + * + * This replaces a Next.js rewrite that proxied straight onto the MinIO bucket with + * no authentication. Combined with the bucket's anonymous read policy that made + * every image in the system enumerable and downloadable from the public internet - + * `GET /minio-images?list-type=2` returned a full ListBucketResult. + * + * The URL shape is deliberately unchanged, so everything that already stores or + * renders `/minio-images/...` keeps working: uploadImage/getImageUrl in lib/s3.ts, + * imageUrlSchema in lib/validators.ts, and every in the app. + * + * Note the app renders images with plain , not next/image. That matters: the + * image optimizer fetches server-side without the session cookie, so switching to + * next/image would 401 every image here. + */ +export const dynamic = "force-dynamic" + +export async function GET( + _request: Request, + { params }: { params: { key: string[] } } +) { + const session = await auth() + if (!session?.user?.id) { + return new Response(null, { status: 401 }) + } + + const key = params.key.join("/") + if (key.includes("..")) { + return new Response(null, { status: 400 }) + } + + try { + const object = await getImage(key) + if (!object.Body) return new Response(null, { status: 404 }) + + return new Response(object.Body as unknown as ReadableStream, { + headers: { + "Content-Type": object.ContentType ?? "application/octet-stream", + ...(object.ContentLength + ? { "Content-Length": String(object.ContentLength) } + : {}), + // Keys embed a random UUID, so an object never changes under a given key. + // `private` keeps it out of any shared cache now that it is session-gated. + "Cache-Control": "private, max-age=31536000, immutable", + }, + }) + } catch { + // Missing key, or storage unavailable - do not distinguish, to avoid + // confirming whether a given key exists. + return new Response(null, { status: 404 }) + } +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 42a8d31..59aee2b 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -6,6 +6,13 @@ import { PrismaAdapter } from "@auth/prisma-adapter" import { prisma } from "@/lib/prisma" import { rateLimit } from "@/lib/rate-limit" +/** + * The only routes reachable without a session. Matched exactly or as a path + * prefix, so `/share` also covers `/share/`. Everything else is private - + * see the `authorized` callback and the denylist matcher in src/middleware.ts. + */ +const PUBLIC_ROUTES = ["/login", "/register", "/share"] + const providers = [ Google({ clientId: process.env.GOOGLE_CLIENT_ID, @@ -66,19 +73,18 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ }, authorized({ auth, request: { nextUrl } }) { const isLoggedIn = !!auth?.user - const isOnApp = nextUrl.pathname.startsWith("/dashboard") || - nextUrl.pathname.startsWith("/scan") || - nextUrl.pathname.startsWith("/drinks") || - nextUrl.pathname.startsWith("/rate") || - nextUrl.pathname.startsWith("/settings") || - nextUrl.pathname.startsWith("/wishlist") + const { pathname } = nextUrl - if (isOnApp) { - if (isLoggedIn) return true - return false - } + // Everything is private unless named here. Paired with the denylist matcher + // in middleware.ts, so a new page is protected by default rather than public + // until somebody remembers to add it. + const isPublic = PUBLIC_ROUTES.some( + (p) => pathname === p || pathname.startsWith(`${p}/`) + ) - if (isLoggedIn && (nextUrl.pathname === "/login" || nextUrl.pathname === "/register")) { + if (!isPublic && !isLoggedIn) return false + + if (isLoggedIn && (pathname === "/login" || pathname === "/register")) { return Response.redirect(new URL("/dashboard", nextUrl)) } diff --git a/src/middleware.ts b/src/middleware.ts index 51db14c..a0345bc 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,13 +1,15 @@ export { auth as middleware } from "@/lib/auth" export const config = { + // A denylist, not an allowlist. The previous per-route allowlist meant every new + // page was public until someone remembered to add it here - /bar, /bartender and + // /recommend all shipped unprotected that way. Everything is matched now except + // static assets, and `authorized` in lib/auth.ts names the few public routes. + // + // /api is deliberately excluded: `authorized` answers with an HTML redirect to + // /login, whereas API clients need a JSON 401 - which every route already returns + // from its own auth() check. matcher: [ - "/dashboard/:path*", - "/scan/:path*", - "/drinks/:path*", - "/rate/:path*", - "/settings/:path*", - "/wishlist/:path*", - "/recipes/:path*", + "/((?!api|_next/static|_next/image|favicon.ico|manifest.json).*)", ], }