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: