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.

443 lines
10 KiB

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