diff --git a/src/lib/clean-pdf/generate.ts b/src/lib/clean-pdf/generate.ts index 7a6c8f8..6be925c 100644 --- a/src/lib/clean-pdf/generate.ts +++ b/src/lib/clean-pdf/generate.ts @@ -8,8 +8,23 @@ 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"; @@ -38,7 +53,7 @@ export async function generateCleanPdf(opts: { await context.route("**/*", (route) => { try { const u = new URL(route.request().url()); - const host = u.hostname.replace(/^\[|\]$/g, ""); + const host = u.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, ""); if ( (u.protocol !== "http:" && u.protocol !== "https:") || host === "localhost" || host.endsWith(".local") || host.endsWith(".internal") || @@ -52,6 +67,26 @@ export async function generateCleanPdf(opts: { 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}`); @@ -84,13 +119,18 @@ export async function generateCleanPdf(opts: { 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) — брифовский вариант с - // timeout здесь не компилировался, поле убрано. - const pdf = await pdfPage.pdf({ - format: opts.format, - printBackground: true, - margin: { top: "15mm", bottom: "18mm", left: "15mm", right: "15mm" }, - }); + // 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 {