index.js 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. var crypto = require('crypto')
  2. var fs = require('mz/fs')
  3. var zlib = require('mz/zlib')
  4. var path = require('path')
  5. var mime = require('mime-types')
  6. var compressible = require('compressible')
  7. var readDir = require('fs-readdir-recursive')
  8. var debug = require('debug')('koa-static-cache')
  9. module.exports = function staticCache(dir, options, files) {
  10. if (typeof dir === 'object') {
  11. files = options
  12. options = dir
  13. dir = null
  14. }
  15. options = options || {}
  16. // prefix must be ASCII code
  17. options.prefix = (options.prefix || '').replace(/\/*$/, '/')
  18. files = new FileManager(files || options.files)
  19. dir = dir || options.dir || process.cwd()
  20. dir = path.normalize(dir)
  21. var enableGzip = !!options.gzip
  22. var filePrefix = path.normalize(options.prefix.replace(/^\//, ''))
  23. // option.filter
  24. var fileFilter = function () { return true }
  25. if (Array.isArray(options.filter)) fileFilter = function (file) { return ~options.filter.indexOf(file) }
  26. if (typeof options.filter === 'function') fileFilter = options.filter
  27. if (options.preload !== false) {
  28. readDir(dir).filter(fileFilter).forEach(function (name) {
  29. loadFile(name, dir, options, files)
  30. })
  31. }
  32. return async (ctx, next) => {
  33. // only accept HEAD and GET
  34. if (ctx.method !== 'HEAD' && ctx.method !== 'GET') return await next()
  35. // check prefix first to avoid calculate
  36. if (ctx.path.indexOf(options.prefix) !== 0) return await next()
  37. // decode for `/%E4%B8%AD%E6%96%87`
  38. // normalize for `//index`
  39. var filename = path.normalize(safeDecodeURIComponent(ctx.path))
  40. // check alias
  41. if (options.alias && options.alias[filename]) filename = options.alias[filename];
  42. var file = files.get(filename)
  43. // try to load file
  44. if (!file) {
  45. if (!options.dynamic) return await next()
  46. if (path.basename(filename)[0] === '.') return await next()
  47. if (filename.charAt(0) === path.sep) filename = filename.slice(1)
  48. // trim prefix
  49. if (options.prefix !== '/') {
  50. if (filename.indexOf(filePrefix) !== 0) return await next()
  51. filename = filename.slice(filePrefix.length)
  52. }
  53. var fullpath = path.join(dir, filename)
  54. // files that can be accessd should be under options.dir
  55. if (fullpath.indexOf(dir) !== 0) {
  56. return await next()
  57. }
  58. var s
  59. try {
  60. s = await fs.stat(fullpath)
  61. } catch (err) {
  62. return await next()
  63. }
  64. if (!s.isFile()) return await next()
  65. file = loadFile(filename, dir, options, files)
  66. }
  67. ctx.status = 200
  68. if (enableGzip) ctx.vary('Accept-Encoding')
  69. if (!file.buffer) {
  70. var stats = await fs.stat(file.path)
  71. if (stats.mtime.getTime() !== file.mtime.getTime()) {
  72. file.mtime = stats.mtime
  73. file.md5 = null
  74. file.length = stats.size
  75. }
  76. }
  77. ctx.response.lastModified = file.mtime
  78. if (file.md5) ctx.response.etag = file.md5
  79. if (ctx.fresh) return ctx.status = 304
  80. ctx.type = file.type
  81. ctx.length = file.zipBuffer ? file.zipBuffer.length : file.length
  82. ctx.set('cache-control', file.cacheControl || 'public, max-age=' + file.maxAge)
  83. if (file.md5) ctx.set('content-md5', file.md5)
  84. if (ctx.method === 'HEAD') return
  85. var acceptGzip = ctx.acceptsEncodings('gzip') === 'gzip'
  86. if (file.zipBuffer) {
  87. if (acceptGzip) {
  88. ctx.set('content-encoding', 'gzip')
  89. ctx.body = file.zipBuffer
  90. } else {
  91. ctx.body = file.buffer
  92. }
  93. return
  94. }
  95. var shouldGzip = enableGzip
  96. && file.length > 1024
  97. && acceptGzip
  98. && compressible(file.type)
  99. if (file.buffer) {
  100. if (shouldGzip) {
  101. var gzFile = files.get(filename + '.gz')
  102. if (options.usePrecompiledGzip && gzFile && gzFile.buffer) { // if .gz file already read from disk
  103. file.zipBuffer = gzFile.buffer
  104. } else {
  105. file.zipBuffer = await zlib.gzip(file.buffer)
  106. }
  107. ctx.set('content-encoding', 'gzip')
  108. ctx.body = file.zipBuffer
  109. } else {
  110. ctx.body = file.buffer
  111. }
  112. return
  113. }
  114. var stream = fs.createReadStream(file.path)
  115. // update file hash
  116. if (!file.md5) {
  117. var hash = crypto.createHash('md5')
  118. stream.on('data', hash.update.bind(hash))
  119. stream.on('end', function () {
  120. file.md5 = hash.digest('base64')
  121. })
  122. }
  123. ctx.body = stream
  124. // enable gzip will remove content length
  125. if (shouldGzip) {
  126. ctx.remove('content-length')
  127. ctx.set('content-encoding', 'gzip')
  128. ctx.body = stream.pipe(zlib.createGzip())
  129. }
  130. }
  131. }
  132. function safeDecodeURIComponent(text) {
  133. try {
  134. return decodeURIComponent(text)
  135. } catch (e) {
  136. return text
  137. }
  138. }
  139. /**
  140. * load file and add file content to cache
  141. *
  142. * @param {String} name
  143. * @param {String} dir
  144. * @param {Object} options
  145. * @param {Object} files
  146. * @return {Object}
  147. * @api private
  148. */
  149. function loadFile(name, dir, options, files) {
  150. var pathname = path.normalize(path.join(options.prefix, name))
  151. if (!files.get(pathname)) files.set(pathname, {})
  152. var obj = files.get(pathname)
  153. var filename = obj.path = path.join(dir, name)
  154. var stats = fs.statSync(filename)
  155. var buffer = fs.readFileSync(filename)
  156. obj.cacheControl = options.cacheControl
  157. obj.maxAge = obj.maxAge ? obj.maxAge : options.maxAge || 0
  158. obj.type = obj.mime = mime.lookup(pathname) || 'application/octet-stream'
  159. obj.mtime = stats.mtime
  160. obj.length = stats.size
  161. obj.md5 = crypto.createHash('md5').update(buffer).digest('base64')
  162. debug('file: ' + JSON.stringify(obj, null, 2))
  163. if (options.buffer)
  164. obj.buffer = buffer
  165. buffer = null
  166. return obj
  167. }
  168. function FileManager(store) {
  169. if (store && typeof store.set === 'function' && typeof store.get === 'function') {
  170. this.store = store
  171. } else {
  172. this.map = store || Object.create(null)
  173. }
  174. }
  175. FileManager.prototype.get = function (key) {
  176. return this.store ? this.store.get(key) : this.map[key]
  177. }
  178. FileManager.prototype.set = function (key, value) {
  179. if (this.store) return this.store.set(key, value)
  180. this.map[key] = value
  181. }