index.js
  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.slice(-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      case 'utf8':
285        value = Buffer.from(binary, 'binary').toString('utf8')
286        break
287      default:
288        throw new TypeError('unsupported charset in extended field')
289    }
290  
291    return value
292  }
293  
294  /**
295   * Get ISO-8859-1 version of string.
296   *
297   * @param {string} val
298   * @return {string}
299   * @private
300   */
301  
302  function getlatin1 (val) {
303    // simple Unicode -> ISO-8859-1 transformation
304    return String(val).replace(NON_LATIN1_REGEXP, '?')
305  }
306  
307  /**
308   * Parse Content-Disposition header string.
309   *
310   * @param {string} string
311   * @return {object}
312   * @public
313   */
314  
315  function parse (string) {
316    if (!string || typeof string !== 'string') {
317      throw new TypeError('argument string is required')
318    }
319  
320    var match = DISPOSITION_TYPE_REGEXP.exec(string)
321  
322    if (!match) {
323      throw new TypeError('invalid type format')
324    }
325  
326    // normalize type
327    var index = match[0].length
328    var type = match[1].toLowerCase()
329  
330    var key
331    var names = []
332    var params = {}
333    var value
334  
335    // calculate index to start at
336    index = PARAM_REGEXP.lastIndex = match[0].slice(-1) === ';'
337      ? index - 1
338      : index
339  
340    // match parameters
341    while ((match = PARAM_REGEXP.exec(string))) {
342      if (match.index !== index) {
343        throw new TypeError('invalid parameter format')
344      }
345  
346      index += match[0].length
347      key = match[1].toLowerCase()
348      value = match[2]
349  
350      if (names.indexOf(key) !== -1) {
351        throw new TypeError('invalid duplicate parameter')
352      }
353  
354      names.push(key)
355  
356      if (key.indexOf('*') + 1 === key.length) {
357        // decode extended value
358        key = key.slice(0, -1)
359        value = decodefield(value)
360  
361        // overwrite existing value
362        params[key] = value
363        continue
364      }
365  
366      if (typeof params[key] === 'string') {
367        continue
368      }
369  
370      if (value[0] === '"') {
371        // remove quotes and escapes
372        value = value
373          .slice(1, -1)
374          .replace(QESC_REGEXP, '$1')
375      }
376  
377      params[key] = value
378    }
379  
380    if (index !== -1 && index !== string.length) {
381      throw new TypeError('invalid parameter format')
382    }
383  
384    return new ContentDisposition(type, params)
385  }
386  
387  /**
388   * Percent decode a single character.
389   *
390   * @param {string} str
391   * @param {string} hex
392   * @return {string}
393   * @private
394   */
395  
396  function pdecode (str, hex) {
397    return String.fromCharCode(parseInt(hex, 16))
398  }
399  
400  /**
401   * Percent encode a single character.
402   *
403   * @param {string} char
404   * @return {string}
405   * @private
406   */
407  
408  function pencode (char) {
409    return '%' + String(char)
410      .charCodeAt(0)
411      .toString(16)
412      .toUpperCase()
413  }
414  
415  /**
416   * Quote a string for HTTP.
417   *
418   * @param {string} val
419   * @return {string}
420   * @private
421   */
422  
423  function qstring (val) {
424    var str = String(val)
425  
426    return '"' + str.replace(QUOTE_REGEXP, '\\$1') + '"'
427  }
428  
429  /**
430   * Encode a Unicode string for HTTP (RFC 5987).
431   *
432   * @param {string} val
433   * @return {string}
434   * @private
435   */
436  
437  function ustring (val) {
438    var str = String(val)
439  
440    // percent encode as UTF-8
441    var encoded = encodeURIComponent(str)
442      .replace(ENCODE_URL_ATTR_CHAR_REGEXP, pencode)
443  
444    return 'UTF-8\'\'' + encoded
445  }
446  
447  /**
448   * Class for parsed Content-Disposition header for v8 optimization
449   *
450   * @public
451   * @param {string} type
452   * @param {object} parameters
453   * @constructor
454   */
455  
456  function ContentDisposition (type, parameters) {
457    this.type = type
458    this.parameters = parameters
459  }