/** * 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: * * ; filename=""; filename*=UTF-8'' * * 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 }