| 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 5987 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 | }
|
|---|