// N8 (PLAN-ECOURT.md D3) — ดึงไบต์ไฟล์รีโมตจากโฮสต์ที่ allowlist ไว้ (server-only) // ใช้ 2 จุด: /api/pdf (viewer) และ /api/annotations/export — กัน CORS ฝั่ง browser // และคุม SSRF ให้เหลือจุดเดียว (validate ซ้ำอีกชั้น — defense in depth) import { isHttpUrl, validateExternalFileUrl } from "@/utils/external-file"; const MAX_REMOTE_BYTES = 100 * 1024 * 1024; // เท่า limit อัปโหลด N4 const FETCH_TIMEOUT_MS = 60_000; export class RemoteFileError extends Error {} export function isRemoteSource(pdfUrl: string): boolean { return isHttpUrl(pdfUrl); } export async function fetchSourceBytes(url: string): Promise { const checked = validateExternalFileUrl(url); if (!checked.ok) throw new RemoteFileError("ลิงก์ไฟล์รีโมตไม่ผ่านการตรวจสอบ"); let res: Response; try { res = await fetch(checked.url, { redirect: "follow", signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); } catch { throw new RemoteFileError("ดึงไฟล์จากต้นทางไม่สำเร็จ (เครือข่ายล่มหรือหมดเวลา)"); } if (!res.ok) { throw new RemoteFileError(`ดึงไฟล์จากต้นทางไม่สำเร็จ (HTTP ${res.status})`); } // hardening (plan-reviewer): re-validate หลัง redirect ด้วยชุดเต็ม ไม่ใช่แค่ hostname // — กัน host ใน allowlist เด้งไป http:// หรือ host อื่นกลาง redirect chain const afterRedirect = validateExternalFileUrl(res.url); if (!afterRedirect.ok) { throw new RemoteFileError("ลิงก์ถูกเปลี่ยนทางไปปลายทางที่ไม่อนุญาต"); } const declared = Number(res.headers.get("content-length")); if (Number.isFinite(declared) && declared > MAX_REMOTE_BYTES) { throw new RemoteFileError("ไฟล์ใหญ่เกิน 100MB"); } // hardening (plan-reviewer): ไม่มี body (เช่น HTTP 204) = ไฟล์เปล่า — ห้ามปล่อย // Buffer ว่างไหลไป pipeline กลาง ๆ if (!res.body) throw new RemoteFileError("ไฟล์ว่าง (0 ไบต์)"); const reader = res.body.getReader(); const chunks: Buffer[] = []; let total = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; total += value.byteLength; if (total > MAX_REMOTE_BYTES) { await reader.cancel().catch(() => undefined); throw new RemoteFileError("ไฟล์ใหญ่เกิน 100MB"); } chunks.push(Buffer.from(value)); } if (total === 0) throw new RemoteFileError("ไฟล์ว่าง (0 ไบต์)"); return Buffer.concat(chunks); }