import { resolve4, resolve6 } from "node:dns/promises"; import { isIP } from "node:net"; export type RssItem = { id: string; content: string; link: string; publishedAt: string | null }; const MAX_FEED_BYTES = 2 * 1024 * 1024; const decode = (value: string) => value.replace(//g, "$1").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/&/g, "&"); const field = (xml: string, name: string) => decode(xml.match(new RegExp(`<${name}[^>]*>([\\s\\S]*?)`, "i"))?.[1]?.trim() || ""); const atomLink = (xml: string) => decode(xml.match(/]*\bhref=["']([^"']+)["'][^>]*>/i)?.[1] || ""); const date = (value: string) => { const parsed = new Date(value); return value && !Number.isNaN(parsed.getTime()) ? parsed.toISOString() : null; }; function privateIp(address: string) { if (isIP(address) === 4) { const [a, b] = address.split(".").map(Number); return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168); } const normalized = address.toLowerCase(); return normalized === "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe80:") || normalized.startsWith("::ffff:127.") || normalized.startsWith("::ffff:10.") || normalized.startsWith("::ffff:192.168."); } async function assertPublicHttps(raw: string) { const url = new URL(raw); if (url.protocol !== "https:" || url.username || url.password) throw new Error("RSS feed must use a public HTTPS URL"); const host = url.hostname.toLowerCase(); if (host === "localhost" || host.endsWith(".local")) throw new Error("RSS feed host is not public"); const direct = isIP(host); const addresses = direct ? [host] : [...await resolve4(host).catch(() => [] as string[]), ...await resolve6(host).catch(() => [] as string[])]; if (!addresses.length || addresses.some(privateIp)) throw new Error("RSS feed host is not public"); return url; } async function responseText(response: Response) { const headerSize = Number(response.headers.get("content-length") || 0); if (headerSize > MAX_FEED_BYTES) throw new Error("RSS feed is too large"); const reader = response.body?.getReader(); if (!reader) return ""; const chunks: Uint8Array[] = []; let total = 0; while (true) { const { done, value } = await reader.read(); if (done) break; total += value.byteLength; if (total > MAX_FEED_BYTES) { await reader.cancel(); throw new Error("RSS feed is too large"); } chunks.push(value); } return Buffer.concat(chunks.map((item) => Buffer.from(item))).toString("utf8"); } export function parseFeed(xml: string): RssItem[] { if (//gi) || []; const atom = xml.match(//gi) || []; const entries = rss.length ? rss.map((body) => ({ body, atom: false })) : atom.map((body) => ({ body, atom: true })); return entries.map(({ body, atom }) => { const link = atom ? atomLink(body) : field(body, "link"); const id = field(body, atom ? "id" : "guid") || link; const content = field(body, atom ? "content" : "description") || field(body, atom ? "summary" : "title") || field(body, "title"); return { id, link, content, publishedAt: date(field(body, atom ? "published" : "pubDate") || field(body, atom ? "updated" : "")) }; }).filter((item) => item.id && item.content) as RssItem[]; } export async function fetchRss(raw: string) { let url = await assertPublicHttps(raw); for (let redirects = 0; redirects <= 3; redirects++) { const response = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(15_000), headers: { Accept: "application/rss+xml, application/atom+xml, application/xml, text/xml" } }); if ([301, 302, 303, 307, 308].includes(response.status)) { const location = response.headers.get("location"); if (!location || redirects === 3) throw new Error("RSS feed redirect is invalid"); url = await assertPublicHttps(new URL(location, url).toString()); continue; } if (!response.ok) throw new Error(`RSS ${response.status}`); return parseFeed(await responseText(response)); } throw new Error("RSS feed redirect is invalid"); }