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>
121 lines
3.5 KiB
TypeScript
121 lines
3.5 KiB
TypeScript
import {
|
|
S3Client,
|
|
PutObjectCommand,
|
|
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}`,
|
|
region: "us-east-1",
|
|
credentials: {
|
|
accessKeyId: process.env.MINIO_ACCESS_KEY!,
|
|
secretAccessKey: process.env.MINIO_SECRET_KEY!,
|
|
},
|
|
forcePathStyle: true,
|
|
})
|
|
|
|
const BUCKET = process.env.MINIO_BUCKET || "drink-images"
|
|
|
|
export async function uploadImage(
|
|
key: string,
|
|
body: Buffer,
|
|
contentType: string
|
|
): Promise<string> {
|
|
await s3Client.send(
|
|
new PutObjectCommand({
|
|
Bucket: BUCKET,
|
|
Key: key,
|
|
Body: body,
|
|
ContentType: contentType,
|
|
})
|
|
)
|
|
|
|
// Return relative URL through Next.js proxy — works from any device
|
|
return `/minio-images/${key}`
|
|
}
|
|
|
|
export async function getImage(key: string) {
|
|
const response = await s3Client.send(
|
|
new GetObjectCommand({
|
|
Bucket: BUCKET,
|
|
Key: key,
|
|
})
|
|
)
|
|
return response
|
|
}
|
|
|
|
export async function deleteImage(key: string) {
|
|
await s3Client.send(
|
|
new DeleteObjectCommand({
|
|
Bucket: BUCKET,
|
|
Key: key,
|
|
})
|
|
)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|