'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(/