import fs from 'node:fs/promises'; import path from 'node:path'; import { isFetchableUrl } from '@cashumints/shared'; import { config } from './config.ts'; import { readBodyBounded } from './http.ts'; import { log } from './log.ts'; import type { MintRow } from './mints.ts'; const EXT_BY_TYPE: Record = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp', 'image/gif': '.gif', 'image/x-icon': '.ico', 'image/vnd.microsoft.icon': '.ico', // No SVG on purpose: SVG is markup that can carry script, and these files are // re-served from the site's own origin, so a cached one opened directly would run a // mint operator's script there. The sandbox header in server.ts covers files cached // before this rule existed. }; const MAX_ICON_BYTES = 512 * 1024; /** Redirect hops followed, each hop re-checked before it is fetched. */ const MAX_REDIRECTS = 3; /** * Fetch an icon with redirects validated hop by hop. * * `icon_url` is whatever the mint's /v1/info says it is, so every address on the way — * the first one and each Location after it — has to pass `isFetchableUrl`, or a public * URL that 302s to a metadata endpoint walks straight around a check done only once. */ async function fetchIconResponse(startUrl: string, signal: AbortSignal): Promise { let target = startUrl; for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { if (!isFetchableUrl(target)) return null; const res = await fetch(target, { signal, redirect: 'manual', headers: { 'User-Agent': config.userAgent }, }); if (res.status >= 300 && res.status < 400) { const location = res.headers.get('location'); if (!location) return null; try { target = new URL(location, target).toString(); } catch { return null; } continue; } return res.ok ? res : null; } return null; } /** * Cache a mint's icon to disk so offline mints keep theirs. * Re-fetches only when the icon_url changed, per BACKEND.md. * Returns the stored filename, or the existing one if nothing needed doing. */ export async function cacheIcon(row: MintRow, iconUrl: string | null): Promise { if (!iconUrl) return row.icon_file; if (iconUrl === row.icon_url && row.icon_file) return row.icon_file; // Some mints inline the icon as a data URI. Nothing to fetch, and nothing worth storing. if (iconUrl.startsWith('data:')) return row.icon_file; try { const absolute = new URL(iconUrl, `${row.url}/`).toString(); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), config.probeTimeoutMs); let buf: Buffer | null = null; let ext: string | undefined; try { const res = await fetchIconResponse(absolute, controller.signal); if (!res) return row.icon_file; const type = (res.headers.get('content-type') ?? '').split(';')[0]?.trim() ?? ''; ext = EXT_BY_TYPE[type]; if (!ext) return row.icon_file; // The timer stays armed through the body read: the abort is what stops a server // that sends its headers quickly and then never finishes the body. buf = await readBodyBounded(res, MAX_ICON_BYTES); } finally { clearTimeout(timer); } if (!buf || buf.byteLength === 0) return row.icon_file; const filename = `${row.host}${ext}`; await fs.writeFile(path.join(config.iconDir, filename), buf); return filename; } catch (err) { log.warn('icon fetch failed', { url: row.url, reason: err instanceof Error ? err.message : String(err), }); return row.icon_file; } }