FileHandler.js 6.7 KB

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