You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

458 lines
10 KiB

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