// Link previews for the wallet's feed: the Open Graph card a page describes // for itself (title, one line of description, a picture, the site's name). // A browser can't read another site's HTML, so the registrar fetches it // once on the reader's behalf and hands back only what a card needs. // // The server sees more than the internet does (containers, the LAN), so a // URL is fetched only after its host resolves to public addresses — and // again after every redirect. Bodies are capped, and only HTML is read. import { promises as dns } from 'node:dns'; const MAX_BODY = 512 * 1024; const TIMEOUT_MS = 7000; const MAX_HOPS = 4; const UA = 'Mozilla/5.0 (compatible; coinos-link-preview/1.0; +https://coinos.io)'; const isPrivate4 = (ip) => { const p = ip.split('.').map(Number); return p[0] === 10 || p[0] === 127 || p[0] === 0 || (p[0] === 172 && p[1] >= 16 && p[1] <= 31) || (p[0] === 192 && p[1] === 168) || (p[0] === 169 && p[1] === 254) || (p[0] === 100 && p[1] >= 64 && p[1] <= 127) || p[0] >= 224; }; const isPrivate6 = (ip) => { const s = ip.toLowerCase(); if (s === '::1' || s === '::') return true; if (s.startsWith('::ffff:')) return isPrivate4(s.slice(7)); return /^(fc|fd|fe[89ab])/.test(s); }; export const isPrivateIp = (ip) => (ip.includes(':') ? isPrivate6(ip) : isPrivate4(ip)); // A URL the server may fetch: http(s), a default port, a public host. async function publicUrl(str) { let u; try { u = new URL(str); } catch { return null; } if (!/^https?:$/.test(u.protocol)) return null; if (u.port && u.port !== '80' && u.port !== '443') return null; if (u.username || u.password) return null; const host = u.hostname.replace(/^\[|\]$/g, ''); if (!host || host === 'localhost' || /\.(local|internal|localhost)$/i.test(host)) return null; let addrs; try { addrs = await dns.lookup(host, { all: true }); } catch { return null; } if (!addrs.length || addrs.some((a) => isPrivateIp(a.address))) return null; return u; } const ENTITIES = { amp: '&', quot: '"', apos: "'", lt: '<', gt: '>', nbsp: ' ', ndash: '–', mdash: '—', hellip: '…', lsquo: '‘', rsquo: '’', ldquo: '“', rdquo: '”', laquo: '«', raquo: '»', copy: '©', reg: '®', trade: '™', middot: '·', bull: '•', euro: '€', pound: '£' }; const decodeEntities = (s) => String(s || '') .replace(/([0-9a-f]+);/gi, (_, h) => { try { return String.fromCodePoint(parseInt(h, 16)); } catch { return ''; } }) .replace(/(\d+);/g, (_, d) => { try { return String.fromCodePoint(Number(d)); } catch { return ''; } }) .replace(/&([a-z]+);/gi, (m, e) => ENTITIES[e.toLowerCase()] ?? m); const tidy = (s, max) => decodeEntities(s).replace(/\s+/g, ' ').trim().slice(0, max); // Every in the head, whichever way its attributes are ordered. function metaTags(html) { const out = []; const re = /]*?)\/?>/gi; let m; while ((m = re.exec(html))) { const attrs = {}; const ar = /([a-zA-Z:_-]+)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g; let a; while ((a = ar.exec(m[1]))) attrs[a[1].toLowerCase()] = a[3] ?? a[4] ?? a[5] ?? ''; const key = (attrs.property || attrs.name || attrs.itemprop || '').toLowerCase(); if (key && attrs.content != null) out.push([key, attrs.content]); } return out; } // What a card shows, from a page's HTML. Exported for the tests. export function parsePreview(html, baseUrl) { const head = html.slice(0, MAX_BODY); const meta = new Map(); for (const [k, v] of metaTags(head)) if (!meta.has(k) && v.trim()) meta.set(k, v); const pick = (...keys) => { for (const k of keys) if (meta.get(k)) return meta.get(k); return ''; }; const titleTag = (head.match(/