From f52ebc7bf014eebd07bee00b7ef35f3829a102f6 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sat, 19 Apr 2025 15:10:34 -0700 Subject: [PATCH] Fix: preserve non-ASCII filenames in document downloads (#9702) --- .../document-detail.component.ts | 11 +++---- src-ui/src/app/utils/http.spec.ts | 31 +++++++++++++++++++ src-ui/src/app/utils/http.ts | 23 ++++++++++++++ 3 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 src-ui/src/app/utils/http.spec.ts create mode 100644 src-ui/src/app/utils/http.ts diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts index d9d78206f..632a2de30 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.ts @@ -77,6 +77,7 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { UserService } from 'src/app/services/rest/user.service' import { SettingsService } from 'src/app/services/settings.service' import { ToastService } from 'src/app/services/toast.service' +import { getFilenameFromContentDisposition } from 'src/app/utils/http' import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter' import * as UTIF from 'utif' import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component' @@ -999,12 +1000,10 @@ export class DocumentDetailComponent .get(downloadUrl, { observe: 'response', responseType: 'blob' }) .subscribe({ next: (response: HttpResponse) => { - const filename = response.headers - .get('Content-Disposition') - ?.split(';') - ?.find((part) => part.trim().startsWith('filename=')) - ?.split('=')[1] - ?.replace(/['"]/g, '') + const contentDisposition = response.headers.get('Content-Disposition') + const filename = + getFilenameFromContentDisposition(contentDisposition) || + this.document.title const blob = new Blob([response.body], { type: response.body.type, }) diff --git a/src-ui/src/app/utils/http.spec.ts b/src-ui/src/app/utils/http.spec.ts new file mode 100644 index 000000000..ab3421ded --- /dev/null +++ b/src-ui/src/app/utils/http.spec.ts @@ -0,0 +1,31 @@ +import { getFilenameFromContentDisposition } from './http' + +describe('getFilenameFromContentDisposition', () => { + it('should extract filename from Content-Disposition header with filename*', () => { + const header = "attachment; filename*=UTF-8''example%20file.txt" + expect(getFilenameFromContentDisposition(header)).toBe('example file.txt') + }) + + it('should extract filename from Content-Disposition header with filename=', () => { + const header = 'attachment; filename="example-file.txt"' + expect(getFilenameFromContentDisposition(header)).toBe('example-file.txt') + }) + + it('should prioritize filename* over filename if both are present', () => { + const header = + 'attachment; filename="fallback.txt"; filename*=UTF-8\'\'preferred%20file.txt' + const result = getFilenameFromContentDisposition(header) + expect(result).toBe('preferred file.txt') + }) + + it('should gracefully fall back to null', () => { + // invalid UTF-8 sequence + expect( + getFilenameFromContentDisposition("attachment; filename*=UTF-8''%E0%A4%A") + ).toBeNull() + // missing filename + expect(getFilenameFromContentDisposition('attachment;')).toBeNull() + // empty header + expect(getFilenameFromContentDisposition(null)).toBeNull() + }) +}) diff --git a/src-ui/src/app/utils/http.ts b/src-ui/src/app/utils/http.ts new file mode 100644 index 000000000..96af8d702 --- /dev/null +++ b/src-ui/src/app/utils/http.ts @@ -0,0 +1,23 @@ +export function getFilenameFromContentDisposition(header: string): string { + if (!header) { + return null + } + + // Try filename* (RFC 5987) + const filenameStar = header.match(/filename\*=(?:UTF-\d['']*)?([^;]+)/i) + if (filenameStar?.[1]) { + try { + return decodeURIComponent(filenameStar[1]) + } catch (e) { + // Ignore decoding errors and fall through + } + } + + // Fallback to filename= + const filenameMatch = header.match(/filename="?([^"]+)"?/) + if (filenameMatch?.[1]) { + return filenameMatch[1] + } + + return null +}