diff --git a/src/utils/helper.js b/src/utils/helper.js index d0dfaef9e..462c5ce88 100644 --- a/src/utils/helper.js +++ b/src/utils/helper.js @@ -182,8 +182,31 @@ const uploadFile = ({ }) } +// returns the file name from a Content-Disposition header, preferring the +// RFC 5987 encoded form (filename*=UTF-8''...), or undefined if there is none +const getFilenameFromContentDisposition = (header) => { + if (!header) { + return undefined + } + const encoded = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(header) + if (encoded) { + try { + return decodeURIComponent(encoded[1].trim()) + } catch (e) { + // malformed encoding, fall back to the plain file name + } + } + const plain = /filename\s*=\s*"([^"]+)"|filename\s*=\s*([^;]+)/i.exec( + header + ) + if (plain) { + return (plain[1] || plain[2]).trim() + } + return undefined +} + // call stub function if available -const locationAssign = (url, blob) => { +const locationAssign = (url, blob, filename) => { if (window.locationAssign) { window.locationAssign(url) } else { @@ -193,13 +216,15 @@ const locationAssign = (url, blob) => { : url const urlFilePart = new URL(downloadUrl).pathname.split('/').pop() - const [filename] = urlFilePart.match(/(^[^.]+)(\..+$)/) + const [urlFilename] = /(^[^.]+)(\..+$)/.exec(urlFilePart) const objectUrl = blob ? URL.createObjectURL(blob) : undefined const link = document.createElement('a') link.href = objectUrl || downloadUrl - link.download = filename + // a blob URL carries no Content-Disposition, so the server's file + // name has to be passed on explicitly + link.download = filename || urlFilename link.target = '_blank' link.click() @@ -244,7 +269,10 @@ const fetchAndDownload = async (url, source) => { } const blob = await response.blob() - locationAssign(url, blob) + const filename = getFilenameFromContentDisposition( + response.headers?.get('Content-Disposition') + ) + locationAssign(url, blob, filename) return undefined } catch (e) { console.error(`${source}-export: request failed`, e) @@ -283,6 +311,7 @@ const formatNumber = (value, locale) => { export { fetchAttributes, fetchAndDownload, + getFilenameFromContentDisposition, getPrevJobDetails, getInitialBoolValue, formatNumber, diff --git a/src/utils/helper.test.js b/src/utils/helper.test.js index 9f9e24d73..84d34706a 100644 --- a/src/utils/helper.test.js +++ b/src/utils/helper.test.js @@ -1,5 +1,10 @@ import { FORM_ERROR } from './final-form.js' -import { fetchAndDownload, formatNumber, locationAssign } from './helper.js' +import { + fetchAndDownload, + formatNumber, + getFilenameFromContentDisposition, + locationAssign, +} from './helper.js' describe('formatNumber', () => { it('adds digit group separators to numbers', () => { @@ -61,6 +66,12 @@ describe('locationAssign', () => { const link = locationAssign(url) expect(link.download).toEqual('events.json.gz') }) + it('should use the given file name instead of the one from the url', () => { + const url = + 'https://debug.dhis2.org/dev/api/dataValueSets.json?dataSet=pBOMPrpg1QX&compression=zip' + const link = locationAssign(url, undefined, 'dataValues.json.zip') + expect(link.download).toEqual('dataValues.json.zip') + }) it('should work with relative URLs when bundled in DHIS2', () => { Object.defineProperty(global.document, 'baseURI', { value: 'http://localhost:8080/dhis-web-import-export/index.html#/export/tei', @@ -81,6 +92,41 @@ describe('locationAssign', () => { }) }) +describe('getFilenameFromContentDisposition', () => { + it('reads a quoted file name', () => { + expect( + getFilenameFromContentDisposition( + 'attachment; filename="dataValues_2026-07-02_2026-10-02.json.zip"' + ) + ).toEqual('dataValues_2026-07-02_2026-10-02.json.zip') + }) + it('reads an unquoted file name', () => { + expect( + getFilenameFromContentDisposition( + 'attachment; filename=metadata.json.gz' + ) + ).toEqual('metadata.json.gz') + }) + it('prefers the encoded file name', () => { + expect( + getFilenameFromContentDisposition( + 'attachment; filename="export.json"; filename*=UTF-8\'\'donn%C3%A9es.json.zip' + ) + ).toEqual('données.json.zip') + }) + it('falls back to the plain file name when the encoded one is malformed', () => { + expect( + getFilenameFromContentDisposition( + 'attachment; filename*=UTF-8\'\'bad%E0%A4%A; filename="export.json.zip"' + ) + ).toEqual('export.json.zip') + }) + it('returns undefined without a file name', () => { + expect(getFilenameFromContentDisposition(null)).toBeUndefined() + expect(getFilenameFromContentDisposition('inline')).toBeUndefined() + }) +}) + describe('fetchAndDownload', () => { const url = 'https://debug.dhis2.org/dev/api/metadata.json' @@ -103,6 +149,43 @@ describe('fetchAndDownload', () => { expect(result).toBeUndefined() }) + it('saves the download under the file name sent by the server', async () => { + const downloads = [] + const clickSpy = jest + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(function () { + downloads.push(this.download) + }) + const createObjectURL = URL.createObjectURL + const revokeObjectURL = URL.revokeObjectURL + URL.createObjectURL = jest.fn(() => 'blob:export') + URL.revokeObjectURL = jest.fn() + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + headers: { + get: (name) => + name.toLowerCase() === 'content-disposition' + ? 'attachment; filename="dataValues.json.zip"' + : null, + }, + blob: () => Promise.resolve(new Blob(['PK'])), + }) + + try { + const result = await fetchAndDownload( + 'https://debug.dhis2.org/dev/api/dataValueSets.json?compression=zip', + 'data' + ) + + expect(result).toBeUndefined() + expect(downloads).toEqual(['dataValues.json.zip']) + } finally { + clickSpy.mockRestore() + URL.createObjectURL = createObjectURL + URL.revokeObjectURL = revokeObjectURL + } + }) + it('returns a form error with the server message when the response is not ok', async () => { global.fetch = jest.fn().mockResolvedValue({ ok: false,