| Leo Repp | 58b9f11 | 2021-11-22 11:57:47 +0100 | [diff] [blame^] | 1 | /*! |
| 2 | * content-disposition |
| 3 | * Copyright(c) 2014-2017 Douglas Christopher Wilson |
| 4 | * MIT Licensed |
| 5 | */ |
| 6 | |
| 7 | 'use strict' |
| 8 | |
| 9 | /** |
| 10 | * Module exports. |
| 11 | * @public |
| 12 | */ |
| 13 | |
| 14 | module.exports = contentDisposition |
| 15 | module.exports.parse = parse |
| 16 | |
| 17 | /** |
| 18 | * Module dependencies. |
| 19 | * @private |
| 20 | */ |
| 21 | |
| 22 | var basename = require('path').basename |
| 23 | var Buffer = require('safe-buffer').Buffer |
| 24 | |
| 25 | /** |
| 26 | * RegExp to match non attr-char, *after* encodeURIComponent (i.e. not including "%") |
| 27 | * @private |
| 28 | */ |
| 29 | |
| 30 | var ENCODE_URL_ATTR_CHAR_REGEXP = /[\x00-\x20"'()*,/:;<=>?@[\\\]{}\x7f]/g // eslint-disable-line no-control-regex |
| 31 | |
| 32 | /** |
| 33 | * RegExp to match percent encoding escape. |
| 34 | * @private |
| 35 | */ |
| 36 | |
| 37 | var HEX_ESCAPE_REGEXP = /%[0-9A-Fa-f]{2}/ |
| 38 | var HEX_ESCAPE_REPLACE_REGEXP = /%([0-9A-Fa-f]{2})/g |
| 39 | |
| 40 | /** |
| 41 | * RegExp to match non-latin1 characters. |
| 42 | * @private |
| 43 | */ |
| 44 | |
| 45 | var NON_LATIN1_REGEXP = /[^\x20-\x7e\xa0-\xff]/g |
| 46 | |
| 47 | /** |
| 48 | * RegExp to match quoted-pair in RFC 2616 |
| 49 | * |
| 50 | * quoted-pair = "\" CHAR |
| 51 | * CHAR = <any US-ASCII character (octets 0 - 127)> |
| 52 | * @private |
| 53 | */ |
| 54 | |
| 55 | var QESC_REGEXP = /\\([\u0000-\u007f])/g // eslint-disable-line no-control-regex |
| 56 | |
| 57 | /** |
| 58 | * RegExp to match chars that must be quoted-pair in RFC 2616 |
| 59 | * @private |
| 60 | */ |
| 61 | |
| 62 | var QUOTE_REGEXP = /([\\"])/g |
| 63 | |
| 64 | /** |
| 65 | * RegExp for various RFC 2616 grammar |
| 66 | * |
| 67 | * parameter = token "=" ( token | quoted-string ) |
| 68 | * token = 1*<any CHAR except CTLs or separators> |
| 69 | * separators = "(" | ")" | "<" | ">" | "@" |
| 70 | * | "," | ";" | ":" | "\" | <"> |
| 71 | * | "/" | "[" | "]" | "?" | "=" |
| 72 | * | "{" | "}" | SP | HT |
| 73 | * quoted-string = ( <"> *(qdtext | quoted-pair ) <"> ) |
| 74 | * qdtext = <any TEXT except <">> |
| 75 | * quoted-pair = "\" CHAR |
| 76 | * CHAR = <any US-ASCII character (octets 0 - 127)> |
| 77 | * TEXT = <any OCTET except CTLs, but including LWS> |
| 78 | * LWS = [CRLF] 1*( SP | HT ) |
| 79 | * CRLF = CR LF |
| 80 | * CR = <US-ASCII CR, carriage return (13)> |
| 81 | * LF = <US-ASCII LF, linefeed (10)> |
| 82 | * SP = <US-ASCII SP, space (32)> |
| 83 | * HT = <US-ASCII HT, horizontal-tab (9)> |
| 84 | * CTL = <any US-ASCII control character (octets 0 - 31) and DEL (127)> |
| 85 | * OCTET = <any 8-bit sequence of data> |
| 86 | * @private |
| 87 | */ |
| 88 | |
| 89 | var PARAM_REGEXP = /;[\x09\x20]*([!#$%&'*+.0-9A-Z^_`a-z|~-]+)[\x09\x20]*=[\x09\x20]*("(?:[\x20!\x23-\x5b\x5d-\x7e\x80-\xff]|\\[\x20-\x7e])*"|[!#$%&'*+.0-9A-Z^_`a-z|~-]+)[\x09\x20]*/g // eslint-disable-line no-control-regex |
| 90 | var TEXT_REGEXP = /^[\x20-\x7e\x80-\xff]+$/ |
| 91 | var TOKEN_REGEXP = /^[!#$%&'*+.0-9A-Z^_`a-z|~-]+$/ |
| 92 | |
| 93 | /** |
| 94 | * RegExp for various RFC 5987 grammar |
| 95 | * |
| 96 | * ext-value = charset "'" [ language ] "'" value-chars |
| 97 | * charset = "UTF-8" / "ISO-8859-1" / mime-charset |
| 98 | * mime-charset = 1*mime-charsetc |
| 99 | * mime-charsetc = ALPHA / DIGIT |
| 100 | * / "!" / "#" / "$" / "%" / "&" |
| 101 | * / "+" / "-" / "^" / "_" / "`" |
| 102 | * / "{" / "}" / "~" |
| 103 | * language = ( 2*3ALPHA [ extlang ] ) |
| 104 | * / 4ALPHA |
| 105 | * / 5*8ALPHA |
| 106 | * extlang = *3( "-" 3ALPHA ) |
| 107 | * value-chars = *( pct-encoded / attr-char ) |
| 108 | * pct-encoded = "%" HEXDIG HEXDIG |
| 109 | * attr-char = ALPHA / DIGIT |
| 110 | * / "!" / "#" / "$" / "&" / "+" / "-" / "." |
| 111 | * / "^" / "_" / "`" / "|" / "~" |
| 112 | * @private |
| 113 | */ |
| 114 | |
| 115 | var EXT_VALUE_REGEXP = /^([A-Za-z0-9!#$%&+\-^_`{}~]+)'(?:[A-Za-z]{2,3}(?:-[A-Za-z]{3}){0,3}|[A-Za-z]{4,8}|)'((?:%[0-9A-Fa-f]{2}|[A-Za-z0-9!#$&+.^_`|~-])+)$/ |
| 116 | |
| 117 | /** |
| 118 | * RegExp for various RFC 6266 grammar |
| 119 | * |
| 120 | * disposition-type = "inline" | "attachment" | disp-ext-type |
| 121 | * disp-ext-type = token |
| 122 | * disposition-parm = filename-parm | disp-ext-parm |
| 123 | * filename-parm = "filename" "=" value |
| 124 | * | "filename*" "=" ext-value |
| 125 | * disp-ext-parm = token "=" value |
| 126 | * | ext-token "=" ext-value |
| 127 | * ext-token = <the characters in token, followed by "*"> |
| 128 | * @private |
| 129 | */ |
| 130 | |
| 131 | var DISPOSITION_TYPE_REGEXP = /^([!#$%&'*+.0-9A-Z^_`a-z|~-]+)[\x09\x20]*(?:$|;)/ // eslint-disable-line no-control-regex |
| 132 | |
| 133 | /** |
| 134 | * Create an attachment Content-Disposition header. |
| 135 | * |
| 136 | * @param {string} [filename] |
| 137 | * @param {object} [options] |
| 138 | * @param {string} [options.type=attachment] |
| 139 | * @param {string|boolean} [options.fallback=true] |
| 140 | * @return {string} |
| 141 | * @public |
| 142 | */ |
| 143 | |
| 144 | function contentDisposition (filename, options) { |
| 145 | var opts = options || {} |
| 146 | |
| 147 | // get type |
| 148 | var type = opts.type || 'attachment' |
| 149 | |
| 150 | // get parameters |
| 151 | var params = createparams(filename, opts.fallback) |
| 152 | |
| 153 | // format into string |
| 154 | return format(new ContentDisposition(type, params)) |
| 155 | } |
| 156 | |
| 157 | /** |
| 158 | * Create parameters object from filename and fallback. |
| 159 | * |
| 160 | * @param {string} [filename] |
| 161 | * @param {string|boolean} [fallback=true] |
| 162 | * @return {object} |
| 163 | * @private |
| 164 | */ |
| 165 | |
| 166 | function createparams (filename, fallback) { |
| 167 | if (filename === undefined) { |
| 168 | return |
| 169 | } |
| 170 | |
| 171 | var params = {} |
| 172 | |
| 173 | if (typeof filename !== 'string') { |
| 174 | throw new TypeError('filename must be a string') |
| 175 | } |
| 176 | |
| 177 | // fallback defaults to true |
| 178 | if (fallback === undefined) { |
| 179 | fallback = true |
| 180 | } |
| 181 | |
| 182 | if (typeof fallback !== 'string' && typeof fallback !== 'boolean') { |
| 183 | throw new TypeError('fallback must be a string or boolean') |
| 184 | } |
| 185 | |
| 186 | if (typeof fallback === 'string' && NON_LATIN1_REGEXP.test(fallback)) { |
| 187 | throw new TypeError('fallback must be ISO-8859-1 string') |
| 188 | } |
| 189 | |
| 190 | // restrict to file base name |
| 191 | var name = basename(filename) |
| 192 | |
| 193 | // determine if name is suitable for quoted string |
| 194 | var isQuotedString = TEXT_REGEXP.test(name) |
| 195 | |
| 196 | // generate fallback name |
| 197 | var fallbackName = typeof fallback !== 'string' |
| 198 | ? fallback && getlatin1(name) |
| 199 | : basename(fallback) |
| 200 | var hasFallback = typeof fallbackName === 'string' && fallbackName !== name |
| 201 | |
| 202 | // set extended filename parameter |
| 203 | if (hasFallback || !isQuotedString || HEX_ESCAPE_REGEXP.test(name)) { |
| 204 | params['filename*'] = name |
| 205 | } |
| 206 | |
| 207 | // set filename parameter |
| 208 | if (isQuotedString || hasFallback) { |
| 209 | params.filename = hasFallback |
| 210 | ? fallbackName |
| 211 | : name |
| 212 | } |
| 213 | |
| 214 | return params |
| 215 | } |
| 216 | |
| 217 | /** |
| 218 | * Format object to Content-Disposition header. |
| 219 | * |
| 220 | * @param {object} obj |
| 221 | * @param {string} obj.type |
| 222 | * @param {object} [obj.parameters] |
| 223 | * @return {string} |
| 224 | * @private |
| 225 | */ |
| 226 | |
| 227 | function format (obj) { |
| 228 | var parameters = obj.parameters |
| 229 | var type = obj.type |
| 230 | |
| 231 | if (!type || typeof type !== 'string' || !TOKEN_REGEXP.test(type)) { |
| 232 | throw new TypeError('invalid type') |
| 233 | } |
| 234 | |
| 235 | // start with normalized type |
| 236 | var string = String(type).toLowerCase() |
| 237 | |
| 238 | // append parameters |
| 239 | if (parameters && typeof parameters === 'object') { |
| 240 | var param |
| 241 | var params = Object.keys(parameters).sort() |
| 242 | |
| 243 | for (var i = 0; i < params.length; i++) { |
| 244 | param = params[i] |
| 245 | |
| 246 | var val = param.substr(-1) === '*' |
| 247 | ? ustring(parameters[param]) |
| 248 | : qstring(parameters[param]) |
| 249 | |
| 250 | string += '; ' + param + '=' + val |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | return string |
| 255 | } |
| 256 | |
| 257 | /** |
| 258 | * Decode a RFC 6987 field value (gracefully). |
| 259 | * |
| 260 | * @param {string} str |
| 261 | * @return {string} |
| 262 | * @private |
| 263 | */ |
| 264 | |
| 265 | function decodefield (str) { |
| 266 | var match = EXT_VALUE_REGEXP.exec(str) |
| 267 | |
| 268 | if (!match) { |
| 269 | throw new TypeError('invalid extended field value') |
| 270 | } |
| 271 | |
| 272 | var charset = match[1].toLowerCase() |
| 273 | var encoded = match[2] |
| 274 | var value |
| 275 | |
| 276 | // to binary string |
| 277 | var binary = encoded.replace(HEX_ESCAPE_REPLACE_REGEXP, pdecode) |
| 278 | |
| 279 | switch (charset) { |
| 280 | case 'iso-8859-1': |
| 281 | value = getlatin1(binary) |
| 282 | break |
| 283 | case 'utf-8': |
| 284 | value = Buffer.from(binary, 'binary').toString('utf8') |
| 285 | break |
| 286 | default: |
| 287 | throw new TypeError('unsupported charset in extended field') |
| 288 | } |
| 289 | |
| 290 | return value |
| 291 | } |
| 292 | |
| 293 | /** |
| 294 | * Get ISO-8859-1 version of string. |
| 295 | * |
| 296 | * @param {string} val |
| 297 | * @return {string} |
| 298 | * @private |
| 299 | */ |
| 300 | |
| 301 | function getlatin1 (val) { |
| 302 | // simple Unicode -> ISO-8859-1 transformation |
| 303 | return String(val).replace(NON_LATIN1_REGEXP, '?') |
| 304 | } |
| 305 | |
| 306 | /** |
| 307 | * Parse Content-Disposition header string. |
| 308 | * |
| 309 | * @param {string} string |
| 310 | * @return {object} |
| 311 | * @public |
| 312 | */ |
| 313 | |
| 314 | function parse (string) { |
| 315 | if (!string || typeof string !== 'string') { |
| 316 | throw new TypeError('argument string is required') |
| 317 | } |
| 318 | |
| 319 | var match = DISPOSITION_TYPE_REGEXP.exec(string) |
| 320 | |
| 321 | if (!match) { |
| 322 | throw new TypeError('invalid type format') |
| 323 | } |
| 324 | |
| 325 | // normalize type |
| 326 | var index = match[0].length |
| 327 | var type = match[1].toLowerCase() |
| 328 | |
| 329 | var key |
| 330 | var names = [] |
| 331 | var params = {} |
| 332 | var value |
| 333 | |
| 334 | // calculate index to start at |
| 335 | index = PARAM_REGEXP.lastIndex = match[0].substr(-1) === ';' |
| 336 | ? index - 1 |
| 337 | : index |
| 338 | |
| 339 | // match parameters |
| 340 | while ((match = PARAM_REGEXP.exec(string))) { |
| 341 | if (match.index !== index) { |
| 342 | throw new TypeError('invalid parameter format') |
| 343 | } |
| 344 | |
| 345 | index += match[0].length |
| 346 | key = match[1].toLowerCase() |
| 347 | value = match[2] |
| 348 | |
| 349 | if (names.indexOf(key) !== -1) { |
| 350 | throw new TypeError('invalid duplicate parameter') |
| 351 | } |
| 352 | |
| 353 | names.push(key) |
| 354 | |
| 355 | if (key.indexOf('*') + 1 === key.length) { |
| 356 | // decode extended value |
| 357 | key = key.slice(0, -1) |
| 358 | value = decodefield(value) |
| 359 | |
| 360 | // overwrite existing value |
| 361 | params[key] = value |
| 362 | continue |
| 363 | } |
| 364 | |
| 365 | if (typeof params[key] === 'string') { |
| 366 | continue |
| 367 | } |
| 368 | |
| 369 | if (value[0] === '"') { |
| 370 | // remove quotes and escapes |
| 371 | value = value |
| 372 | .substr(1, value.length - 2) |
| 373 | .replace(QESC_REGEXP, '$1') |
| 374 | } |
| 375 | |
| 376 | params[key] = value |
| 377 | } |
| 378 | |
| 379 | if (index !== -1 && index !== string.length) { |
| 380 | throw new TypeError('invalid parameter format') |
| 381 | } |
| 382 | |
| 383 | return new ContentDisposition(type, params) |
| 384 | } |
| 385 | |
| 386 | /** |
| 387 | * Percent decode a single character. |
| 388 | * |
| 389 | * @param {string} str |
| 390 | * @param {string} hex |
| 391 | * @return {string} |
| 392 | * @private |
| 393 | */ |
| 394 | |
| 395 | function pdecode (str, hex) { |
| 396 | return String.fromCharCode(parseInt(hex, 16)) |
| 397 | } |
| 398 | |
| 399 | /** |
| 400 | * Percent encode a single character. |
| 401 | * |
| 402 | * @param {string} char |
| 403 | * @return {string} |
| 404 | * @private |
| 405 | */ |
| 406 | |
| 407 | function pencode (char) { |
| 408 | return '%' + String(char) |
| 409 | .charCodeAt(0) |
| 410 | .toString(16) |
| 411 | .toUpperCase() |
| 412 | } |
| 413 | |
| 414 | /** |
| 415 | * Quote a string for HTTP. |
| 416 | * |
| 417 | * @param {string} val |
| 418 | * @return {string} |
| 419 | * @private |
| 420 | */ |
| 421 | |
| 422 | function qstring (val) { |
| 423 | var str = String(val) |
| 424 | |
| 425 | return '"' + str.replace(QUOTE_REGEXP, '\\$1') + '"' |
| 426 | } |
| 427 | |
| 428 | /** |
| 429 | * Encode a Unicode string for HTTP (RFC 5987). |
| 430 | * |
| 431 | * @param {string} val |
| 432 | * @return {string} |
| 433 | * @private |
| 434 | */ |
| 435 | |
| 436 | function ustring (val) { |
| 437 | var str = String(val) |
| 438 | |
| 439 | // percent encode as UTF-8 |
| 440 | var encoded = encodeURIComponent(str) |
| 441 | .replace(ENCODE_URL_ATTR_CHAR_REGEXP, pencode) |
| 442 | |
| 443 | return 'UTF-8\'\'' + encoded |
| 444 | } |
| 445 | |
| 446 | /** |
| 447 | * Class for parsed Content-Disposition header for v8 optimization |
| 448 | * |
| 449 | * @public |
| 450 | * @param {string} type |
| 451 | * @param {object} parameters |
| 452 | * @constructor |
| 453 | */ |
| 454 | |
| 455 | function ContentDisposition (type, parameters) { |
| 456 | this.type = type |
| 457 | this.parameters = parameters |
| 458 | } |