ContentCacheManager.js 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. /**
  2. * ContentCacheManager - maintains a cache of stream hashes from a PDF file
  3. */
  4. const { callbackify } = require('util')
  5. const fs = require('fs')
  6. const crypto = require('crypto')
  7. const Path = require('path')
  8. const Settings = require('settings-sharelatex')
  9. const pLimit = require('p-limit')
  10. const MIN_CHUNK_SIZE = Settings.pdfCachingMinChunkSize
  11. const START_OF_STREAM_MARKER = 'stream'
  12. const END_OF_STREAM_MARKER = 'endstream'
  13. const START_OF_STREAM_MARKER_LENGTH = START_OF_STREAM_MARKER.length
  14. const END_OF_STREAM_MARKER_LENGTH = END_OF_STREAM_MARKER.length
  15. /**
  16. *
  17. * @param {String} contentDir path to directory where content hash files are cached
  18. * @param {String} filePath the pdf file to scan for streams
  19. */
  20. async function update(contentDir, filePath) {
  21. const stream = fs.createReadStream(filePath)
  22. const extractor = new PdfStreamsExtractor()
  23. const ranges = []
  24. const newRanges = []
  25. const seenHashes = new Set()
  26. // keep track of hashes expire old ones when they reach a generation > N.
  27. const tracker = await HashFileTracker.from(contentDir)
  28. for await (const chunk of stream) {
  29. const pdfStreams = extractor.consume(chunk)
  30. for (const pdfStream of pdfStreams) {
  31. if (pdfStream.end - pdfStream.start < MIN_CHUNK_SIZE) continue
  32. const hash = pdfStreamHash(pdfStream.buffers)
  33. const range = { start: pdfStream.start, end: pdfStream.end, hash }
  34. ranges.push(range)
  35. // Optimization: Skip writing of duplicate streams.
  36. if (seenHashes.has(hash)) continue
  37. seenHashes.add(hash)
  38. if (await writePdfStream(contentDir, hash, pdfStream.buffers)) {
  39. newRanges.push(range)
  40. }
  41. }
  42. }
  43. tracker.update(ranges, newRanges)
  44. const reclaimedSpace = await tracker.deleteStaleHashes(5)
  45. await tracker.flush()
  46. return [ranges, newRanges, reclaimedSpace]
  47. }
  48. function getStatePath(contentDir) {
  49. return Path.join(contentDir, '.state.v0.json')
  50. }
  51. class HashFileTracker {
  52. constructor(contentDir, { hashAge = [], hashSize = [] }) {
  53. this.contentDir = contentDir
  54. this.hashAge = new Map(hashAge)
  55. this.hashSize = new Map(hashSize)
  56. }
  57. static async from(contentDir) {
  58. const statePath = getStatePath(contentDir)
  59. let state = {}
  60. try {
  61. const blob = await fs.promises.readFile(statePath)
  62. state = JSON.parse(blob)
  63. } catch (e) {}
  64. return new HashFileTracker(contentDir, state)
  65. }
  66. update(ranges, newRanges) {
  67. for (const [hash, age] of this.hashAge) {
  68. this.hashAge.set(hash, age + 1)
  69. }
  70. for (const range of ranges) {
  71. this.hashAge.set(range.hash, 0)
  72. }
  73. for (const range of newRanges) {
  74. this.hashSize.set(range.hash, range.end - range.start)
  75. }
  76. return this
  77. }
  78. findStale(maxAge) {
  79. const stale = []
  80. for (const [hash, age] of this.hashAge) {
  81. if (age > maxAge) {
  82. stale.push(hash)
  83. }
  84. }
  85. return stale
  86. }
  87. async flush() {
  88. const statePath = getStatePath(this.contentDir)
  89. const blob = JSON.stringify({
  90. hashAge: Array.from(this.hashAge.entries()),
  91. hashSize: Array.from(this.hashSize.entries())
  92. })
  93. const atomicWrite = statePath + '~'
  94. try {
  95. await fs.promises.writeFile(atomicWrite, blob)
  96. } catch (err) {
  97. try {
  98. await fs.promises.unlink(atomicWrite)
  99. } catch (e) {}
  100. throw err
  101. }
  102. try {
  103. await fs.promises.rename(atomicWrite, statePath)
  104. } catch (err) {
  105. try {
  106. await fs.promises.unlink(atomicWrite)
  107. } catch (e) {}
  108. throw err
  109. }
  110. }
  111. async deleteStaleHashes(n) {
  112. // delete any hash file older than N generations
  113. const hashes = this.findStale(n)
  114. let reclaimedSpace = 0
  115. if (hashes.length === 0) {
  116. return reclaimedSpace
  117. }
  118. await promiseMapWithLimit(10, hashes, async (hash) => {
  119. await fs.promises.unlink(Path.join(this.contentDir, hash))
  120. this.hashAge.delete(hash)
  121. reclaimedSpace += this.hashSize.get(hash)
  122. this.hashSize.delete(hash)
  123. })
  124. return reclaimedSpace
  125. }
  126. }
  127. class PdfStreamsExtractor {
  128. constructor() {
  129. this.fileIndex = 0
  130. this.inStream = false
  131. this.streamStartIndex = 0
  132. this.buffers = []
  133. this.lastChunk = Buffer.alloc(0)
  134. }
  135. consume(chunk) {
  136. let chunkIndex = 0
  137. const pdfStreams = []
  138. chunk = Buffer.concat([this.lastChunk, chunk])
  139. while (true) {
  140. if (!this.inStream) {
  141. // Not in a stream, look for stream start
  142. const index = chunk.indexOf(START_OF_STREAM_MARKER, chunkIndex)
  143. if (index === -1) {
  144. // Couldn't find stream start
  145. break
  146. }
  147. // Found stream start, start a stream
  148. this.inStream = true
  149. this.streamStartIndex = this.fileIndex + index
  150. chunkIndex = index
  151. } else {
  152. // In a stream, look for stream end
  153. const index = chunk.indexOf(END_OF_STREAM_MARKER, chunkIndex)
  154. if (index === -1) {
  155. break
  156. }
  157. // add "endstream" part
  158. const endIndex = index + END_OF_STREAM_MARKER_LENGTH
  159. this.buffers.push(chunk.slice(chunkIndex, endIndex))
  160. pdfStreams.push({
  161. start: this.streamStartIndex,
  162. end: this.fileIndex + endIndex,
  163. buffers: this.buffers
  164. })
  165. this.inStream = false
  166. this.buffers = []
  167. chunkIndex = endIndex
  168. }
  169. }
  170. const remaining = chunk.length - chunkIndex
  171. const nextMarkerLength = this.inStream
  172. ? END_OF_STREAM_MARKER_LENGTH
  173. : START_OF_STREAM_MARKER_LENGTH
  174. if (remaining > nextMarkerLength) {
  175. const retainMarkerSection = chunk.length - nextMarkerLength
  176. if (this.inStream) {
  177. this.buffers.push(chunk.slice(chunkIndex, retainMarkerSection))
  178. }
  179. this.lastChunk = chunk.slice(retainMarkerSection)
  180. this.fileIndex += retainMarkerSection
  181. } else {
  182. this.lastChunk = chunk.slice(chunkIndex)
  183. this.fileIndex += chunkIndex
  184. }
  185. return pdfStreams
  186. }
  187. }
  188. function pdfStreamHash(buffers) {
  189. const hash = crypto.createHash('sha256')
  190. for (const buffer of buffers) {
  191. hash.update(buffer)
  192. }
  193. return hash.digest('hex')
  194. }
  195. async function writePdfStream(dir, hash, buffers) {
  196. const filename = Path.join(dir, hash)
  197. try {
  198. await fs.promises.stat(filename)
  199. // The file exists. Do not rewrite the content.
  200. // It would change the modified-time of the file and hence invalidate the
  201. // ETags used for client side caching via browser internals.
  202. return false
  203. } catch (e) {}
  204. const atomicWriteFilename = filename + '~'
  205. const file = await fs.promises.open(atomicWriteFilename, 'w')
  206. if (Settings.enablePdfCachingDark) {
  207. // Write an empty file in dark mode.
  208. buffers = []
  209. }
  210. try {
  211. try {
  212. for (const buffer of buffers) {
  213. await file.write(buffer)
  214. }
  215. } finally {
  216. await file.close()
  217. }
  218. await fs.promises.rename(atomicWriteFilename, filename)
  219. } catch (err) {
  220. try {
  221. await fs.promises.unlink(atomicWriteFilename)
  222. } catch (_) {
  223. throw err
  224. }
  225. }
  226. return true
  227. }
  228. function promiseMapWithLimit(concurrency, array, fn) {
  229. const limit = pLimit(concurrency)
  230. return Promise.all(array.map((x) => limit(() => fn(x))))
  231. }
  232. module.exports = {
  233. HASH_REGEX: /^[0-9a-f]{64}$/,
  234. update: callbackify(update)
  235. }