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 { 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 = { "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 } }