Skip to content

Keep attachment filenames and avoid decoding downloaded bodies - #1668

Open
fly1d wants to merge 4 commits into
PaloAltoNetworks:mainfrom
fly1d:fix/download-filenames
Open

fly1d wants to merge 4 commits into
PaloAltoNetworks:mainfrom
fly1d:fix/download-filenames

Conversation

@fly1d

@fly1d fly1d commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #733.

Use the browser-visible Content-Disposition filename for successful attachment downloads, including lenient quoted/plain names, extended encodings and versioned basenames. Keep path/control-character protection and MIME-based defaults. The filename parser adds no dependency or Node polyfill.

Consume a downloaded body once as a Blob and display a short placeholder instead of decoding binary bytes into the response pane/Redux. Error responses stay visible. Only readable text/event-stream responses use the incremental reader; Transfer-Encoding: chunked does not identify a stream, so chunked PDF responses still download. Keep object URLs alive for 1 second after the click. Cross-origin filename headers must still be exposed by the API.

Include the main-branch changes through #1677. With the same final 79-case Request fixture, the previous stream guard fails the two chunked-PDF cases and passes 77 controls; the fixed guard passes all 79, including genuine SSE cases. Full yarn test passes 386 tests across 17 suites, with 35 snapshots on Node 26.5.0/macOS. All three packages build without cache; strict changed-file ESLint, Prettier and diff checks pass.

Required full yarn lint still exits 2 at the existing Cypress cypress/globals configuration error. Tests use native Response bodies and Jest DOM, with fetch delivery, object URLs and clicks substituted; no browser/CORS or hosted CI matrix was run.

@sserrata sserrata left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. Using the server filename is useful, but several common cases are still not covered. Please address the inline items.

Other items (outside the diff):

  • packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/Request/makeRequest.ts: A download starts only for 11 hard-coded types. attachment with text/csv or .xlsx does not download, and the binary goes into the text pane. Start a download when the disposition type is attachment.
  • packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/Request/makeRequest.ts: Existing bug, but this feature makes it visible: the body is read with blob(), then handleResponse calls res.text() on the same Response. That throws, and the explorer shows "Connection failed" after a successful download.
  • packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/Request/makeRequest.ts: The object URL is never revoked. Call URL.revokeObjectURL(url) in the setTimeout.

link.setAttribute(
"download",
getDownloadFilename(
response.headers.get("content-disposition"),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Content-Disposition is not a CORS-safelisted header. For cross-origin APIs, headers.get returns null unless the server sends Access-Control-Expose-Headers: Content-Disposition, so most explorer users do not get the fix. Please document this requirement.

link.href = url;
// Now the file name includes the extension
link.setAttribute("download", `file${fileExtension}`);
link.setAttribute(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the server filename has no extension (filename=report), the .pdf from Content-Type is lost. Add fileExtension when the parsed name has no extension.

): string {
if (!header) return fallback;
try {
const filename = parse(header).parameters.filename;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parse() throws for the full header when one parameter is bad. Then a valid filename=report.pdf is discarded when filename* is malformed. RFC 6266 says to fall back to filename.

try {
const filename = parse(header).parameters.filename;
// eslint-disable-next-line no-control-regex
if (!filename || /[\u0000-\u001f\u007f]/.test(filename)) return fallback;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only C0 control characters and DEL are removed. Bidi overrides (U+202E, U+2066–2069) get through, so invoice\u202Efdp.exe can show as invoiceexe.pdf. Also remove \p{Cf}.

"allof-merge": "^0.6.6",
"buffer": "^6.0.3",
"clsx": "^2.1.1",
"content-disposition": "^0.5.4",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

content-disposition@0.5.4 needs safe-buffer and path, so the client bundle gets polyfills to parse one header. Use a small browser-native parser, or 1.x.

@fly1d fly1d changed the title fix: use response filenames for binary downloads fix(theme): handle response attachment downloads Oct 28, 2026

@sserrata sserrata left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the update. Attachments of any type now download, and the object URL is revoked. Some new problems must be fixed before merge:

const extension =
fileExtension || attachmentExtensions[contentType ?? ""] || "";
// Keep the original response readable by the explorer's response handler.
const blob = await response.clone().blob();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regression: with response.clone(), handleResponse now calls res.text() on every binary download and puts the decoded bytes in Redux and the response pane. A 200 MB video or ZIP is stored two times and decoded as text, which can freeze the tab. Return a short placeholder for downloaded bodies, and do not decode them. Also, blob() never resolves for a streaming response (text/event-stream or a chunked stream) that has attachment, so the explorer stays on "Fetching...". Exclude streams.

};
const fileExtension = binaryExtensions[contentType ?? ""] ?? "";

if (fileExtension || attachment) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Responses that are not OK (401, 500) that have attachment are also downloaded, so an error body can look like a successful download. Check response.ok first.

} else {
return response;
}
setTimeout(() => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Revoking at 0 ms can cancel the download in Firefox and Safari, mostly for large files. Wait longer (for example, about 1 s).

while (index < header.length) {
parameter.lastIndex = index;
const match = parameter.exec(header);
if (!match) return fallback;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A trailing ; (filename=report.pdf;) or a token that is not strictly valid (report (1).pdf, résumé.pdf) makes the full parse fail, and the file is named file.pdf. Browsers accept these headers. Parse leniently.


link.click();
const contentType = response.headers
.get("content-type")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exact essence match does not match a duplicated header that a proxy merged (application/pdf, application/pdf), but includes matched it before. Use the first item of the list.

.pop()
?.trim();
if (!basename || basename === "." || basename === "..") return fallback;
return /\.[^.]+$/.test(basename) ? basename : basename + extension;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/\.[^.]+$/ treats report.v2 or export.2024-10 as having an extension, so the Content-Type extension is not added.

])
);
type TestDom = { window: Window & typeof globalThis };
const { JSDOM } = jest.requireActual("jsdom") as {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jsdom is not a declared dependency. It comes only from jest-environment-jsdom@27. Use the test environment's document, or add the dependency.

.toLowerCase();
const disposition = response.headers.get("content-disposition");
const attachment = isAttachment(disposition);
const binaryExtensions: Record<string, string> = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please put the MIME-to-extension maps in one map at module level. Also, please revert the formatting changes that are not related to the fix (RequestErrorType, the quotes in demo/docs/advanced.mdx).

@fly1d fly1d changed the title fix(theme): handle response attachment downloads Keep attachment filenames and avoid decoding downloaded bodies Oct 29, 2026

@sserrata sserrata left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. All items from the last review are fixed. One small item remains before merge.

return (
response.body !== null &&
(getContentType(response) === "text/event-stream" ||
(response.headers

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possible regression: any response with a readable Transfer-Encoding: chunked header is treated as a stream. A chunked binary response (for example, a generated PDF over HTTP/1.1 through the proxy, or same-origin) then goes to handleEventStream, which decodes it into the response pane. On main, it downloads. Please detect streams by text/event-stream only, or check chunked only when the response is not a download.

@sserrata

Copy link
Copy Markdown
Member

This PR now conflicts with main after #1677 merged (jest.config.js). Please rebase onto main when you address the open review item. Thanks.

This branch is waiting to be deployed

1 waiting deployment
default — 2e390d89 Waiting Oct 31, 2026 by fly1d via Analyze Unsafe (javascript) #2447
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

File download always with same name "file.<extension>"

2 participants