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