diff --git a/next.config.mjs b/next.config.mjs index 1c807cb..3c59148 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -63,7 +63,17 @@ const nextConfig = { "default-src 'self'", "script-src 'self' 'unsafe-inline' 'unsafe-eval'", "style-src 'self' 'unsafe-inline'", - "img-src 'self' data: blob: http://localhost:9000 https://*.amazonaws.com", + // Product and drink images are mirrored into our own storage and served + // from 'self' via /minio-images, so third-party image hosts do not belong + // here. The exceptions are OAuth avatars, which the provider hosts and we + // 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", "frame-ancestors 'none'", diff --git a/src/app/api/bar/barcode-lookup/route.ts b/src/app/api/bar/barcode-lookup/route.ts index 6f2d472..3ca999e 100644 --- a/src/app/api/bar/barcode-lookup/route.ts +++ b/src/app/api/bar/barcode-lookup/route.ts @@ -3,6 +3,7 @@ import { auth } from "@/lib/auth" import { prisma } from "@/lib/prisma" import { getUserProvider } from "@/lib/ai/provider-factory" import { FEATURE_ROUTING } from "@/lib/ai/routing" +import { mirrorExternalImage } from "@/lib/s3" import { rateLimit } from "@/lib/rate-limit" import { z } from "zod" @@ -42,8 +43,14 @@ async function lookupOpenFoodFacts(barcode: string) { const name = product.product_name || product.product_name_en || null if (!name) return null - // Extract product image URL - const imageUrl = product.image_url || product.image_front_url || product.image_front_small_url || null + // Open Food Facts hosts these on its own domain, which the CSP blocks, so copy + // the picture into our storage and hand back a local URL. Null on failure - an + // item with no image beats one with an image that cannot render. + const remoteImage = + product.image_url || product.image_front_url || product.image_front_small_url || null + const imageUrl = remoteImage + ? await mirrorExternalImage(remoteImage, `bar/${barcode}`) + : null // Extract ABV from alcohol_100g nutrient or nutriments let abv: number | null = null diff --git a/src/lib/s3.ts b/src/lib/s3.ts index ba2166b..175960f 100644 --- a/src/lib/s3.ts +++ b/src/lib/s3.ts @@ -4,6 +4,7 @@ import { GetObjectCommand, DeleteObjectCommand, } from "@aws-sdk/client-s3" +import { randomUUID } from "crypto" const s3Client = new S3Client({ endpoint: `http${process.env.MINIO_USE_SSL === "true" ? "s" : ""}://${process.env.MINIO_ENDPOINT}:${process.env.MINIO_PORT}`, @@ -58,3 +59,62 @@ export function getImageUrl(key: string): string { // Return relative URL through Next.js proxy — works from any device return `/minio-images/${key}` } + +/** Returns true for anything that isn't already one of our own proxied paths. */ +export function isExternalImageUrl(url: string | null | undefined): boolean { + return !!url && !url.startsWith("/minio-images/") +} + +const MAX_MIRROR_BYTES = 8 * 1024 * 1024 + +const EXT_BY_MIME: Record = { + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", + "image/gif": "gif", + "image/avif": "avif", +} + +/** + * Copy an image hosted elsewhere into our own storage and return a local + * `/minio-images/...` URL. + * + * Third-party image URLs can't be rendered by the browser: the Content-Security-Policy + * in next.config.mjs restricts `img-src`, so anything not served from our own origin is + * blocked and shows as a broken image. Mirroring also means the picture survives the + * source deleting or reorganising it, and the user's browser never has to talk to a + * third party to render their own bar. + * + * Returns null on any failure — callers should fall back to storing no image rather + * than storing a URL that will not render. + */ +export async function mirrorExternalImage( + sourceUrl: string, + keyPrefix: string +): Promise { + try { + if (!isExternalImageUrl(sourceUrl)) return sourceUrl ?? null + + const parsed = new URL(sourceUrl) + // Only fetch over http(s); refuse anything else outright. + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null + + const res = await fetch(sourceUrl, { signal: AbortSignal.timeout(10000) }) + if (!res.ok) return null + + const contentType = (res.headers.get("content-type") || "").split(";")[0].trim() + const ext = EXT_BY_MIME[contentType] + if (!ext) return null + + const declared = Number(res.headers.get("content-length") || 0) + if (declared > MAX_MIRROR_BYTES) return null + + const buffer = Buffer.from(await res.arrayBuffer()) + // Re-check after download: content-length is advisory and may be absent or wrong. + if (buffer.byteLength === 0 || buffer.byteLength > MAX_MIRROR_BYTES) return null + + return await uploadImage(`${keyPrefix}/${randomUUID()}.${ext}`, buffer, contentType) + } catch { + return null + } +} diff --git a/src/lib/validators.ts b/src/lib/validators.ts index 2d62780..08a84e3 100644 --- a/src/lib/validators.ts +++ b/src/lib/validators.ts @@ -1,5 +1,19 @@ import { z } from "zod" +/** + * An image reference. Uploaded and mirrored images are stored as relative paths + * (`/minio-images/...`) served through the Next.js proxy, so a plain `.url()` check + * rejects them - which silently broke saving any uploaded image. Absolute http(s) + * URLs stay allowed for data restored from older backups. + */ +export const imageUrlSchema = z + .string() + .max(2048) + .refine( + (v) => v.startsWith("/minio-images/") || /^https?:\/\//i.test(v), + "Must be an uploaded image path or an http(s) URL" + ) + export const drinkCreateSchema = z.object({ name: z.string().min(1, "Name is required").max(200), type: z.enum(["BEER", "WINE", "COCKTAIL", "SPIRIT", "OTHER"]), @@ -8,7 +22,7 @@ export const drinkCreateSchema = z.object({ region: z.string().max(200).optional(), abv: z.number().min(0).max(100).optional(), description: z.string().max(2000).optional(), - imageUrl: z.string().url().optional(), + imageUrl: imageUrlSchema.optional(), }) export const drinkUpdateSchema = drinkCreateSchema.partial() @@ -79,7 +93,7 @@ export const barItemCreateSchema = z.object({ quantity: z.enum(["FULL", "HALF", "LOW", "EMPTY"]).default("FULL"), notes: z.string().max(2000).optional(), barcode: z.string().max(50).optional(), - imageUrl: z.string().url().optional().or(z.literal("")), + imageUrl: imageUrlSchema.optional().or(z.literal("")), }) export const barItemUpdateSchema = barItemCreateSchema.partial()