/** * One mint URL normalizer, used by the indexer, the API and the browser. * * The old site had three different ad-hoc normalizers that disagreed (see NOTES.md): * mints were keyed on the raw tag string (so a trailing slash created a duplicate mint) * while reviews were matched on bare hostname (so a review of one path counted for every * path on that host). Rules below, per BACKEND.md: * * - lowercase scheme and host, force https, strip trailing slash, strip default port * - keep the path: mint.minibits.cash/Bitcoin is a different mint from mint.minibits.cash * - reject non-https-able, localhost, private IPs and .onion */ export interface NormalizedMint { /** Canonical URL, the primary key. */ url: string; /** URL-safe routing slug, unique per url. */ host: string; } const PRIVATE_IPV4 = /^(10\.|127\.|0\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.)/; /** Hostnames that are never a public mint. */ function isDisallowedHost(hostname: string): boolean { if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true; if (hostname.endsWith('.onion')) return true; if (hostname.endsWith('.local')) return true; if (PRIVATE_IPV4.test(hostname)) return true; if (isPrivateIpv6(hostname)) return true; // Anything that parses as an IP literal is judged by the resolved-address rule too, // so the two checks cannot disagree about, say, 100.64.0.1 or 224.0.0.1. if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname) && isPrivateIpAddress(hostname)) return true; // A bare label with no dot cannot be a public host. if (!hostname.includes('.') && !hostname.includes(':')) return true; return false; } /** * IPv6 forms that are loopback, link-local (fe80::/10), unique-local (fc00::/7) or an * IPv4-mapped address whose IPv4 part is private. The URL parser brackets an IPv6 * hostname, so both spellings are accepted. */ function isPrivateIpv6(hostname: string): boolean { const bare = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname; if (!bare.includes(':')) return false; if (bare === '::' || bare === '::1') return true; if (/^f[cd]/i.test(bare)) return true; if (/^fe[89ab]/i.test(bare)) return true; const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(bare); if (mapped?.[1]) return PRIVATE_IPV4.test(mapped[1]) || mapped[1].startsWith('127.'); return false; } /** * Is this literal IP address one the indexer must never connect to? * * Written against a *resolved* address rather than a hostname, which is the difference * between this and `isDisallowedHost` above: `mint.example.com` looks like an ordinary * public name and can resolve to `127.0.0.1`, and only the answer DNS gave can tell you * so. `POST /api/index` fetches URLs a stranger typed, so it resolves first and checks * every address here before a socket is opened. * * Broader than the hostname rule on purpose. Beyond loopback, link-local and the three * RFC-1918 ranges it also refuses carrier-grade NAT (100.64/10), `0.0.0.0/8`, the * benchmarking and documentation ranges, multicast and the broadcast address: none of * them is a public mint, and each of them is somewhere on a network this server can see * and a stranger should not be able to point it at. */ export function isPrivateIpAddress(value: string): boolean { const ip = value.trim().toLowerCase().replace(/^\[|\]$/g, ''); if (!ip) return true; const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip); if (v4) { const [a, b] = [Number(v4[1]), Number(v4[2])]; if (a === undefined || b === undefined || a > 255 || b > 255) return true; if (a === 0 || a === 10 || a === 127) return true; // this-network, RFC1918, loopback if (a === 169 && b === 254) return true; // link-local if (a === 172 && b >= 16 && b <= 31) return true; // RFC1918 if (a === 192 && b === 168) return true; // RFC1918 if (a === 192 && b === 0) return true; // IETF protocol assignments / 192.0.2.0 docs if (a === 198 && (b === 18 || b === 19)) return true; // benchmarking if (a === 198 && b === 51) return true; // documentation if (a === 203 && b === 0) return true; // documentation if (a === 100 && b >= 64 && b <= 127) return true; // carrier-grade NAT if (a >= 224) return true; // multicast, reserved, broadcast return false; } if (!ip.includes(':')) return true; // Not an address this function understands. // An IPv4-mapped or IPv4-compatible address is judged on its IPv4 half. const mapped = /:((?:\d{1,3}\.){3}\d{1,3})$/.exec(ip); if (mapped?.[1]) return isPrivateIpAddress(mapped[1]); if (ip === '::' || ip === '::1') return true; if (/^f[cd]/.test(ip)) return true; // unique local, fc00::/7 if (/^fe[89ab]/.test(ip)) return true; // link-local, fe80::/10 if (/^ff/.test(ip)) return true; // multicast return false; } /** * Is this hostname one the indexer must never fetch, before DNS is consulted at all? * * The cheap half of the check: `localhost`, `.onion`, `.local`, a bare label with no * dot, and an IP literal that is already disqualified by `isPrivateIpAddress`. Exported * so the on-demand indexer can refuse the obvious cases without paying for a lookup, * and so a redirect hop can be judged by the same rule its origin was. */ export function isBlockedHostname(hostname: string): boolean { return isDisallowedHost(hostname.toLowerCase()); } /** * May the indexer fetch this URL? http(s) only, and never a private or local host. * * For URLs a mint *publishes* rather than the URL it lives at — its `icon_url` above * all. Those never pass through `normalizeMintUrl`, so without this check a mint's * /v1/info could point the indexer at a cloud metadata endpoint or anything else on * the API host's own network. Callers that follow redirects must re-check every hop. */ export function isFetchableUrl(value: URL | string): boolean { let u: URL; try { u = typeof value === 'string' ? new URL(value) : value; } catch { return false; } if (u.protocol !== 'https:' && u.protocol !== 'http:') return false; const hostname = u.hostname.toLowerCase(); return hostname !== '' && !isDisallowedHost(hostname); } /** * Normalize a mint URL as found in a Nostr `u` tag or typed by a user. * Returns null when the input is not a usable public mint URL. */ export function normalizeMintUrl(input: string): NormalizedMint | null { const raw = input?.trim(); if (!raw) return null; // Tolerate a missing scheme ("mint.example.com/x"), which users paste constantly. const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`; let u: URL; try { u = new URL(withScheme); } catch { return null; } // http:// and https:// are the only schemes a mint speaks; http is upgraded, not kept, // because the browser cannot fetch http from an https page anyway (mixed content). if (u.protocol !== 'https:' && u.protocol !== 'http:') return null; u.protocol = 'https:'; const hostname = u.hostname.toLowerCase(); if (!hostname || isDisallowedHost(hostname)) return null; // Drop the default port, query and fragment: none of them identify a mint. const port = u.port === '443' || u.port === '80' ? '' : u.port; const path = u.pathname.replace(/\/+$/, ''); const url = `https://${hostname}${port ? `:${port}` : ''}${path}`; return { url, host: slugFor(hostname, port, path) }; } /** * Routing slug. Hostname alone when there is no path, otherwise hostname plus the path * segments joined with `-`. Deterministic; the row is looked up by slug, so it does not * need to be reversible. */ function slugFor(hostname: string, port: string, path: string): string { const segments = path.split('/').filter(Boolean); const base = port ? `${hostname}-${port}` : hostname; if (segments.length === 0) return base; const tail = segments .join('-') .toLowerCase() .replace(/[^a-z0-9._-]+/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, ''); return tail ? `${base}-${tail}` : base; } /** Display domain for a mint: hostname plus path, no scheme. */ export function displayDomain(url: string): string { return url.replace(/^https?:\/\//, '').replace(/\/+$/, ''); } /** * Short display name derived from the URL, for mints whose /v1/info has no name. * Matches the old site's `hostname.replace(/^mint\./,'').replace(/^www\./,'')`. */ export function nameFromUrl(url: string): string { const domain = displayDomain(url); const host = domain.split('/')[0] ?? domain; return host.replace(/^mint\./, '').replace(/^www\./, ''); }