Files
accounted/lib/api/content-disposition.ts
T
Mattsson 321e684523 Fix/usr fdbck ch (#1105)
* fix(privacy): mask voucher amounts in session replays

* fix: persist transaction source filter

* fix: clarify invoice filenames and booking previews

* fix: truncate long uploaded filenames

* feat: add invoice delivery history

* fix: harden invoice delivery history

* fix: include invoice deliveries in full archive
2026-07-22 18:49:57 +02:00

60 lines
2.4 KiB
TypeScript

/**
* RFC 6266 Content-Disposition builder with RFC 5987 extended filename
* encoding.
*
* undici (the fetch/Headers implementation in the Next.js runtime) requires
* header values to be ByteStrings: every code unit <= 0xFF. Splicing a raw
* filename into the header therefore throws for any non-Latin-1 character,
* e.g. the NFD combining diaeresis (U+0308) that macOS/iOS uploads put in
* Swedish filenames, turning the whole response into a 500.
*
* The dual form emitted here is:
*
* <type>; filename="<ascii fallback>"; filename*=UTF-8''<percent-encoded>
*
* Legacy clients read `filename`; modern browsers prefer `filename*`
* (RFC 6266 section 4.3) and decode the original UTF-8 name.
*/
export function contentDisposition(
type: 'inline' | 'attachment',
filename: string,
): string {
// Lone/unpaired UTF-16 surrogates survive normalize('NFC') and make
// encodeURIComponent below throw a URIError, which would turn the download
// response into the very 500 this helper exists to prevent. Replace them
// with U+FFFD first so the function always returns a valid header value.
// Then normalize NFD (macOS/iOS) to NFC so precomposed characters encode
// as themselves instead of base letter + combining mark.
const normalized = filename.toWellFormed().normalize('NFC')
// ASCII fallback for the quoted-string form: anything outside printable
// ASCII, plus structurally significant header characters, becomes _. This
// also neutralizes CR/LF header injection.
const fallback = normalized.replace(/[^\x20-\x7e]|["\\;]/g, '_')
// RFC 5987 value-chars: encodeURIComponent covers everything except
// ! ' ( ) * which it leaves bare but RFC 5987 forbids unencoded.
const encoded = encodeURIComponent(normalized).replace(
/[!'()*]/g,
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
)
return `${type}; filename="${fallback}"; filename*=UTF-8''${encoded}`
}
/** Read the preferred UTF-8 filename from a Content-Disposition header. */
export function contentDispositionFilename(header: string | null): string | null {
if (!header) return null
const extended = header.match(/(?:^|;)\s*filename\*=UTF-8''([^;]*)/i)
if (extended?.[1]) {
try {
return decodeURIComponent(extended[1])
} catch {
// Fall through to the ASCII quoted-string form.
}
}
return header.match(/(?:^|;)\s*filename="([^"]*)"/i)?.[1] ?? null
}