Mirror external images into our own storage so they render

Bar items added by barcode stored the Open Food Facts image URL
directly. The CSP in next.config.mjs restricts img-src to our own
origin, so the browser blocked those and showed a broken image - the
picture was fine, we just could not display it.

Copy externally-hosted images into MinIO at lookup time and hand back a
/minio-images path instead. That fixes the class rather than the
instance: no CSP entry is needed per image source, 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.

Also fixes a latent bug this uncovered: imageUrl was validated with
z.string().url(), which rejects the relative /minio-images/... paths
that uploadImage returns, so saving an uploaded drink image would fail
validation. That matches production having zero drinks with an image.
Both schemas now accept either form.

CSP keeps two third-party entries for OAuth avatars, which the provider
hosts and we only ever receive as a URL at sign-in.

Existing rows were backfilled separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JP
2026-08-08 19:06:45 +00:00
parent 80fd99bc40
commit 058735b1a0
4 changed files with 96 additions and 5 deletions

View File

@@ -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'",

View File

@@ -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

View File

@@ -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<string, string> = {
"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<string | null> {
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
}
}

View File

@@ -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()