Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
108 lines
3.6 KiB
TypeScript
108 lines
3.6 KiB
TypeScript
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<string, string> = {
|
|
'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<Response | null> {
|
|
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<string | null> {
|
|
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;
|
|
}
|
|
}
|