// Reader view for watch-party presentations: fetch a public web page, pull // out the article (Mozilla Readability) and return clean, safe HTML that every // guest can read at their own font size. // // GET /api/party/:code/reader reads the link the party is presenting right now // // Only the party's current link can be fetched, so this is not an open proxy. // The fetch refuses private/loopback addresses (also after every redirect), // caps size and time, and the output keeps only an allow-list of tags and // attributes. import { lookup } from 'node:dns/promises'; import { isIP } from 'node:net'; import { parseHTML } from 'linkedom'; import { Readability } from '@mozilla/readability'; const MAX_BYTES = 3 * 1024 * 1024, TIMEOUT_MS = 12000, MAX_REDIRECTS = 4; const TAGS = new Set(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'em', 'strong', 'b', 'i', 'u', 's', 'a', 'img', 'figure', 'figcaption', 'br', 'hr', 'sup', 'sub', 'small', 'mark', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'caption', 'dl', 'dt', 'dd', 'span', 'div', 'section', 'article']); const DROP = new Set(['script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'textarea', 'select', 'noscript', 'svg', 'math', 'video', 'audio', 'canvas', 'link', 'meta', 'base', 'template']); export function privateAddress(ip) { const v = isIP(ip); if (v === 4) { const [a, b] = ip.split('.').map(Number); return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b < 128) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b < 32) || (a === 192 && b === 168) || (a === 198 && (b === 18 || b === 19)) || a >= 224; } if (v === 6) { const x = ip.toLowerCase(); if (x.startsWith('::ffff:')) return privateAddress(x.slice(7)); return x === '::' || x === '::1' || x.startsWith('fc') || x.startsWith('fd') || x.startsWith('fe8') || x.startsWith('fe9') || x.startsWith('fea') || x.startsWith('feb') || x.startsWith('ff'); } return true; } async function assertPublic(u, resolve) { if (!/^https?:$/.test(u.protocol)) throw new Error('only http and https links can be read'); if (u.port && !['80', '443'].includes(u.port)) throw new Error('that port is not allowed'); if (u.username || u.password) throw new Error('links with credentials are not allowed'); const host = u.hostname.replace(/^\[|\]$/g, ''); const addrs = isIP(host) ? [host] : (await resolve(host)).map((a) => a.address); if (!addrs.length || addrs.some(privateAddress)) throw new Error('that address is not on the public internet'); } async function fetchPublic(url, { fetchImpl, resolve }) { let u = new URL(url); for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { await assertPublic(u, resolve); const res = await fetchImpl(u.href, { redirect: 'manual', signal: AbortSignal.timeout(TIMEOUT_MS), headers: { 'User-Agent': 'Mozilla/5.0 (compatible; YTPlayerReader/1.0)', Accept: 'text/html,application/xhtml+xml' } }); if (res.status >= 300 && res.status < 400 && res.headers.get('location')) { u = new URL(res.headers.get('location'), u); continue; } if (!res.ok) throw new Error(`the site answered ${res.status}`); const type = res.headers.get('content-type') || ''; if (!/html|xml/i.test(type)) throw new Error('that link is not a web page (try Original view)'); const reader = res.body.getReader(); const parts = []; let size = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; size += value.length; if (size > MAX_BYTES) { reader.cancel(); throw new Error('that page is too large to read'); } parts.push(value); } const charset = (type.match(/charset=([\w-]+)/i) || [])[1] || 'utf-8'; let text; try { text = new TextDecoder(charset).decode(Buffer.concat(parts)); } catch { text = new TextDecoder().decode(Buffer.concat(parts)); } return { html: text, finalUrl: u.href }; } throw new Error('too many redirects'); } // Rebuild the article from an allow-list; anything else is unwrapped or dropped. export function sanitize(html, baseUrl) { const { document } = parseHTML(`