import db from "../../db.server"; import { STORED_LOGO_SENTINEL } from "./logoCache.constants"; import { safeFetch, SafeFetchError } from "./safeFetch.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. * * Re-exported from `logoCache.constants` so existing server-side imports keep * working; new client/route imports should use `logoCache.constants` directly. */ export { STORED_LOGO_SENTINEL }; /** * 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: Awaited>; try { response = await safeFetch(logoUrl, { maxBytes: MAX_BYTES, accept: "image/*", }); } catch (err) { if (err instanceof SafeFetchError) { console.warn(`Logo fetch refused for ${shopDomain} (${err.code}): ${err.message}`); } else { console.warn(`Logo fetch failed for ${shopDomain}:`, err); } return cached ? toDataUrl(cached.bytes, cached.contentType) : undefined; } if (response.status < 200 || response.status >= 300) { console.warn(`Logo fetch HTTP ${response.status} for ${shopDomain}`); return cached ? toDataUrl(cached.bytes, cached.contentType) : undefined; } const bytes = Buffer.from(response.bytes); const contentType = response.contentType || guessContentType(logoUrl); const 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 } }); }