[6a3a178] | 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 | }
|
---|