Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/core/src/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ describe('generateContentDisposition', () => {
it('escape non-ASCII filenames', () => {
expect(generateContentDisposition('テンプレ\'"ート.txt')).toEqual('inline; filename="____\'\\"__.txt"; filename*=utf-8\'\'%E3%83%86%E3%83%B3%E3%83%97%E3%83%AC%27%22%E3%83%BC%E3%83%88.txt')
})

it('support inline and attachment types', () => {
expect(generateContentDisposition('test.txt', 'inline')).toEqual('inline; filename="test.txt"; filename*=utf-8\'\'test.txt')
expect(generateContentDisposition('test.txt', 'attachment')).toEqual('attachment; filename="test.txt"; filename*=utf-8\'\'test.txt')
})
})

it('getFilenameFromContentDisposition', () => {
Expand All @@ -49,6 +54,25 @@ it('getFilenameFromContentDisposition', () => {
expect(getFilenameFromContentDisposition('inline; filename"hello.txt"; size=123')).toEqual(undefined)

expect(getFilenameFromContentDisposition('inline; filename*=!%40%23%24%25^%25^%26%2A%28%29%27%22.txt; size=123')).toEqual('!@#$%^%^&*()\'".txt')

// unquoted token form
expect(getFilenameFromContentDisposition('attachment; filename=report.pdf')).toEqual('report.pdf')
expect(getFilenameFromContentDisposition('attachment; filename=report.pdf; size=123')).toEqual('report.pdf')
expect(getFilenameFromContentDisposition('attachment; filename=')).toEqual(undefined)

// param names must be anchored, not substring-matched
expect(getFilenameFromContentDisposition('attachment; xfilename*=evil')).toEqual(undefined)
expect(getFilenameFromContentDisposition('attachment; filename="good.txt"; xfilename*=evil.exe')).toEqual('good.txt')
expect(getFilenameFromContentDisposition('attachment; creation-filename="e.exe"')).toEqual(undefined)
expect(getFilenameFromContentDisposition('filename*=utf-8\'\'first.txt')).toEqual('first.txt')
expect(getFilenameFromContentDisposition('filename=first.txt')).toEqual('first.txt')

// ext-value charset and language prefix
expect(getFilenameFromContentDisposition('attachment; filename*=utf-8\'en\'%E2%82%AC.txt')).toEqual('€.txt')
expect(getFilenameFromContentDisposition('attachment; filename*=UTF-8\'\'%E2%82%AC.txt')).toEqual('€.txt')
expect(getFilenameFromContentDisposition('attachment; filename*=us-ascii\'en\'test.txt')).toEqual('test.txt')
expect(getFilenameFromContentDisposition('attachment; filename*=iso-8859-1\'\'%E9.txt; filename="fallback.txt"')).toEqual('fallback.txt')
expect(getFilenameFromContentDisposition('attachment; filename*=iso-8859-1\'\'%E9.txt')).toEqual(undefined)
})

describe('mergeStandardHeaders', () => {
Expand Down
31 changes: 23 additions & 8 deletions packages/core/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,43 @@
import type { StandardHeaders, StandardUrl } from './types'
import { toArray, tryDecodeURIComponent } from '@standardserver/shared'

export function generateContentDisposition(filename: string): string {
export function generateContentDisposition(filename: string, type: 'inline' | 'attachment' = 'inline'): string {
const encodedFilename = filename.replace(/[^\x20-\x7E]/g, '_').replace(/[\\"]/g, '\\$&')

// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_content-disposition_and_link_headers
const encodedFilenameStar = encodeURIComponent(filename)
.replace(/['()*]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
.replace(/%(7C|60|5E)/g, (str, hex) => String.fromCharCode(Number.parseInt(hex, 16)))

return `inline; filename="${encodedFilename}"; filename*=utf-8''${encodedFilenameStar}`
return `${type}; filename="${encodedFilename}"; filename*=utf-8''${encodedFilenameStar}`
}

export function getFilenameFromContentDisposition(contentDisposition: string): string | undefined {
const encodedFilenameStarMatch = contentDisposition.match(/filename\*=(UTF-8'')?([^;]*)/i)
const extValue = contentDisposition.match(/(?:^|;)\s*filename\*=([^;]*)/i)?.[1]?.trim()

if (encodedFilenameStarMatch && typeof encodedFilenameStarMatch[2] === 'string') {
return tryDecodeURIComponent(encodedFilenameStarMatch[2])
// RFC 8187 ext-value: charset "'" [ language ] "'" value-chars
const extValueMatch = extValue?.match(/^([^']*)'[^']*'(.*)$/)

if (extValueMatch) {
const [, charset = '', encodedFilename = ''] = extValueMatch

if (/^(?:utf-8|us-ascii)$/i.test(charset)) {
return tryDecodeURIComponent(encodedFilename)
}
// unsupported charset: fall through to the plain filename param
}
else if (extValue) {
// lenient: some senders omit the charset prefix entirely
return tryDecodeURIComponent(extValue)
}

const filenameMatch = contentDisposition.match(/(?:^|;)\s*filename=(?:"((?:\\.|[^"\\])*)"|([^";]*))/i)

const encodedFilenameMatch = contentDisposition.match(/filename="((?:\\.|[^"\\])*)"/i)
if (encodedFilenameMatch && typeof encodedFilenameMatch[1] === 'string') {
return encodedFilenameMatch[1].replace(/\\(.)/g, '$1')
if (filenameMatch?.[1] !== undefined) {
return filenameMatch[1].replace(/\\(.)/g, '$1')
}

return filenameMatch?.[2]?.trim() || undefined
}

export function flattenStandardHeader(header: string | readonly string[] | undefined): string | undefined {
Expand Down
Loading