OutputCacheManager.coffee 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. async = require "async"
  2. fs = require "fs"
  3. fse = require "fs-extra"
  4. Path = require "path"
  5. logger = require "logger-sharelatex"
  6. _ = require "underscore"
  7. Settings = require "settings-sharelatex"
  8. crypto = require "crypto"
  9. OutputFileOptimiser = require "./OutputFileOptimiser"
  10. module.exports = OutputCacheManager =
  11. CACHE_SUBDIR: '.cache/clsi'
  12. ARCHIVE_SUBDIR: '.archive/clsi'
  13. # build id is HEXDATE-HEXRANDOM from Date.now()and RandomBytes
  14. # for backwards compatibility, make the randombytes part optional
  15. BUILD_REGEX: /^[0-9a-f]+(-[0-9a-f]+)?$/
  16. CACHE_LIMIT: 2 # maximum number of cache directories
  17. CACHE_AGE: 60*60*1000 # up to one hour old
  18. path: (buildId, file) ->
  19. # used by static server, given build id return '.cache/clsi/buildId'
  20. if buildId.match OutputCacheManager.BUILD_REGEX
  21. return Path.join(OutputCacheManager.CACHE_SUBDIR, buildId, file)
  22. else
  23. # for invalid build id, return top level
  24. return file
  25. generateBuildId: (callback = (error, buildId) ->) ->
  26. # generate a secure build id from Date.now() and 8 random bytes in hex
  27. crypto.randomBytes 8, (err, buf) ->
  28. return callback(err) if err?
  29. random = buf.toString('hex')
  30. date = Date.now().toString(16)
  31. callback err, "#{date}-#{random}"
  32. saveOutputFiles: (outputFiles, compileDir, callback = (error) ->) ->
  33. OutputCacheManager.generateBuildId (err, buildId) ->
  34. return callback(err) if err?
  35. OutputCacheManager.saveOutputFilesInBuildDir outputFiles, compileDir, buildId, callback
  36. saveOutputFilesInBuildDir: (outputFiles, compileDir, buildId, callback = (error) ->) ->
  37. # make a compileDir/CACHE_SUBDIR/build_id directory and
  38. # copy all the output files into it
  39. cacheRoot = Path.join(compileDir, OutputCacheManager.CACHE_SUBDIR)
  40. # Put the files into a new cache subdirectory
  41. cacheDir = Path.join(compileDir, OutputCacheManager.CACHE_SUBDIR, buildId)
  42. # Is it a per-user compile? check if compile directory is PROJECTID-USERID
  43. perUser = Path.basename(compileDir).match(/^[0-9a-f]{24}-[0-9a-f]{24}$/)
  44. # Archive logs in background
  45. if Settings.clsi?.archive_logs or Settings.clsi?.strace
  46. OutputCacheManager.archiveLogs outputFiles, compileDir, buildId, (err) ->
  47. if err?
  48. logger.warn err:err, "erroring archiving log files"
  49. # make the new cache directory
  50. fse.ensureDir cacheDir, (err) ->
  51. if err?
  52. logger.error err: err, directory: cacheDir, "error creating cache directory"
  53. callback(err, outputFiles)
  54. else
  55. # copy all the output files into the new cache directory
  56. results = []
  57. async.mapSeries outputFiles, (file, cb) ->
  58. # don't send dot files as output, express doesn't serve them
  59. if OutputCacheManager._fileIsHidden(file.path)
  60. logger.debug compileDir: compileDir, path: file.path, "ignoring dotfile in output"
  61. return cb()
  62. # copy other files into cache directory if valid
  63. newFile = _.clone(file)
  64. [src, dst] = [Path.join(compileDir, file.path), Path.join(cacheDir, file.path)]
  65. OutputCacheManager._checkFileIsSafe src, (err, isSafe) ->
  66. return cb(err) if err?
  67. if !isSafe
  68. return cb()
  69. OutputCacheManager._checkIfShouldCopy src, (err, shouldCopy) ->
  70. return cb(err) if err?
  71. if !shouldCopy
  72. return cb()
  73. OutputCacheManager._copyFile src, dst, (err) ->
  74. return cb(err) if err?
  75. newFile.build = buildId # attach a build id if we cached the file
  76. results.push newFile
  77. cb()
  78. , (err) ->
  79. if err?
  80. # pass back the original files if we encountered *any* error
  81. callback(err, outputFiles)
  82. # clean up the directory we just created
  83. fse.remove cacheDir, (err) ->
  84. if err?
  85. logger.error err: err, dir: cacheDir, "error removing cache dir after failure"
  86. else
  87. # pass back the list of new files in the cache
  88. callback(err, results)
  89. # let file expiry run in the background, expire all previous files if per-user
  90. OutputCacheManager.expireOutputFiles cacheRoot, {keep: buildId, limit: if perUser then 1 else null}
  91. archiveLogs: (outputFiles, compileDir, buildId, callback = (error) ->) ->
  92. archiveDir = Path.join(compileDir, OutputCacheManager.ARCHIVE_SUBDIR, buildId)
  93. logger.log {dir: archiveDir}, "archiving log files for project"
  94. fse.ensureDir archiveDir, (err) ->
  95. return callback(err) if err?
  96. async.mapSeries outputFiles, (file, cb) ->
  97. [src, dst] = [Path.join(compileDir, file.path), Path.join(archiveDir, file.path)]
  98. OutputCacheManager._checkFileIsSafe src, (err, isSafe) ->
  99. return cb(err) if err?
  100. return cb() if !isSafe
  101. OutputCacheManager._checkIfShouldArchive src, (err, shouldArchive) ->
  102. return cb(err) if err?
  103. return cb() if !shouldArchive
  104. OutputCacheManager._copyFile src, dst, cb
  105. , callback
  106. expireOutputFiles: (cacheRoot, options, callback = (error) ->) ->
  107. # look in compileDir for build dirs and delete if > N or age of mod time > T
  108. fs.readdir cacheRoot, (err, results) ->
  109. if err?
  110. return callback(null) if err.code == 'ENOENT' # cache directory is empty
  111. logger.error err: err, project_id: cacheRoot, "error clearing cache"
  112. return callback(err)
  113. dirs = results.sort().reverse()
  114. currentTime = Date.now()
  115. isExpired = (dir, index) ->
  116. return false if options?.keep == dir
  117. # remove any directories over the requested (non-null) limit
  118. return true if options?.limit? and index > options.limit
  119. # remove any directories over the hard limit
  120. return true if index > OutputCacheManager.CACHE_LIMIT
  121. # we can get the build time from the first part of the directory name DDDD-RRRR
  122. # DDDD is date and RRRR is random bytes
  123. dirTime = parseInt(dir.split('-')?[0], 16)
  124. age = currentTime - dirTime
  125. return age > OutputCacheManager.CACHE_AGE
  126. toRemove = _.filter(dirs, isExpired)
  127. removeDir = (dir, cb) ->
  128. fse.remove Path.join(cacheRoot, dir), (err, result) ->
  129. logger.log cache: cacheRoot, dir: dir, "removed expired cache dir"
  130. if err?
  131. logger.error err: err, dir: dir, "cache remove error"
  132. cb(err, result)
  133. async.eachSeries toRemove, (dir, cb) ->
  134. removeDir dir, cb
  135. , callback
  136. _fileIsHidden: (path) ->
  137. return path?.match(/^\.|\/\./)?
  138. _checkFileIsSafe: (src, callback = (error, isSafe) ->) ->
  139. # check if we have a valid file to copy into the cache
  140. fs.stat src, (err, stats) ->
  141. if err?.code is 'ENOENT'
  142. logger.warn err: err, file: src, "file has disappeared before copying to build cache"
  143. callback(err, false)
  144. else if err?
  145. # some other problem reading the file
  146. logger.error err: err, file: src, "stat error for file in cache"
  147. callback(err, false)
  148. else if not stats.isFile()
  149. # other filetype - reject it
  150. logger.warn src: src, stat: stats, "nonfile output - refusing to copy to cache"
  151. callback(null, false)
  152. else
  153. # it's a plain file, ok to copy
  154. callback(null, true)
  155. _copyFile: (src, dst, callback) ->
  156. # copy output file into the cache
  157. fse.copy src, dst, (err) ->
  158. if err?.code is 'ENOENT'
  159. logger.warn err: err, file: src, "file has disappeared when copying to build cache"
  160. callback(err, false)
  161. else if err?
  162. logger.error err: err, src: src, dst: dst, "copy error for file in cache"
  163. callback(err)
  164. else
  165. if Settings.clsi?.optimiseInDocker
  166. # don't run any optimisations on the pdf when they are done
  167. # in the docker container
  168. callback()
  169. else
  170. # call the optimiser for the file too
  171. OutputFileOptimiser.optimiseFile src, dst, callback
  172. _checkIfShouldCopy: (src, callback = (err, shouldCopy) ->) ->
  173. return callback(null, !Path.basename(src).match(/^strace/))
  174. _checkIfShouldArchive: (src, callback = (err, shouldCopy) ->) ->
  175. if Path.basename(src).match(/^strace/)
  176. return callback(null, true)
  177. if Settings.clsi?.archive_logs and Path.basename(src) in ["output.log", "output.blg"]
  178. return callback(null, true)
  179. return callback(null, false)