aboutsummaryrefslogtreecommitdiffstats
path: root/names
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-27 19:10:05 -0700
committerAdam Soltys <[email protected]>2026-09-27 19:10:05 -0700
commit55cb118f25ddd69ffe9cc4bd44a36b1e689ee19e (patch)
tree2446aaf0c3827ac124d9c75d967b62a8ef6d6f5a /names
parent10b7a97a3ecfc2f8e084c81520c78942ac745ec5 (diff)
downloadcoinosv3-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')
-rw-r--r--names/preview.js131
-rw-r--r--names/server.js23
2 files changed, 154 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;
+}
diff --git a/names/server.js b/names/server.js
index d99e330..82d00ec 100644
--- a/names/server.js
+++ b/names/server.js
@@ -27,6 +27,7 @@ import { HDKey } from '@scure/bip32';
import { mnemonicToSeedSync } from '@scure/bip39';
import { lnBackend } from '../bridge/ln.js';
import { ArkManager } from '../src/ark/manager.js';
+import { fetchPreview } from './preview.js';
import { decodeAddress } from '../src/ark/proto.js';
import { decodeNoffer } from '../src/noffer.js';
import { decodeBolt11 } from '../src/ark/lightning.js';
@@ -847,6 +848,7 @@ setInterval(() => { drainWebhooks().catch(() => {}); }, 30_000);
// crude per-IP limiter
const rate = new Map();
+const previews = new Map(); // url -> { meta, until }
function rateOk(ip, limit = 10) {
const now = Date.now();
const arr = (rate.get(ip) || []).filter((t) => t > now - 60_000);
@@ -982,6 +984,27 @@ Bun.serve({
return json({ error: 'not found' }, 404);
}
+ // A link in a post, as the card its page describes for itself (Open
+ // Graph). Cached a day either way: a page with nothing to show is asked
+ // again only after an hour. Public data; the CDN may cache it too.
+ if (url.pathname === '/preview' && req.method === 'GET') {
+ if (!rateOk(ip, 120)) return json({ error: 'rate limited' }, 429);
+ const target = (url.searchParams.get('url') || '').trim();
+ if (!/^https?:\/\/\S{4,2000}$/.test(target)) return json({ error: 'bad url' }, 400);
+ const now = Date.now();
+ let hit = previews.get(target);
+ if (!hit || now > hit.until) {
+ const meta = await fetchPreview(target).catch(() => null);
+ hit = { meta, until: now + (meta ? 24 : 1) * 3600_000 };
+ previews.set(target, hit);
+ if (previews.size > 5000) previews.delete(previews.keys().next().value);
+ }
+ return new Response(JSON.stringify(hit.meta || {}), {
+ headers: { 'content-type': 'application/json', 'access-control-allow-origin': '*',
+ 'cache-control': 'public, max-age=' + (hit.meta ? 86400 : 3600) },
+ });
+ }
+
if (url.pathname === '/health') {
return json({
ok: true, domain: DOMAIN, names: Object.keys(state.names).length,