FileHandler.js 5.8 KB

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