58cfc30cd7
Vite/React Router refused to bundle the client because app/routes/app.settings.tsx imported the constant from a .server file and used it inside the route component (not just loader/action), so it could not be tree-shaken out. Move the sentinel to logoCache.constants.ts, re-export from logoCache.server.ts for backwards compatibility, and import the constant from constants in the route while keeping the server-only functions (deleteStoredLogo, storeUploadedLogo) imported from .server (they are only referenced inside the action and get tree-shaken correctly).
128 lines
4.4 KiB
TypeScript
128 lines
4.4 KiB
TypeScript
import db from "../../db.server";
|
|
import { STORED_LOGO_SENTINEL } from "./logoCache.constants";
|
|
|
|
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<string | undefined> {
|
|
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<StoreUploadedLogoResult> {
|
|
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<void> {
|
|
await db.logoCache.deleteMany({ where: { shopDomain } });
|
|
}
|