/** * Fetches product images for invoice line items and returns them as * `data:` URLs ready to embed in the PDF. * * Uses a simple in-process LRU-ish Map keyed by URL. Images are typically * served from Shopify's CDN so re-fetching is cheap, but caching avoids * hammering the network when regenerating an invoice multiple times. */ import { safeFetch, SafeFetchError, SHOPIFY_CDN_HOSTS } from "./safeFetch.server"; import pLimit from "p-limit"; const MAX_BYTES = 2 * 1024 * 1024; // 2 MB cap per image const CACHE_MAX_ENTRIES = 200; /** Max images fetched/embedded per invoice (DoS bound for large carts). */ const MAX_IMAGES_PER_INVOICE = 100; /** Max concurrent image fetches per invoice. */ const IMAGE_FETCH_CONCURRENCY = 6; const cache = new Map(); // url -> data URL function rememberInCache(url: string, dataUrl: string) { if (cache.size >= CACHE_MAX_ENTRIES) { // Drop the oldest entry (Map preserves insertion order). const oldest = cache.keys().next().value; if (oldest) cache.delete(oldest); } cache.set(url, dataUrl); } function guessContentType(url: string, headerCt: string | null): string { if (headerCt && headerCt.startsWith("image/")) return headerCt; const lower = url.toLowerCase(); if (lower.includes(".jpg") || lower.includes(".jpeg")) return "image/jpeg"; if (lower.includes(".webp")) return "image/webp"; if (lower.includes(".gif")) return "image/gif"; return "image/png"; } export async function fetchProductImageDataUrl(url: string): Promise { if (!url) return undefined; const hit = cache.get(url); if (hit) return hit; // Request a small Shopify CDN variant when possible to keep the PDF lean. // Shopify image URLs accept a `width=` query param; fall back to the original URL. const requestUrl = url.includes("cdn.shopify.com") && !/[?&](width|height|crop)=/.test(url) ? `${url}${url.includes("?") ? "&" : "?"}width=128` : url; let res: Awaited>; try { res = await safeFetch(requestUrl, { maxBytes: MAX_BYTES, accept: "image/*", // Lock product images to Shopify's CDN — line item image URLs come // from the Admin API and should never point anywhere else. allowedHosts: SHOPIFY_CDN_HOSTS, }); } catch (err) { if (err instanceof SafeFetchError) { console.warn(`Product image refused (${err.code}) for ${url}: ${err.message}`); } else { console.warn(`Product image fetch failed for ${url}:`, err); } return undefined; } if (res.status < 200 || res.status >= 300) { console.warn(`Product image HTTP ${res.status} for ${url}`); return undefined; } if (res.bytesRead === 0) return undefined; const contentType = guessContentType(url, res.contentType); // @react-pdf supports png/jpeg natively; webp/gif are unreliable. Skip those. if (contentType !== "image/png" && contentType !== "image/jpeg") return undefined; const b64 = Buffer.from(res.bytes).toString("base64"); const dataUrl = `data:${contentType};base64,${b64}`; rememberInCache(url, dataUrl); return dataUrl; } /** * Resolves images for every line, mutating `imageDataUrl` in place. Fetches * run with bounded concurrency and a hard cap on the number of images * embedded per invoice, so a large cart (Shopify allows hundreds of line * items) can't trigger an unbounded fan-out of network requests. Failures are * swallowed (the row simply renders without an icon). */ export async function attachLineItemImages( lines: { imageUrl?: string; imageDataUrl?: string }[], ): Promise { const limit = pLimit(IMAGE_FETCH_CONCURRENCY); let budget = MAX_IMAGES_PER_INVOICE; const tasks: Promise[] = []; for (const line of lines) { if (!line.imageUrl) continue; if (budget <= 0) break; // cap reached — remaining rows render iconless budget -= 1; tasks.push( limit(async () => { const dataUrl = await fetchProductImageDataUrl(line.imageUrl!); if (dataUrl) line.imageDataUrl = dataUrl; }), ); } await Promise.all(tasks); }