From 26478159bee598e618b5581cbaf31ee278a51872 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 1 Jul 2026 18:21:41 -0400 Subject: [PATCH] feat(web): generalize downloadFile.ts with downloadBlob, fileNameFromUri, isHttpUrl (closes #1560) Extend the browser download library beyond JSON so the Apps host's ui/download-file support can download arbitrary embedded resources and open http(s) links safely, with no UI wiring in this change: - downloadBlob(): temp-anchor download core with a setTimeout(...,0)- deferred revokeObjectURL so a synchronous revoke can't abort the scheduled download (Firefox/Safari, intermittently Chrome). - downloadJsonFile(): refactored on top of downloadBlob; callers unchanged. - fileNameFromUri(): last path segment sanitized (control/format chars stripped, disallowed filename chars -> _, capped at 255, "download" fallback). - isHttpUrl(): parse-and-allowlist http(s) only, else null. Re-implementation of the downloadFile.ts slice of PR #1510 as Wave 1 of the #1579 decomposition. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX --- clients/web/src/lib/downloadFile.ts | 55 +++++++++++++--- clients/web/src/test/lib/downloadFile.test.ts | 66 +++++++++++++++++-- 2 files changed, 108 insertions(+), 13 deletions(-) diff --git a/clients/web/src/lib/downloadFile.ts b/clients/web/src/lib/downloadFile.ts index 77590717..4ce5057f 100644 --- a/clients/web/src/lib/downloadFile.ts +++ b/clients/web/src/lib/downloadFile.ts @@ -2,22 +2,23 @@ * Browser-side helpers for triggering file downloads from in-memory content. * * Centralized so the temp-anchor incantation (`appendChild` for Firefox, - * try/finally to keep cleanup bulletproof, `revokeObjectURL` to release the - * object URL) lives in one place — and so the wiring is unit-testable - * under happy-dom without dragging React along. + * deferred `revokeObjectURL` so the scheduled download can read the blob) + * lives in one place — and so the wiring is unit-testable under happy-dom + * without dragging React along. */ /** - * Download an in-memory string as a JSON file. Uses a temporary anchor + * Download an in-memory {@link Blob} as `filename`. Uses a temporary anchor * element to trigger the browser's save dialog. The append-to-body step is * for older Firefox versions that wouldn't fire `click()` on a detached * anchor; modern browsers don't require it but it stays as the safe path. * - * The cleanup (removeChild + revokeObjectURL) runs in a `finally` so even - * a thrown `click()` doesn't leak the DOM node or the object URL. + * The object-URL revoke is deferred to a task: `link.click()` only schedules + * the download, and revoking the URL synchronously can abort it before the + * browser reads the blob (Firefox/Safari, intermittently Chrome for larger + * blobs). */ -export function downloadJsonFile(filename: string, json: string): void { - const blob = new Blob([json], { type: "application/json" }); +export function downloadBlob(filename: string, blob: Blob): void { const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; @@ -27,7 +28,43 @@ export function downloadJsonFile(filename: string, json: string): void { anchor.click(); } finally { document.body.removeChild(anchor); - URL.revokeObjectURL(url); + setTimeout(() => URL.revokeObjectURL(url), 0); + } +} + +/** Download an in-memory JSON string as `filename`. */ +export function downloadJsonFile(filename: string, json: string): void { + downloadBlob(filename, new Blob([json], { type: "application/json" })); +} + +/** + * Derive a safe suggested filename from a resource URI: the last path segment, + * stripped of control/format characters, path separators, and characters + * disallowed in filenames on common platforms. Falls back to `"download"` when + * nothing usable remains. + */ +export function fileNameFromUri(uri: string): string { + const tail = uri.split(/[\\/]/).pop() ?? ""; + const safe = tail + .replace(/[\p{Cc}\p{Cf}]+/gu, "") + .replace(/[\\/:*?"<>|]+/g, "_") + .trim(); + return safe.length > 0 ? safe.slice(0, 255) : "download"; +} + +/** + * Parse `url` and return it only if its scheme is `http:` or `https:`; + * otherwise null. Shared http(s)-only allowlist for opening or downloading + * server-supplied URLs. + */ +export function isHttpUrl(url: string): URL | null { + try { + const parsed = new URL(url); + return parsed.protocol === "https:" || parsed.protocol === "http:" + ? parsed + : null; + } catch { + return null; } } diff --git a/clients/web/src/test/lib/downloadFile.test.ts b/clients/web/src/test/lib/downloadFile.test.ts index de1ae994..36cc656d 100644 --- a/clients/web/src/test/lib/downloadFile.test.ts +++ b/clients/web/src/test/lib/downloadFile.test.ts @@ -1,19 +1,26 @@ /** - * Tests for the downloadJsonFile helper. Mocks URL.createObjectURL + + * Tests for the download helpers. Mocks URL.createObjectURL + * URL.revokeObjectURL (happy-dom doesn't ship them) and asserts the temp - * anchor's attributes + click + cleanup sequence. + * anchor's attributes + click + deferred cleanup sequence. */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { downloadJsonFile, buildExportFilename } from "../../lib/downloadFile"; +import { + downloadBlob, + downloadJsonFile, + buildExportFilename, + fileNameFromUri, + isHttpUrl, +} from "../../lib/downloadFile"; -describe("downloadJsonFile", () => { +describe("downloadBlob / downloadJsonFile", () => { const originalCreate = URL.createObjectURL; const originalRevoke = URL.revokeObjectURL; let createMock: ReturnType; let revokeMock: ReturnType; beforeEach(() => { + vi.useFakeTimers(); createMock = vi.fn().mockReturnValue("blob:mock-url"); revokeMock = vi.fn(); // happy-dom doesn't implement URL.createObjectURL/revokeObjectURL. @@ -22,6 +29,7 @@ describe("downloadJsonFile", () => { }); afterEach(() => { + vi.useRealTimers(); URL.createObjectURL = originalCreate; URL.revokeObjectURL = originalRevoke; vi.restoreAllMocks(); @@ -54,6 +62,9 @@ describe("downloadJsonFile", () => { // After the call, the anchor must be removed and the URL revoked. expect(document.body.contains(anchor)).toBe(false); + // Revoke is deferred so the scheduled download can read the blob first. + expect(revokeMock).not.toHaveBeenCalled(); + vi.runAllTimers(); expect(revokeMock).toHaveBeenCalledWith("blob:mock-url"); }); @@ -82,6 +93,7 @@ describe("downloadJsonFile", () => { // Cleanup still ran despite the throw. expect(removed).toHaveLength(1); + vi.runAllTimers(); expect(revokeMock).toHaveBeenCalledWith("blob:mock-url"); }); @@ -91,6 +103,15 @@ describe("downloadJsonFile", () => { const text = await blob.text(); expect(text).toBe('{"x":1}'); }); + + it("downloadBlob passes the given blob through and honours its type", async () => { + const blob = new Blob(["plain text"], { type: "text/plain" }); + downloadBlob("note.txt", blob); + expect(createMock).toHaveBeenCalledWith(blob); + const passed = createMock.mock.calls[0]?.[0] as Blob; + expect(passed.type).toBe("text/plain"); + expect(await passed.text()).toBe("plain text"); + }); }); describe("buildExportFilename", () => { @@ -127,3 +148,40 @@ describe("buildExportFilename", () => { ); }); }); + +describe("fileNameFromUri", () => { + it("returns the last path segment", () => { + expect(fileNameFromUri("https://x/y/z/report.pdf")).toBe("report.pdf"); + }); + it("splits on backslashes as well as forward slashes", () => { + expect(fileNameFromUri("C:\\Users\\me\\report.pdf")).toBe("report.pdf"); + }); + it("strips control/format chars and disallowed filename chars", () => { + // U+200B (zero-width space, Cf) and U+202E (RTL override, Cf) are + // stripped; the disallowed `*?` run collapses to a single `_`. + const uri = "https://x/a\u200bb\u202ec*?.txt"; + expect(fileNameFromUri(uri)).toBe("abc_.txt"); + }); + it("truncates very long names to 255 characters", () => { + const long = "a".repeat(300) + ".txt"; + expect(fileNameFromUri(`https://x/${long}`)).toHaveLength(255); + }); + it("falls back to 'download' when nothing usable remains", () => { + expect(fileNameFromUri("https://x/")).toBe("download"); + }); +}); + +describe("isHttpUrl", () => { + it("accepts http and https", () => { + expect(isHttpUrl("https://example.com")?.href).toBe("https://example.com/"); + expect(isHttpUrl("http://localhost:3000/a")?.href).toBe( + "http://localhost:3000/a", + ); + }); + it("rejects other schemes and unparsable input", () => { + expect(isHttpUrl("javascript:alert(1)")).toBeNull(); + expect(isHttpUrl("data:text/html,")).toBeNull(); + expect(isHttpUrl("file:///etc/passwd")).toBeNull(); + expect(isHttpUrl("not a url")).toBeNull(); + }); +});