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>
203 lines
8.7 KiB
TypeScript
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\./, '');
|
|
}
|