b4a21b1029
* fix(documents): RFC 5987 Content-Disposition so NFD filenames stop crashing inline view macOS/iOS uploads carry NFD-decomposed filenames (base letter + combining diaeresis U+0308, char code 776). undici Headers require ByteString values (every code unit <= 0xFF), so splicing the raw filename into the Content-Disposition header threw while building the response and the inline document route 500ed. 122 prod documents across 35 companies hit this; last crash 2026-07-09T16:17. Add lib/api/content-disposition.ts emitting the RFC 6266 dual form: an ASCII quoted fallback (NFC-normalize, then replace anything outside printable ASCII plus quote and backslash with _) and filename*=UTF-8''<percent-encoded> per RFC 5987 (encodeURIComponent on the NFC name, additionally escaping ! ' ( ) * which it leaves bare). Use it in the inline document route and in the two latent same-shape sites that embed raw employee names in payslip PDF headers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): sanitize lone surrogates before percent-encoding Content-Disposition (CodeRabbit) Unpaired UTF-16 surrogates survive normalize('NFC') and make encodeURIComponent throw a URIError, so replace them with U+FFFD via String.prototype.toWellFormed() before encoding so the helper always returns a valid header value. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
44 lines
1.9 KiB
TypeScript
44 lines
1.9 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 the quoted-string specials " and \, 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}`
|
|
}
|