Files
CashuMints.space/shared/src/normalize.ts
T
michilisandCursor c97b44018d Add shared LNURL types, indexing helpers, and warnings.
Introduce lnurl as a first-class mint type with probe/announcement fields
and shared helpers the API and web can both rely on.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 03:44:27 +02:00

203 lines
8.7 KiB
TypeScript

/**
* 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\./, '');
}