Skip to content

Commit 18dc3af

Browse files
fix(download): use server-provided filename for cross-origin downloads (#417)
When the Download plugin fetched a cross-origin file over CORS, the `Content-Disposition` response header was discarded, and the file was saved under a generated blob name unless an explicit filename was configured. The filename from the `Content-Disposition` header (including the RFC 5987 `filename*` form) is now used as a fallback when no explicit filename is provided. The header must be exposed through `Access-Control-Expose-Headers`.
1 parent c61a245 commit 18dc3af

2 files changed

Lines changed: 57 additions & 1 deletion

File tree

src/plugins/download/FileSaver.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,40 @@
33
* https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js
44
*/
55

6+
export function parseContentDispositionFilename(disposition: string | null) {
7+
if (!disposition) {
8+
return undefined;
9+
}
10+
11+
// RFC 5987 `filename*` takes precedence over `filename` (RFC 6266)
12+
const extended = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(disposition);
13+
if (extended) {
14+
try {
15+
const decoded = decodeURIComponent(extended[1].trim());
16+
if (decoded) {
17+
return decoded;
18+
}
19+
} catch (_) {
20+
//
21+
}
22+
}
23+
24+
const plain = /filename\s*=\s*("[^"]*"|[^;]+)/i.exec(disposition);
25+
return plain?.[1].trim().replace(/^"(.*)"$/, "$1") || undefined;
26+
}
27+
28+
function getContentDisposition(xhr: XMLHttpRequest) {
29+
// unlike `getResponseHeader()`, `getAllResponseHeaders()` doesn't produce console errors
30+
// when the header is not exposed through `Access-Control-Expose-Headers`
31+
return /^content-disposition:(.*)$/im.exec(xhr.getAllResponseHeaders())?.[1] ?? null;
32+
}
33+
634
function download(url: string, name?: string) {
735
const xhr = new XMLHttpRequest();
836
xhr.open("GET", url);
937
xhr.responseType = "blob";
1038
xhr.onload = () => {
11-
saveAs(xhr.response, name);
39+
saveAs(xhr.response, name || parseContentDispositionFilename(getContentDisposition(xhr)));
1240
};
1341
xhr.onerror = () => {
1442
// eslint-disable-next-line no-console

test/unit/plugins/Download.spec.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { vi } from "vitest";
44
import { clickButton, lightbox, expectLightboxToBeOpen } from "../test-utils.js";
55
import { Download } from "../../../src/plugins/index.js";
66
import { LightboxExternalProps } from "../../../src/index.js";
7+
import { parseContentDispositionFilename } from "../../../src/plugins/download/FileSaver.js";
78

89
function renderLightbox(props?: LightboxExternalProps) {
910
return render(lightbox({ plugins: [Download], ...props }));
@@ -45,4 +46,31 @@ describe("Download", () => {
4546

4647
expect(download).toHaveBeenCalled();
4748
});
49+
50+
it("parses Content-Disposition filename", () => {
51+
expect(parseContentDispositionFilename(null)).toBeUndefined();
52+
expect(parseContentDispositionFilename("")).toBeUndefined();
53+
expect(parseContentDispositionFilename("inline")).toBeUndefined();
54+
expect(parseContentDispositionFilename('inline; filename=""')).toBeUndefined();
55+
56+
expect(parseContentDispositionFilename('inline; filename="photo.jpg"')).toBe("photo.jpg");
57+
expect(parseContentDispositionFilename("attachment; filename=photo.jpg")).toBe("photo.jpg");
58+
expect(parseContentDispositionFilename("attachment; filename = photo.jpg ; size=1024")).toBe("photo.jpg");
59+
60+
// RFC 5987 encoded filename takes precedence
61+
expect(parseContentDispositionFilename("attachment; filename*=UTF-8''na%C3%AFve.jpg")).toBe("naïve.jpg");
62+
expect(
63+
parseContentDispositionFilename("attachment; filename=\"fallback.jpg\"; filename*=UTF-8''f%C3%B8t%C3%B8.jpg"),
64+
).toBe("føtø.jpg");
65+
66+
// malformed percent-encoding falls back to the plain filename
67+
expect(parseContentDispositionFilename("attachment; filename*=UTF-8''%E0%A4%A; filename=\"fallback.jpg\"")).toBe(
68+
"fallback.jpg",
69+
);
70+
71+
// empty encoded filename falls back to the plain filename
72+
expect(parseContentDispositionFilename("attachment; filename*=UTF-8'' ; filename=\"fallback.jpg\"")).toBe(
73+
"fallback.jpg",
74+
);
75+
});
4876
});

0 commit comments

Comments
 (0)