import { isIP } from "node:net"; import { chromium } from "playwright-core"; import { JSDOM } from "jsdom"; import { Defuddle } from "defuddle/node"; import { assertPublicUrl, isPrivateAddress } from "@/lib/clean-pdf/ssrf"; import { buildCleanHtml } from "@/lib/clean-pdf/template"; export class EmptyContentError extends Error {} export class RenderError extends Error {} /** * Оборачивает промис в app-level таймаут: если `p` не завершится за `ms`, * гонка завершится отказом с RenderError, и внешний `finally` (browser.close()) * освободит слот browserless вместо того, чтобы держать его до зависшего вызова. */ function withTimeout(p: Promise, ms: number, message: string): Promise { let timer: ReturnType; const guard = new Promise((_, reject) => { timer = setTimeout(() => reject(new RenderError(message)), ms); timer.unref?.(); }); return Promise.race([p, guard]).finally(() => clearTimeout(timer)) as Promise; } const GOTO_TIMEOUT_MS = 30_000; const SETTLE_TIMEOUT_MS = 10_000; const PDF_TIMEOUT_MS = 30_000; const UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36"; function browserWsUrl(): string { const base = process.env.BROWSER_WS_URL; if (!base) throw new RenderError("BROWSER_WS_URL не задан"); const token = process.env.BROWSERLESS_TOKEN; return token ? `${base}${base.includes("?") ? "&" : "?"}token=${token}` : base; } export async function generateCleanPdf(opts: { url: string; format: "A4" | "Letter"; theme: "light" | "dark"; }): Promise<{ pdf: Buffer; title: string }> { const target = await assertPublicUrl(opts.url); const browser = await chromium.connectOverCDP(browserWsUrl()).catch((e) => { throw new RenderError(`Браузер недоступен: ${e.message}`); }); try { const context = await browser.newContext({ userAgent: UA, viewport: { width: 1280, height: 800 } }); // Вторая линия SSRF-обороны: страница не может дёргать приватные адреса // (литеральные IP и localhost; hostnames уже проверены до goto). await context.route("**/*", (route) => { try { const u = new URL(route.request().url()); const host = u.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, ""); if ( (u.protocol !== "http:" && u.protocol !== "https:") || host === "localhost" || host.endsWith(".local") || host.endsWith(".internal") || (isIP(host) !== 0 && isPrivateAddress(host)) ) { return route.abort(); } } catch { return route.abort(); } return route.continue(); }); // context.route НЕ перехватывает WebSocket — отдельная защита от SSRF через ws:// await context.routeWebSocket("**/*", (ws) => { try { const u = new URL(ws.url()); const host = u.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, ""); if ( (u.protocol !== "ws:" && u.protocol !== "wss:") || host === "localhost" || host.endsWith(".local") || host.endsWith(".internal") || (isIP(host) !== 0 && isPrivateAddress(host)) ) { ws.close(); return; } } catch { ws.close(); return; } ws.connectToServer(); }); const page = await context.newPage(); await page.goto(target.href, { waitUntil: "domcontentloaded", timeout: GOTO_TIMEOUT_MS }).catch((e) => { throw new RenderError(`Страница не открылась: ${e.message}`); }); await page.waitForLoadState("networkidle", { timeout: SETTLE_TIMEOUT_MS }).catch(() => {}); const rawHtml = await page.content(); const pageTitle = await page.title().catch(() => ""); const dom = new JSDOM(rawHtml, { url: target.href }); const article = await Defuddle(dom.window.document, target.href); // DefuddleResponse (node_modules/defuddle/dist/types.d.ts) типизирует // content/title/author/site/domain/wordCount как обязательные (не optional) поля — // сам объект тоже не nullable (Defuddle() не возвращает null/undefined). // Отсутствие контента выражается пустой строкой/нулевым wordCount, не отсутствием поля. if (!article.content || article.wordCount < 10) { throw new EmptyContentError("Не удалось выделить содержимое страницы"); } const title = article.title || pageTitle || target.hostname; const cleanHtml = buildCleanHtml({ title, author: article.author || undefined, site: article.site || article.domain || undefined, url: target.href, contentHtml: article.content, theme: opts.theme, }); const pdfPage = await context.newPage(); await pdfPage.setContent(cleanHtml, { waitUntil: "networkidle", timeout: SETTLE_TIMEOUT_MS }).catch(() => {}); // page.pdf() в playwright-core не принимает опцию timeout (проверено по // node_modules/playwright-core/types/types.d.ts) и не имеет implicit-бага — // оборачиваем в app-level withTimeout, чтобы зависание всё же дошло до // finally (browser.close()) и не держало CONCURRENT-слот browserless. const pdf = await withTimeout( pdfPage.pdf({ format: opts.format, printBackground: true, margin: { top: "15mm", bottom: "18mm", left: "15mm", right: "15mm" }, }), PDF_TIMEOUT_MS, "Печать PDF не завершилась вовремя", ); return { pdf, title }; } finally { await browser.close().catch(() => {}); } }