| 8 | |
| 9 | // fallow-ignore-next-line complexity |
| 10 | async function streamToFile(url: string, destPath: string): Promise<number> { |
| 11 | // safeFetch re-validates redirect hops; bare redirect:"follow" leaks to private hosts. |
| 12 | const r = await safeFetch(url, { |
| 13 | signal: AbortSignal.timeout(120_000), |
| 14 | headers: { "User-Agent": "HyperFrames/1.0" }, |
| 15 | }); |
| 16 | if (!r) { |
| 17 | throw new Error( |
| 18 | `fetch blocked or failed (private/metadata host, redirect chain, or network error): ${url}`, |
| 19 | ); |
| 20 | } |
| 21 | if (!r.ok) throw new Error(`HTTP ${r.status} ${r.statusText} for ${url}`); |
| 22 | const ct = r.headers.get("content-type") || ""; |
| 23 | if (!VIDEO_CONTENT_TYPE_RE.test(ct)) { |
| 24 | throw new Error( |
| 25 | `unexpected content-type "${ct}" for ${url} — expected video/*. The URL probably doesn't point at a real video file.`, |
| 26 | ); |
| 27 | } |
| 28 | const cl = r.headers.get("content-length"); |
| 29 | if (cl && Number(cl) > MAX_VIDEO_BYTES) { |
| 30 | throw new Error( |
| 31 | `video too large (${Math.round(Number(cl) / 1024 / 1024)}MB > ${Math.round(MAX_VIDEO_BYTES / 1024 / 1024)}MB cap) for ${url}`, |
| 32 | ); |
| 33 | } |
| 34 | if (!r.body) throw new Error(`empty response body for ${url}`); |
| 35 | |
| 36 | // `flags: "wx"` = exclusive-create; throws EEXIST if destPath exists. Stream chunks |
| 37 | // and abort mid-transfer if cumulative bytes exceed the cap so a hostile CDN can't |
| 38 | // OOM the process by lying about content-length. |
| 39 | const file = createWriteStream(destPath, { flags: "wx" }); |
| 40 | // Single shared error promise: avoids re-attaching `error` listeners per chunk (MaxListeners warning). |
| 41 | let streamError: Error | null = null; |
| 42 | const streamErrored = new Promise<never>((_, reject) => { |
| 43 | file.once("error", (e) => { |
| 44 | streamError = e; |
| 45 | reject(e); |
| 46 | }); |
| 47 | }); |
| 48 | let bytes = 0; |
| 49 | try { |
| 50 | await Promise.race([ |
| 51 | streamErrored, |
| 52 | new Promise<void>((resolveOpen) => file.once("open", () => resolveOpen())), |
| 53 | ]); |
| 54 | for await (const chunk of r.body as unknown as AsyncIterable<Uint8Array>) { |
| 55 | if (streamError) throw streamError; |
| 56 | bytes += chunk.byteLength; |
| 57 | if (bytes > MAX_VIDEO_BYTES) { |
| 58 | throw new Error( |
| 59 | `video exceeded ${Math.round(MAX_VIDEO_BYTES / 1024 / 1024)}MB cap mid-stream for ${url}`, |
| 60 | ); |
| 61 | } |
| 62 | // lgtm[js/http-to-file-access] — manifest-vetted URL, content-type whitelist, 250MB cap with mid-stream abort, SSRF-safe fetch |
| 63 | if (!file.write(chunk)) { |
| 64 | await Promise.race([ |
| 65 | streamErrored, |
| 66 | new Promise<void>((resolveDrain) => file.once("drain", () => resolveDrain())), |
| 67 | ]); |