Files
Mebbling/lib/rss.ts
T

36 lines
4.1 KiB
TypeScript

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(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&amp;/g, "&");
const field = (xml: string, name: string) => decode(xml.match(new RegExp(`<${name}[^>]*>([\\s\\S]*?)</${name}>`, "i"))?.[1]?.trim() || "");
const atomLink = (xml: string) => decode(xml.match(/<link\b[^>]*\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 (/<!DOCTYPE|<!ENTITY/i.test(xml)) throw new Error("RSS feed contains unsupported XML declarations");
const rss = xml.match(/<item\b[\s\S]*?<\/item>/gi) || []; const atom = xml.match(/<entry\b[\s\S]*?<\/entry>/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");
}