'use strict'; // What a web link card shows: the page's title and description, read from the start of its HTML. // Runs in the main process (no CORS there). Only public http(s) addresses are fetched: a canvas file that // someone else made must not be able to make this app read an address on your own network. const dns = require('node:dns/promises'); const net = require('node:net'); const MAX_BYTES = 512 * 1024; // of HTML read; the tags we want are at the top const TIMEOUT = 8000; const MAX_REDIRECTS = 5; // Replaceable for tests: { fetch, lookup } const hooks = { fetch: null, lookup: null }; // Addresses that are not on the public internet (this machine, the local network, link-local, multicast, …). const PRIVATE = new net.BlockList(); for (const [ip, bits] of [['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['224.0.0.0', 3]]) PRIVATE.addSubnet(ip, bits, 'ipv4'); for (const [ip, bits] of [['::', 128], ['::1', 128], ['fc00::', 7], ['fe80::', 10], ['ff00::', 8]]) PRIVATE.addSubnet(ip, bits, 'ipv6'); function privateAddress(ip) { const v = net.isIP(ip); if (!v) return true; const x = ip.toLowerCase(); if (v === 6) { // IPv4 inside IPv6 (::ffff:a.b.c.d, which URL parsing turns into ::ffff:aabb:ccdd, or ::a.b.c.d): judge the IPv4 part const dotted = x.match(/^::(?:ffff:)?(\d+\.\d+\.\d+\.\d+)$/); const hex = x.match(/^::(?:ffff:)?([0-9a-f]{1,4}):([0-9a-f]{1,4})$/); let v4 = dotted ? dotted[1] : null; if (hex) { const hi = parseInt(hex[1], 16), lo = parseInt(hex[2], 16); v4 = `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`; } if (v4 && PRIVATE.check(v4, 'ipv4')) return true; } return PRIVATE.check(x, v === 4 ? 'ipv4' : 'ipv6'); } // The URL if it may be fetched, else null. async function allowed(raw, lookup) { let u; try { u = new URL(raw); } catch { return null; } if (u.protocol !== 'http:' && u.protocol !== 'https:') return null; if (u.username || u.password) return null; const host = u.hostname.replace(/^\[|\]$/g, ''); if (!host) return null; if (net.isIP(host)) return privateAddress(host) ? null : u; if (/(^|\.)localhost$/i.test(host)) return null; try { const addrs = await (lookup || ((h) => dns.lookup(h, { all: true })))(host); if (!addrs.length || addrs.some((a) => privateAddress(a.address))) return null; } catch { return null; } return u; } const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', ndash: '–', mdash: '—', hellip: '…', rsquo: '’', lsquo: '‘', rdquo: '”', ldquo: '“', copy: '©' }; const decode = (s) => s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => { if (e[0] === '#') { const n = e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10); try { return n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : m; } catch { return m; } } return ENTITIES[e.toLowerCase()] ?? m; }); const tidy = (s, max) => { const t = decode(String(s || '')).replace(/\s+/g, ' ').trim(); return t.length > max ? t.slice(0, max - 1).trimEnd() + '…' : t; }; // { title, description, site } from a page's HTML (empty strings where it says nothing) function parsePreview(html) { const head = String(html).slice(0, MAX_BYTES); const meta = {}; for (const tag of head.match(/]*>/gi) || []) { const at = {}; for (const m of tag.matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g)) at[m[1].toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? ''; const key = (at.property || at.name || '').toLowerCase(); if (key && at.content !== undefined && !(key in meta)) meta[key] = at.content; } const t = head.match(/]*>([\s\S]*?)<\/title>/i); return { title: tidy(meta['og:title'] || meta['twitter:title'] || (t ? t[1] : ''), 200), description: tidy(meta['og:description'] || meta['twitter:description'] || meta.description, 400), site: tidy(meta['og:site_name'], 80) }; } function charsetOf(contentType, bytes) { const h = /charset\s*=\s*["']?([\w-]+)/i.exec(contentType || ''); if (h) return h[1]; const m = /]+charset\s*=\s*["']?([\w-]+)/i.exec(Buffer.from(bytes.subarray(0, 4096)).toString('latin1')); return m ? m[1] : 'utf-8'; } async function readLimited(res, limit = MAX_BYTES) { const chunks = []; let size = 0; const reader = res.body.getReader(); try { while (size < limit) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); size += value.length; } } finally { reader.cancel().catch(() => {}); } return Buffer.concat(chunks.map((c) => Buffer.from(c))).subarray(0, limit); } // { title, description, site } for a web address, or null when there is nothing to show. async function fetchPreview(url, { fetch: doFetch, lookup } = {}) { const get = doFetch || hooks.fetch || globalThis.fetch; const look = lookup || hooks.lookup; const signal = AbortSignal.timeout(TIMEOUT); let u = await allowed(url, look); for (let hop = 0; u && hop <= MAX_REDIRECTS; hop++) { let res; try { res = await get(u.href, { redirect: 'manual', signal, headers: { 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36', accept: 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.1', 'accept-language': 'en;q=0.8' } }); } catch { return null; } if (res.status >= 300 && res.status < 400) { const loc = res.headers.get('location'); u = loc ? await allowed(new URL(loc, u).href, look) : null; // every hop is checked again continue; } if (!res.ok || !/html/i.test(res.headers.get('content-type') || '') || !res.body) return null; const bytes = await readLimited(res); let text; try { text = new TextDecoder(charsetOf(res.headers.get('content-type'), bytes)).decode(bytes); } catch { text = bytes.toString('utf8'); } const p = parsePreview(text); return p.title || p.description ? p : null; } return null; } // { html, url } for a whole web page (up to 3 MB), under the same rules as previews; null if it can't be had. async function fetchHtml(url, { fetch: doFetch, lookup } = {}) { const get = doFetch || hooks.fetch || globalThis.fetch; const look = lookup || hooks.lookup; const signal = AbortSignal.timeout(15000); let u = await allowed(url, look); for (let hop = 0; u && hop <= MAX_REDIRECTS; hop++) { let res; try { res = await get(u.href, { redirect: 'manual', signal, headers: { 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36', accept: 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.1' } }); } catch { return null; } if (res.status >= 300 && res.status < 400) { const loc = res.headers.get('location'); u = loc ? await allowed(new URL(loc, u).href, look) : null; continue; } if (!res.ok || !/html/i.test(res.headers.get('content-type') || '') || !res.body) return null; const bytes = await readLimited(res, 3 * 1024 * 1024); let text; try { text = new TextDecoder(charsetOf(res.headers.get('content-type'), bytes)).decode(bytes); } catch { text = bytes.toString('utf8'); } return { html: text, url: u.href }; } return null; } module.exports = { fetchHtml, fetchPreview, parsePreview, allowed, hooks };