FileHandler.js 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. const Settings = require('@overleaf/settings')
  2. const { callbackify } = require('util')
  3. const fs = require('fs')
  4. let PersistorManager = require('./PersistorManager')
  5. const LocalFileWriter = require('./LocalFileWriter')
  6. const FileConverter = require('./FileConverter')
  7. const KeyBuilder = require('./KeyBuilder')
  8. const ImageOptimiser = require('./ImageOptimiser')
  9. const { ConversionError, InvalidParametersError } = require('./Errors')
  10. const metrics = require('@overleaf/metrics')
  11. module.exports = {
  12. copyObject: callbackify(copyObject),
  13. insertFile: callbackify(insertFile),
  14. deleteFile: callbackify(deleteFile),
  15. deleteProject: callbackify(deleteProject),
  16. getFile: callbackify(getFile),
  17. getRedirectUrl: callbackify(getRedirectUrl),
  18. getFileSize: callbackify(getFileSize),
  19. getDirectorySize: callbackify(getDirectorySize),
  20. promises: {
  21. copyObject,
  22. getFile,
  23. getRedirectUrl,
  24. insertFile,
  25. deleteFile,
  26. deleteProject,
  27. getFileSize,
  28. getDirectorySize,
  29. },
  30. }
  31. if (process.env.NODE_ENV === 'test') {
  32. module.exports._TESTONLYSwapPersistorManager = _PersistorManager => {
  33. PersistorManager = _PersistorManager
  34. }
  35. }
  36. async function copyObject(bucket, sourceKey, destinationKey) {
  37. await PersistorManager.copyObject(bucket, sourceKey, destinationKey)
  38. }
  39. async function insertFile(bucket, key, stream) {
  40. const convertedKey = KeyBuilder.getConvertedFolderKey(key)
  41. if (!convertedKey.match(/^[0-9a-f]{24}\/([0-9a-f]{24}|v\/[0-9]+\/[a-z]+)/i)) {
  42. throw new InvalidParametersError('key does not match validation regex', {
  43. bucket,
  44. key,
  45. convertedKey,
  46. })
  47. }
  48. await PersistorManager.sendStream(bucket, key, stream)
  49. }
  50. async function deleteFile(bucket, key) {
  51. const convertedKey = KeyBuilder.getConvertedFolderKey(key)
  52. if (!convertedKey.match(/^[0-9a-f]{24}\/([0-9a-f]{24}|v\/[0-9]+\/[a-z]+)/i)) {
  53. throw new InvalidParametersError('key does not match validation regex', {
  54. bucket,
  55. key,
  56. convertedKey,
  57. })
  58. }
  59. const jobs = [PersistorManager.deleteObject(bucket, key)]
  60. if (
  61. Settings.enableConversions &&
  62. bucket === Settings.filestore.stores.template_files
  63. ) {
  64. jobs.push(PersistorManager.deleteDirectory(bucket, convertedKey))
  65. }
  66. await Promise.all(jobs)
  67. }
  68. async function deleteProject(bucket, key) {
  69. if (!key.match(/^[0-9a-f]{24}\//i)) {
  70. throw new InvalidParametersError('key does not match validation regex', {
  71. bucket,
  72. key,
  73. })
  74. }
  75. await PersistorManager.deleteDirectory(bucket, key)
  76. }
  77. async function getFile(bucket, key, opts) {
  78. opts = opts || {}
  79. if (!opts.format && !opts.style) {
  80. return await PersistorManager.getObjectStream(bucket, key, opts)
  81. } else {
  82. return await _getConvertedFile(bucket, key, opts)
  83. }
  84. }
  85. let ACTIVE_SIGNED_URL_CALLS = 0
  86. async function getRedirectUrl(bucket, key, opts) {
  87. // if we're doing anything unusual with options, or the request isn't for
  88. // one of the default buckets, return null so that we proxy the file
  89. opts = opts || {}
  90. if (
  91. !opts.start &&
  92. !opts.end &&
  93. !opts.format &&
  94. !opts.style &&
  95. Object.values(Settings.filestore.stores).includes(bucket) &&
  96. Settings.filestore.allowRedirects
  97. ) {
  98. // record the number of in-flight calls to generate signed URLs
  99. metrics.gauge('active_signed_url_calls', ++ACTIVE_SIGNED_URL_CALLS, {
  100. path: bucket,
  101. })
  102. try {
  103. const timer = new metrics.Timer('signed_url_call_time', {
  104. path: bucket,
  105. })
  106. const redirectUrl = await PersistorManager.getRedirectUrl(bucket, key)
  107. timer.done()
  108. return redirectUrl
  109. } finally {
  110. metrics.gauge('active_signed_url_calls', --ACTIVE_SIGNED_URL_CALLS, {
  111. path: bucket,
  112. })
  113. }
  114. }
  115. return null
  116. }
  117. async function getFileSize(bucket, key) {
  118. return await PersistorManager.getObjectSize(bucket, key)
  119. }
  120. async function getDirectorySize(bucket, projectId) {
  121. return await PersistorManager.directorySize(bucket, projectId)
  122. }
  123. async function _getConvertedFile(bucket, key, opts) {
  124. const convertedKey = KeyBuilder.addCachingToKey(key, opts)
  125. const exists = await PersistorManager.checkIfObjectExists(
  126. bucket,
  127. convertedKey
  128. )
  129. if (exists) {
  130. return await PersistorManager.getObjectStream(bucket, convertedKey, opts)
  131. } else {
  132. return await _getConvertedFileAndCache(bucket, key, convertedKey, opts)
  133. }
  134. }
  135. async function _getConvertedFileAndCache(bucket, key, convertedKey, opts) {
  136. let convertedFsPath
  137. try {
  138. convertedFsPath = await _convertFile(bucket, key, opts)
  139. await ImageOptimiser.promises.compressPng(convertedFsPath)
  140. await PersistorManager.sendFile(bucket, convertedKey, convertedFsPath)
  141. } catch (err) {
  142. LocalFileWriter.deleteFile(convertedFsPath, () => {})
  143. throw new ConversionError(
  144. 'failed to convert file',
  145. { opts, bucket, key, convertedKey },
  146. err
  147. )
  148. }
  149. // Send back the converted file from the local copy to avoid problems
  150. // with the file not being present in S3 yet. As described in the
  151. // documentation below, we have already made a 'HEAD' request in
  152. // checkIfFileExists so we only have "eventual consistency" if we try
  153. // to stream it from S3 here. This was a cause of many 403 errors.
  154. //
  155. // "Amazon S3 provides read-after-write consistency for PUTS of new
  156. // objects in your S3 bucket in all regions with one caveat. The
  157. // caveat is that if you make a HEAD or GET request to the key name
  158. // (to find if the object exists) before creating the object, Amazon
  159. // S3 provides eventual consistency for read-after-write.""
  160. // https://docs.aws.amazon.com/AmazonS3/latest/dev/Introduction.html#ConsistencyModel
  161. const readStream = fs.createReadStream(convertedFsPath)
  162. readStream.on('error', function () {
  163. LocalFileWriter.deleteFile(convertedFsPath, function () {})
  164. })
  165. readStream.on('end', function () {
  166. LocalFileWriter.deleteFile(convertedFsPath, function () {})
  167. })
  168. return readStream
  169. }
  170. async function _convertFile(bucket, originalKey, opts) {
  171. let originalFsPath
  172. try {
  173. originalFsPath = await _writeFileToDisk(bucket, originalKey, opts)
  174. } catch (err) {
  175. throw new ConversionError(
  176. 'unable to write file to disk',
  177. { bucket, originalKey, opts },
  178. err
  179. )
  180. }
  181. let promise
  182. if (opts.format) {
  183. promise = FileConverter.promises.convert(originalFsPath, opts.format)
  184. } else if (opts.style === 'thumbnail') {
  185. promise = FileConverter.promises.thumbnail(originalFsPath)
  186. } else if (opts.style === 'preview') {
  187. promise = FileConverter.promises.preview(originalFsPath)
  188. } else {
  189. throw new ConversionError('invalid file conversion options', {
  190. bucket,
  191. originalKey,
  192. opts,
  193. })
  194. }
  195. let destPath
  196. try {
  197. destPath = await promise
  198. } catch (err) {
  199. throw new ConversionError(
  200. 'error converting file',
  201. { bucket, originalKey, opts },
  202. err
  203. )
  204. }
  205. LocalFileWriter.deleteFile(originalFsPath, function () {})
  206. return destPath
  207. }
  208. async function _writeFileToDisk(bucket, key, opts) {
  209. const fileStream = await PersistorManager.getObjectStream(bucket, key, opts)
  210. return await LocalFileWriter.promises.writeStream(fileStream, key)
  211. }