diff options
| author | Adam Soltys <[email protected]> | 2026-09-27 19:10:05 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-27 19:10:05 -0700 |
| commit | 55cb118f25ddd69ffe9cc4bd44a36b1e689ee19e (patch) | |
| tree | 2446aaf0c3827ac124d9c75d967b62a8ef6d6f5a /names/preview.js | |
| parent | 10b7a97a3ecfc2f8e084c81520c78942ac745ec5 (diff) | |
| download | coinosv3-55cb118f25ddd69ffe9cc4bd44a36b1e689ee19e.tar.gz coinosv3-55cb118f25ddd69ffe9cc4bd44a36b1e689ee19e.zip | |
Links in posts show as the cards their pages describe.
A plain link becomes an Open Graph card: picture, title, a line, the site.
The registrar fetches the page on the reader's behalf (names/preview.js,
GET /preview?url=): only public hosts, at most half a megabyte of HTML,
cached a day. The client warms the card with the rest of the post so the row
arrives whole, keeps a few hundred cards across sessions, and leaves a page
with nothing to say as a bare link. Pictures and YouTube keep their boxes.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VhUKyXKtsJ67QAFfb7QR9D
Diffstat (limited to 'names/preview.js')
| -rw-r--r-- | names/preview.js | 131 |
1 files changed, 131 insertions, 0 deletions
diff --git a/names/preview.js b/names/preview.js new file mode 100644 index 0000000..25051d9 --- /dev/null +++ b/names/preview.js @@ -0,0 +1,131 @@ +// 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 decodeEntities = (s) => String(s || '') + .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16))) + .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d))) + .replace(/&(amp|quot|apos|lt|gt|nbsp);/g, (_, e) => ({ amp: '&', quot: '"', apos: "'", lt: '<', gt: '>', nbsp: ' ' })[e]); +const tidy = (s, max) => decodeEntities(s).replace(/\s+/g, ' ').trim().slice(0, max); + +// Every <meta> in the head, whichever way its attributes are ordered. +function metaTags(html) { + const out = []; + const re = /<meta\s+([^>]*?)\/?>/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(/<title[^>]*>([\s\S]*?)<\/title>/i) || [])[1] || ''; + const title = tidy(pick('og:title', 'twitter:title') || titleTag, 200); + const description = tidy(pick('og:description', 'twitter:description', 'description'), 300); + let image = pick('og:image:secure_url', 'og:image', 'twitter:image', 'twitter:image:src'); + if (image) { + try { image = new URL(decodeEntities(image).trim(), baseUrl).href; } catch { image = ''; } + if (!/^https?:/.test(image)) image = ''; + } + let site = tidy(pick('og:site_name'), 80); + if (!site) { try { site = new URL(baseUrl).hostname.replace(/^www\./, ''); } catch {} } + if (!title && !image) return null; + return { url: baseUrl, title, description, image, site }; +} + +// Read at most MAX_BODY bytes of a response. +async function readCapped(res) { + const reader = res.body?.getReader(); + if (!reader) return ''; + const chunks = []; let got = 0; + while (got < MAX_BODY) { + const { value, done } = await reader.read(); + if (done) break; + chunks.push(value); got += value.length; + } + try { await reader.cancel(); } catch {} + return new TextDecoder('utf-8', { fatal: false }).decode(Buffer.concat(chunks.map((c) => Buffer.from(c)))); +} + +// Fetch a page and describe it, or null when there is nothing to show +// (not HTML, unreachable, a private host, no title or picture). +export async function fetchPreview(str) { + let u = await publicUrl(str); + for (let hop = 0; u && hop < MAX_HOPS; hop++) { + let res; + try { + res = await fetch(u.href, { + redirect: 'manual', + signal: AbortSignal.timeout(TIMEOUT_MS), + headers: { 'user-agent': UA, accept: 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.1', 'accept-language': 'en' }, + }); + } catch { return null; } + if (res.status >= 300 && res.status < 400 && res.headers.get('location')) { + let next; + try { next = new URL(res.headers.get('location'), u.href).href; } catch { return null; } + try { await res.body?.cancel(); } catch {} + u = await publicUrl(next); + continue; + } + if (!res.ok) return null; + const type = (res.headers.get('content-type') || '').toLowerCase(); + if (!/text\/html|application\/xhtml/.test(type)) { try { await res.body?.cancel(); } catch {} return null; } + const html = await readCapped(res); + return parsePreview(html, u.href); + } + return null; +} |
