From f678ba42ae5b359291bf7b4e2818ffb101334225 Mon Sep 17 00:00:00 2001 From: James Barlow <332269+manwithacat@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:56:54 +0100 Subject: [PATCH] fix: prefer RFC 5987 filename* in hx-download parseFilename() matched the first filename= token, so a UTF-8 filename* was ignored when an ASCII filename= fallback was also present. Closes #4020 --- src/ext/hx-download.js | 10 ++++- test/tests/ext/hx-download.js | 46 ++++++++++++++++++++ www/src/content/extensions/15-hx-download.md | 2 + 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/ext/hx-download.js b/src/ext/hx-download.js index 550494515..6437187f1 100644 --- a/src/ext/hx-download.js +++ b/src/ext/hx-download.js @@ -73,8 +73,14 @@ function parseFilename(headers, url) { let cd = headers.get('Content-Disposition'); if (cd) { - let match = cd.match(/filename\*?=['"]?(?:UTF-8'')?([^'";]+)/i); - if (match) return decodeURIComponent(match[1]); + // RFC 6266 / 5987: filename*=UTF-8''... takes precedence over filename= + let star = cd.match(/filename\*\s*=\s*(?:UTF-8'')?([^;]+)/i); + if (star) { + let raw = star[1].trim().replace(/^["']|["']$/g, ''); + try { return decodeURIComponent(raw); } catch { /* fall through */ } + } + let ascii = cd.match(/filename\s*=\s*(?:UTF-8'')?(?:"([^"]+)"|([^;]+))/i); + if (ascii) return (ascii[1] || ascii[2]).trim(); } return url.split('/').pop().split('?')[0] || 'download'; } diff --git a/test/tests/ext/hx-download.js b/test/tests/ext/hx-download.js index dc7b4acd0..2ee2d5cb0 100644 --- a/test/tests/ext/hx-download.js +++ b/test/tests/ext/hx-download.js @@ -60,4 +60,50 @@ describe('hx-download extension', function() { assert.equal(filename, 'report.txt') assert.equal(button.innerText, 'Download') }) + + it('prefers RFC 5987 filename* over ASCII filename=', async function() { + let filename + URL.createObjectURL = () => 'blob:test' + URL.revokeObjectURL = () => {} + HTMLAnchorElement.prototype.click = function() { + filename = this.download + } + + fetchMock.mockResponse('GET', '/report.xlsx', new Response('xlsx', { + headers: { + 'Content-Disposition': 'attachment; filename="R_conciliation Inv priv_s.xlsx"; filename*=UTF-8\'\'R%C3%A9conciliation%20Inv%20priv%C3%A9s.xlsx', + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + } + })) + let button = createProcessedHTML('') + let complete = waitForEvent('htmx:download:complete') + + button.click() + await complete + + assert.equal(filename, 'Réconciliation Inv privés.xlsx') + }) + + it('uses filename* when it is the only parameter', async function() { + let filename + URL.createObjectURL = () => 'blob:test' + URL.revokeObjectURL = () => {} + HTMLAnchorElement.prototype.click = function() { + filename = this.download + } + + fetchMock.mockResponse('GET', '/cafe.txt', new Response('cafe', { + headers: { + 'Content-Disposition': "attachment; filename*=UTF-8''caf%C3%A9.txt", + 'Content-Type': 'text/plain' + } + })) + let button = createProcessedHTML('') + let complete = waitForEvent('htmx:download:complete') + + button.click() + await complete + + assert.equal(filename, 'café.txt') + }) }) diff --git a/www/src/content/extensions/15-hx-download.md b/www/src/content/extensions/15-hx-download.md index e58a26d4a..e1b79788a 100644 --- a/www/src/content/extensions/15-hx-download.md +++ b/www/src/content/extensions/15-hx-download.md @@ -47,6 +47,8 @@ If the server returns `Content-Disposition: attachment`, the extension triggers Content-Disposition: attachment; filename="report.pdf" ``` +If both `filename=` and `filename*` are present, the RFC 5987 `filename*` value is used (so a UTF-8 name is not overwritten by the ASCII fallback). + ### HX-Download header When the backend cannot stream the file directly as the htmx response (e.g. it needs to redirect to a separate download endpoint), return an `HX-Download` header pointing to the file URL. The extension will fetch that URL as the download while htmx processes the original response body as a normal swap: