import db from "../../db.server"; const MAX_BYTES = 5 * 1024 * 1024; // 5 MB cap const STALE_AFTER_MS = 24 * 60 * 60 * 1000; // re-fetch once a day at most /** * Sentinel value stored in `ShopSettings.logoUrl` when the logo was uploaded * directly through the settings UI (rather than fetched from a remote URL). * The actual bytes live in `LogoCache` for that shop. */ export const STORED_LOGO_SENTINEL = "stored://shop-logo"; /** * Returns a `data:` URL for the shop's logo bytes, fetching from the * configured URL on first use (or when stale) and persisting to the * `LogoCache` table for subsequent renders. */ export async function getLogoDataUrl( shopDomain: string, logoUrl: string, ): Promise { if (!logoUrl) return undefined; const cached = await db.logoCache.findUnique({ where: { shopDomain } }); // Locally uploaded logo: bytes live in LogoCache, no HTTP fetch. if (logoUrl === STORED_LOGO_SENTINEL) { return cached ? toDataUrl(cached.bytes, cached.contentType) : undefined; } const isFresh = cached && cached.sourceUrl === logoUrl && Date.now() - cached.fetchedAt.getTime() < STALE_AFTER_MS; if (isFresh && cached) { return toDataUrl(cached.bytes, cached.contentType); } let response: Response; try { response = await fetch(logoUrl); } catch (err) { console.warn(`Logo fetch failed for ${shopDomain}:`, err); return cached ? toDataUrl(cached.bytes, cached.contentType) : undefined; } if (!response.ok) { console.warn(`Logo fetch HTTP ${response.status} for ${shopDomain}`); return cached ? toDataUrl(cached.bytes, cached.contentType) : undefined; } const arrayBuf = await response.arrayBuffer(); if (arrayBuf.byteLength > MAX_BYTES) { console.warn(`Logo too large (${arrayBuf.byteLength} bytes) — skipping cache.`); return undefined; } const bytes = Buffer.from(arrayBuf); const contentType = response.headers.get("content-type") || guessContentType(logoUrl); const etag = response.headers.get("etag") || ""; await db.logoCache.upsert({ where: { shopDomain }, create: { shopDomain, sourceUrl: logoUrl, bytes, contentType, etag }, update: { sourceUrl: logoUrl, bytes, contentType, etag, fetchedAt: new Date() }, }); return toDataUrl(bytes, contentType); } function toDataUrl(bytes: Buffer | Uint8Array, contentType: string): string { const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes); return `data:${contentType};base64,${buf.toString("base64")}`; } function guessContentType(url: string): string { const lower = url.toLowerCase(); if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; if (lower.endsWith(".webp")) return "image/webp"; return "image/png"; } const ALLOWED_LOGO_MIME = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]); export interface StoreUploadedLogoResult { ok: boolean; error?: string; contentType?: string; byteLength?: number; } /** * Persists an uploaded logo file directly into `LogoCache`. Caller is * responsible for setting `ShopSettings.logoUrl = STORED_LOGO_SENTINEL`. */ export async function storeUploadedLogo( shopDomain: string, bytes: Buffer, contentType: string, ): Promise { const ct = (contentType || "").toLowerCase(); if (!ALLOWED_LOGO_MIME.has(ct)) { return { ok: false, error: `Unsupported image type "${contentType || "unknown"}". Use PNG, JPEG, WebP or GIF.` }; } if (bytes.byteLength === 0) { return { ok: false, error: "Uploaded file is empty." }; } if (bytes.byteLength > MAX_BYTES) { return { ok: false, error: `File too large (${(bytes.byteLength / 1024 / 1024).toFixed(2)} MB). Max is ${MAX_BYTES / 1024 / 1024} MB.` }; } const bytesU8 = new Uint8Array(bytes); await db.logoCache.upsert({ where: { shopDomain }, create: { shopDomain, sourceUrl: STORED_LOGO_SENTINEL, bytes: bytesU8, contentType: ct, etag: "" }, update: { sourceUrl: STORED_LOGO_SENTINEL, bytes: bytesU8, contentType: ct, etag: "", fetchedAt: new Date() }, }); return { ok: true, contentType: ct, byteLength: bytes.byteLength }; } export async function deleteStoredLogo(shopDomain: string): Promise { await db.logoCache.deleteMany({ where: { shopDomain } }); }