/** * Image cache core (§113 basics, §166 delivery). Node-only: shared by the `/img` proxy route * (apps/web, via src/lib/images-core.ts re-export + experimental.externalDir) and the * image-processing worker. Lives in workers/ so it is an ES module for tsc. No Next.js imports here. * * Why a self-hosted cache: Next's optimizer fetches with Node's default `User-Agent: node`, which * Scryfall (62k Magic images) rejects with 400; other hosts hot-link-protect or rate-limit. We fetch * once with honest, host-appropriate headers, keep the original on disk and serve resized WebP. */ import { createHash } from 'node:crypto'; import { existsSync, mkdirSync } from 'node:fs'; import { readFile, rename, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; import sharp, { type Metadata } from 'sharp'; export const IMAGE_WIDTHS = [96, 192, 384, 768, 1200] as const; export type ImageWidth = (typeof IMAGE_WIDTHS)[number]; export const MAX_BYTES = 8 * 1024 * 1024; export const FETCH_TIMEOUT_MS = 20_000; const NEGATIVE_TTL_MS = 24 * 3600_000; const USER_AGENT = 'RareIndexImageCache/1.0 (+https://www.rareindex.io/about; caches product images with attribution; contact data@rareindex.io)'; const ACCEPT = 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8'; /** Per-host request rules. `referer: 'self'` sends the host's own origin (hot-link protection). */ interface HostRule { referer?: 'self' | string; headers?: Record; maxConcurrent?: number; /** minimum ms between requests to the host */ minIntervalMs?: number; } const HOST_RULES: Array<[RegExp, HostRule]> = [ [/(^|\.)scryfall\.(io|com)$/, { maxConcurrent: 4, minIntervalMs: 60 }], [/^storage\.googleapis\.com$/, { referer: 'https://www.pricecharting.com/', maxConcurrent: 6 }], [/(^|\.)pricecharting\.com$/, { referer: 'self', maxConcurrent: 4 }], [/(^|\.)chrono24\.(com|de|fr|ch)$/, { referer: 'https://www.chrono24.com/', maxConcurrent: 3, minIntervalMs: 200 }], [/(^|\.)goldin\.co$/, { referer: 'https://goldin.co/', maxConcurrent: 3 }], [/(^|\.)comicconnect\.com$/, { referer: 'self', maxConcurrent: 3 }], [/(^|\.)phillips\.com$/, { referer: 'https://www.phillips.com/', maxConcurrent: 4 }], [/(^|\.)brickeconomy\.com$/, { referer: 'self', maxConcurrent: 2, minIntervalMs: 300 }], [/(^|\.)ygoprodeck\.com$/, { maxConcurrent: 4, minIntervalMs: 50 }], [/(^|\.)pokemontcg\.io$/, { maxConcurrent: 4 }], [/(^|\.)tcgdex\.net$/, { maxConcurrent: 4 }], [/(^|\.)lorcast\.io$/, { maxConcurrent: 4 }], [/(^|\.)cloudfront\.net$/, { maxConcurrent: 6 }], [/(^|\.)catawiki\.(com|nl)$|(^|\.)assets\.catawiki\.nl$/, { referer: 'https://www.catawiki.com/', maxConcurrent: 3 }], [/(^|\.)sothebys\.com$|(^|\.)christies\.com$|(^|\.)bonhams\.com$/, { referer: 'self', maxConcurrent: 3 }], [/(^|\.)bringatrailer\.com$/, { referer: 'self', maxConcurrent: 3 }], [/(^|\.)ebayimg\.com$/, { maxConcurrent: 6 }], ]; export function hostRule(host: string): HostRule { const h = host.toLowerCase(); for (const [re, rule] of HOST_RULES) if (re.test(h)) return rule; return { maxConcurrent: 3 }; } export function requestHeadersFor(url: URL): Record { const rule = hostRule(url.hostname); const headers: Record = { 'user-agent': USER_AGENT, accept: ACCEPT, 'accept-language': 'en-US,en;q=0.8', ...(rule.headers ?? {}) }; if (rule.referer === 'self') headers.referer = `${url.protocol}//${url.hostname}/`; else if (rule.referer) headers.referer = rule.referer; return headers; } export function imageKey(url: string): string { return createHash('sha1').update(url).digest('hex'); } /** Resolve RI_DATA_DIR; a relative value is anchored at the monorepo root so web and workers share one cache. */ export function dataDir(): string { const raw = process.env.RI_DATA_DIR ?? './data'; if (path.isAbsolute(raw)) return raw; let dir = process.cwd(); for (let i = 0; i < 5; i++) { if (existsSync(path.join(dir, 'pnpm-workspace.yaml'))) return path.resolve(dir, raw); const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } return path.resolve(process.cwd(), raw); } export function cacheRoot(): string { return path.join(dataDir(), 'images'); } export function origPath(key: string, ext: string): string { return path.join(cacheRoot(), 'orig', key.slice(0, 2), `${key}.${ext}`); } export function variantPath(key: string, width: number, fmt: 'webp' | 'avif'): string { return path.join(cacheRoot(), `w${width}`, key.slice(0, 2), `${key}.${fmt}`); } export function nearestWidth(w: unknown): ImageWidth { const n = Number(w); if (!Number.isFinite(n) || n <= 0) return 384; for (const cand of IMAGE_WIDTHS) if (n <= cand) return cand; return 1200; } // ---------- host politeness ---------- const inflight = new Map(); const lastAt = new Map(); async function acquire(host: string): Promise<() => void> { const rule = hostRule(host); const max = rule.maxConcurrent ?? 3; while ((inflight.get(host) ?? 0) >= max) await new Promise((r) => setTimeout(r, 25)); inflight.set(host, (inflight.get(host) ?? 0) + 1); const wait = (lastAt.get(host) ?? 0) + (rule.minIntervalMs ?? 0) - Date.now(); if (wait > 0) await new Promise((r) => setTimeout(r, wait)); lastAt.set(host, Date.now()); return () => inflight.set(host, Math.max(0, (inflight.get(host) ?? 1) - 1)); } // ---------- negative cache (in-process; the worker also persists status in the DB) ---------- const negative = new Map(); export function negativeFor(url: string): string | null { const n = negative.get(url); if (!n) return null; if (n.until < Date.now()) { negative.delete(url); return null; } return n.reason; } export function markNegative(url: string, reason: string, ttlMs = NEGATIVE_TTL_MS): void { negative.set(url, { until: Date.now() + ttlMs, reason }); if (negative.size > 50_000) negative.delete(negative.keys().next().value!); } export interface OriginalInfo { key: string; path: string; ext: string; contentType: string; bytes: number; width: number | null; height: number | null; fromCache: boolean; } export type FetchOutcome = { ok: true; info: OriginalInfo } | { ok: false; status: 'dead' | 'blocked' | 'error'; reason: string; httpStatus: number | null }; const EXT_BY_FORMAT: Record = { jpeg: 'jpg', jpg: 'jpg', png: 'png', webp: 'webp', avif: 'avif', gif: 'gif', tiff: 'tif', svg: 'svg', heif: 'heic' }; const CT_BY_EXT: Record = { jpg: 'image/jpeg', png: 'image/png', webp: 'image/webp', avif: 'image/avif', gif: 'image/gif', tif: 'image/tiff', svg: 'image/svg+xml', heic: 'image/heic' }; const originalInflight = new Map>(); /** Find an already cached original for a key (any extension). */ export async function findOriginal(key: string): Promise { for (const ext of Object.keys(CT_BY_EXT)) { const p = origPath(key, ext); try { const s = await stat(p); return { key, path: p, ext, contentType: CT_BY_EXT[ext]!, bytes: s.size, width: null, height: null, fromCache: true }; } catch { /* next */ } } return null; } /** Fetch + validate + store the original image; idempotent and de-duplicated per URL. */ export async function ensureOriginal(url: string): Promise { const key = imageKey(url); const cached = await findOriginal(key); if (cached) return { ok: true, info: cached }; const neg = negativeFor(url); if (neg) return { ok: false, status: neg.startsWith('blocked') ? 'blocked' : 'dead', reason: neg, httpStatus: null }; const existing = originalInflight.get(url); if (existing) return existing; const p = fetchOriginal(url, key).finally(() => originalInflight.delete(url)); originalInflight.set(url, p); return p; } async function fetchOriginal(url: string, key: string): Promise { let u: URL; try { u = new URL(url); if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error('protocol'); } catch { markNegative(url, 'dead: invalid url'); return { ok: false, status: 'dead', reason: 'invalid url', httpStatus: null }; } const release = await acquire(u.hostname); const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS); try { const res = await fetch(u, { headers: requestHeadersFor(u), signal: ctrl.signal, redirect: 'follow' }); if (res.status === 404 || res.status === 410) { markNegative(url, `dead: http ${res.status}`); return { ok: false, status: 'dead', reason: `http ${res.status}`, httpStatus: res.status }; } if (res.status === 401 || res.status === 403 || res.status === 429 || res.status === 400) { markNegative(url, `blocked: http ${res.status}`, res.status === 429 ? 3600_000 : NEGATIVE_TTL_MS); return { ok: false, status: 'blocked', reason: `http ${res.status}`, httpStatus: res.status }; } if (!res.ok) { markNegative(url, `error: http ${res.status}`, 3600_000); return { ok: false, status: 'error', reason: `http ${res.status}`, httpStatus: res.status }; } const len = Number(res.headers.get('content-length') ?? 0); if (len > MAX_BYTES) { markNegative(url, 'dead: too large'); return { ok: false, status: 'dead', reason: `too large (${len} bytes)`, httpStatus: res.status }; } const buf = Buffer.from(await res.arrayBuffer()); if (buf.byteLength === 0 || buf.byteLength > MAX_BYTES) { markNegative(url, 'dead: empty or too large'); return { ok: false, status: 'dead', reason: `size ${buf.byteLength}`, httpStatus: res.status }; } // Validate by decoding metadata (also guards against HTML error pages served as 200). let meta: Metadata; try { meta = await sharp(buf, { limitInputPixels: 80_000_000 }).metadata(); } catch { markNegative(url, 'dead: not an image'); return { ok: false, status: 'dead', reason: `not an image (${res.headers.get('content-type') ?? 'unknown type'})`, httpStatus: res.status }; } const ext = EXT_BY_FORMAT[meta.format ?? ''] ?? 'jpg'; const dest = origPath(key, ext); mkdirSync(path.dirname(dest), { recursive: true }); const tmp = `${dest}.${process.pid}.tmp`; await writeFile(tmp, buf); await rename(tmp, dest); return { ok: true, info: { key, path: dest, ext, contentType: CT_BY_EXT[ext] ?? 'application/octet-stream', bytes: buf.byteLength, width: meta.width ?? null, height: meta.height ?? null, fromCache: false } }; } catch (err) { const reason = err instanceof Error ? (err.name === 'AbortError' ? 'timeout' : err.message) : String(err); markNegative(url, `error: ${reason}`, 3600_000); return { ok: false, status: 'error', reason, httpStatus: null }; } finally { clearTimeout(timer); release(); } } const variantInflight = new Map>(); /** Produce (or reuse) a resized WebP/AVIF variant; returns its path. */ export async function ensureVariant(info: OriginalInfo, width: ImageWidth, fmt: 'webp' | 'avif' = 'webp'): Promise { const dest = variantPath(info.key, width, fmt); try { await stat(dest); return dest; } catch { /* build */ } const k = `${dest}`; const existing = variantInflight.get(k); if (existing) return existing; const p = (async () => { mkdirSync(path.dirname(dest), { recursive: true }); const tmp = `${dest}.${process.pid}.tmp`; let pipeline = sharp(info.path, { limitInputPixels: 80_000_000, animated: false }).rotate().resize({ width, withoutEnlargement: true, fit: 'inside' }); pipeline = fmt === 'avif' ? pipeline.avif({ quality: 55, effort: 3 }) : pipeline.webp({ quality: 80, effort: 4 }); await pipeline.toFile(tmp); await rename(tmp, dest); return dest; })().finally(() => variantInflight.delete(k)); variantInflight.set(k, p); return p; } export async function readVariant(p: string): Promise { return readFile(p); } /** 64-bit difference hash (dHash) over a 9×8 grayscale thumbnail; robust to resize/recompression. */ export function dhashFromGray(pixels: Uint8Array | number[], width = 9, height = 8): string { let bits = ''; for (let y = 0; y < height; y++) { for (let x = 0; x < width - 1; x++) { const a = pixels[y * width + x]!; const b = pixels[y * width + x + 1]!; bits += a > b ? '1' : '0'; } } return BigInt(`0b${bits}`).toString(16).padStart(16, '0'); } export function hamming(a: string, b: string): number { const x = BigInt(`0x${a}`) ^ BigInt(`0x${b}`); let n = 0; let v = x; while (v) { n += Number(v & 1n); v >>= 1n; } return n; } export async function perceptualHash(filePath: string): Promise { try { const { data } = await sharp(filePath, { limitInputPixels: 80_000_000 }).rotate().grayscale().resize(9, 8, { fit: 'fill' }).raw().toBuffer({ resolveWithObject: true }); return dhashFromGray(data, 9, 8); } catch { return null; } }