]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Fix: preserve non-ASCII filenames in document downloads (#9702)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Sat, 19 Apr 2025 22:10:34 +0000 (15:10 -0700)
committerGitHub <noreply@github.com>
Sat, 19 Apr 2025 22:10:34 +0000 (22:10 +0000)
src-ui/src/app/components/document-detail/document-detail.component.ts
src-ui/src/app/utils/http.spec.ts [new file with mode: 0644]
src-ui/src/app/utils/http.ts [new file with mode: 0644]

index d9d78206f56b26e1c612a4b96abb5f2adf479c99..632a2de30ba4a00b8027670cf147aece695c5e25 100644 (file)
@@ -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<Blob>) => {
-          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 (file)
index 0000000..ab3421d
--- /dev/null
@@ -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 (file)
index 0000000..96af8d7
--- /dev/null
@@ -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
+}