index.js 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. /**
  2. * Module dependencies.
  3. */
  4. const fs = require('fs')
  5. const util = require('util')
  6. const debug = require('debug')('koa-send')
  7. const resolvePath = require('resolve-path')
  8. const createError = require('http-errors')
  9. const assert = require('assert')
  10. const stat = util.promisify(fs.stat)
  11. const access = util.promisify(fs.access)
  12. async function exists (path) {
  13. try {
  14. await access(path)
  15. return true
  16. } catch (e) {
  17. return false
  18. }
  19. }
  20. const {
  21. normalize,
  22. basename,
  23. extname,
  24. resolve,
  25. parse,
  26. sep
  27. } = require('path')
  28. /**
  29. * Expose `send()`.
  30. */
  31. module.exports = send
  32. /**
  33. * Send file at `path` with the
  34. * given `options` to the koa `ctx`.
  35. *
  36. * @param {Context} ctx
  37. * @param {String} path
  38. * @param {Object} [opts]
  39. * @return {Promise}
  40. * @api public
  41. */
  42. async function send (ctx, path, opts = {}) {
  43. assert(ctx, 'koa context required')
  44. assert(path, 'pathname required')
  45. // options
  46. debug('send "%s" %j', path, opts)
  47. const root = opts.root ? normalize(resolve(opts.root)) : ''
  48. const trailingSlash = path[path.length - 1] === '/'
  49. path = path.substr(parse(path).root.length)
  50. const index = opts.index
  51. const maxage = opts.maxage || opts.maxAge || 0
  52. const immutable = opts.immutable || false
  53. const hidden = opts.hidden || false
  54. const format = opts.format !== false
  55. const extensions = Array.isArray(opts.extensions) ? opts.extensions : false
  56. const brotli = opts.brotli !== false
  57. const gzip = opts.gzip !== false
  58. const setHeaders = opts.setHeaders
  59. if (setHeaders && typeof setHeaders !== 'function') {
  60. throw new TypeError('option setHeaders must be function')
  61. }
  62. // normalize path
  63. path = decode(path)
  64. if (path === -1) return ctx.throw(400, 'failed to decode')
  65. // index file support
  66. if (index && trailingSlash) path += index
  67. path = resolvePath(root, path)
  68. // hidden file support, ignore
  69. if (!hidden && isHidden(root, path)) return
  70. let encodingExt = ''
  71. // serve brotli file when possible otherwise gzipped file when possible
  72. if (ctx.acceptsEncodings('br', 'identity') === 'br' && brotli && (await exists(path + '.br'))) {
  73. path = path + '.br'
  74. ctx.set('Content-Encoding', 'br')
  75. ctx.res.removeHeader('Content-Length')
  76. encodingExt = '.br'
  77. } else if (ctx.acceptsEncodings('gzip', 'identity') === 'gzip' && gzip && (await exists(path + '.gz'))) {
  78. path = path + '.gz'
  79. ctx.set('Content-Encoding', 'gzip')
  80. ctx.res.removeHeader('Content-Length')
  81. encodingExt = '.gz'
  82. }
  83. if (extensions && !/\./.exec(basename(path))) {
  84. const list = [].concat(extensions)
  85. for (let i = 0; i < list.length; i++) {
  86. let ext = list[i]
  87. if (typeof ext !== 'string') {
  88. throw new TypeError('option extensions must be array of strings or false')
  89. }
  90. if (!/^\./.exec(ext)) ext = `.${ext}`
  91. if (await exists(`${path}${ext}`)) {
  92. path = `${path}${ext}`
  93. break
  94. }
  95. }
  96. }
  97. // stat
  98. let stats
  99. try {
  100. stats = await stat(path)
  101. // Format the path to serve static file servers
  102. // and not require a trailing slash for directories,
  103. // so that you can do both `/directory` and `/directory/`
  104. if (stats.isDirectory()) {
  105. if (format && index) {
  106. path += `/${index}`
  107. stats = await stat(path)
  108. } else {
  109. return
  110. }
  111. }
  112. } catch (err) {
  113. const notfound = ['ENOENT', 'ENAMETOOLONG', 'ENOTDIR']
  114. if (notfound.includes(err.code)) {
  115. throw createError(404, err)
  116. }
  117. err.status = 500
  118. throw err
  119. }
  120. if (setHeaders) setHeaders(ctx.res, path, stats)
  121. // stream
  122. ctx.set('Content-Length', stats.size)
  123. if (!ctx.response.get('Last-Modified')) ctx.set('Last-Modified', stats.mtime.toUTCString())
  124. if (!ctx.response.get('Cache-Control')) {
  125. const directives = [`max-age=${(maxage / 1000 | 0)}`]
  126. if (immutable) {
  127. directives.push('immutable')
  128. }
  129. ctx.set('Cache-Control', directives.join(','))
  130. }
  131. if (!ctx.type) ctx.type = type(path, encodingExt)
  132. ctx.body = fs.createReadStream(path)
  133. return path
  134. }
  135. /**
  136. * Check if it's hidden.
  137. */
  138. function isHidden (root, path) {
  139. path = path.substr(root.length).split(sep)
  140. for (let i = 0; i < path.length; i++) {
  141. if (path[i][0] === '.') return true
  142. }
  143. return false
  144. }
  145. /**
  146. * File type.
  147. */
  148. function type (file, ext) {
  149. return ext !== '' ? extname(basename(file, ext)) : extname(file)
  150. }
  151. /**
  152. * Decode `path`.
  153. */
  154. function decode (path) {
  155. try {
  156. return decodeURIComponent(path)
  157. } catch (err) {
  158. return -1
  159. }
  160. }