PersistorHelper.js 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. const Crypto = require('crypto')
  2. const Stream = require('stream')
  3. const { pipeline } = require('stream/promises')
  4. const Logger = require('@overleaf/logger')
  5. const { WriteError, ReadError, NotFoundError } = require('./Errors')
  6. // Observes data that passes through and computes some metadata for it
  7. // - specifically, it computes the number of bytes transferred, and optionally
  8. // computes a cryptographic hash based on the 'hash' option. e.g., pass
  9. // { hash: 'md5' } to compute the md5 hash of the stream
  10. // - if 'metric' is supplied as an option, this metric will be incremented by
  11. // the number of bytes transferred
  12. class ObserverStream extends Stream.Transform {
  13. constructor(options) {
  14. super({ autoDestroy: true, ...options })
  15. this.bytes = 0
  16. if (options.hash) {
  17. this.hash = Crypto.createHash(options.hash)
  18. }
  19. if (options.metric && options.Metrics) {
  20. const onEnd = () => {
  21. options.Metrics.count(options.metric, this.bytes)
  22. }
  23. this.once('error', onEnd)
  24. this.once('end', onEnd)
  25. }
  26. }
  27. _transform(chunk, encoding, done) {
  28. if (this.hash) {
  29. this.hash.update(chunk)
  30. }
  31. this.bytes += chunk.length
  32. this.push(chunk)
  33. done()
  34. }
  35. getHash() {
  36. return this.hash && this.hash.digest('hex')
  37. }
  38. }
  39. module.exports = {
  40. ObserverStream,
  41. calculateStreamMd5,
  42. verifyMd5,
  43. getReadyPipeline,
  44. wrapError,
  45. hexToBase64,
  46. base64ToHex,
  47. }
  48. // returns a promise which resolves with the md5 hash of the stream
  49. // - consumes the stream
  50. async function calculateStreamMd5(stream) {
  51. const hash = Crypto.createHash('md5')
  52. hash.setEncoding('hex')
  53. await pipeline(stream, hash)
  54. return hash.read()
  55. }
  56. // verifies the md5 hash of a file against the supplied md5 or the one stored in
  57. // storage if not supplied - deletes the new file if the md5 does not match and
  58. // throws an error
  59. async function verifyMd5(persistor, bucket, key, sourceMd5, destMd5 = null) {
  60. if (!destMd5) {
  61. destMd5 = await persistor.getObjectMd5Hash(bucket, key)
  62. }
  63. if (sourceMd5 !== destMd5) {
  64. try {
  65. await persistor.deleteObject(bucket, key)
  66. } catch (err) {
  67. Logger.warn(err, 'error deleting file for invalid upload')
  68. }
  69. throw new WriteError('source and destination hashes do not match', {
  70. sourceMd5,
  71. destMd5,
  72. bucket,
  73. key,
  74. })
  75. }
  76. }
  77. // resolves when a stream is 'readable', or rejects if the stream throws an error
  78. // before that happens - this lets us handle protocol-level errors before trying
  79. // to read them
  80. function getReadyPipeline(...streams) {
  81. return new Promise((resolve, reject) => {
  82. const lastStream = streams.slice(-1)[0]
  83. // in case of error or stream close, we must ensure that we drain the
  84. // previous stream so that it can clean up its socket (if it has one)
  85. const drainPreviousStream = function (previousStream) {
  86. // this stream is no longer reliable, so don't pipe anything more into it
  87. previousStream.unpipe(this)
  88. previousStream.resume()
  89. }
  90. // handler to resolve when either:
  91. // - an error happens, or
  92. // - the last stream in the chain is readable
  93. // for example, in the case of a 4xx error an error will occur and the
  94. // streams will not become readable
  95. const handler = function (err) {
  96. // remove handler from all streams because we don't want to do this on
  97. // later errors
  98. lastStream.removeListener('readable', handler)
  99. for (const stream of streams) {
  100. stream.removeListener('error', handler)
  101. }
  102. // return control to the caller
  103. if (err) {
  104. reject(
  105. wrapError(err, 'error before stream became ready', {}, ReadError)
  106. )
  107. } else {
  108. resolve(lastStream)
  109. }
  110. }
  111. // ensure the handler fires when the last strem becomes readable
  112. lastStream.on('readable', handler)
  113. for (const stream of streams) {
  114. // when a stream receives a pipe, set up the drain handler to drain the
  115. // connection if an error occurs or the stream is closed
  116. stream.on('pipe', previousStream => {
  117. stream.on('error', x => {
  118. drainPreviousStream(previousStream)
  119. })
  120. stream.on('close', () => {
  121. drainPreviousStream(previousStream)
  122. })
  123. })
  124. // add the handler function to resolve this method on error if we can't
  125. // set up the pipeline
  126. stream.on('error', handler)
  127. }
  128. // begin the pipeline
  129. for (let index = 0; index < streams.length - 1; index++) {
  130. streams[index].pipe(streams[index + 1])
  131. }
  132. })
  133. }
  134. function wrapError(error, message, params, ErrorType) {
  135. if (
  136. error instanceof NotFoundError ||
  137. ['NoSuchKey', 'NotFound', 404, 'AccessDenied', 'ENOENT'].includes(
  138. error.code
  139. ) ||
  140. (error.response && error.response.statusCode === 404)
  141. ) {
  142. return new NotFoundError('no such file', params, error)
  143. } else {
  144. return new ErrorType(message, params, error)
  145. }
  146. }
  147. function base64ToHex(base64) {
  148. return Buffer.from(base64, 'base64').toString('hex')
  149. }
  150. function hexToBase64(hex) {
  151. return Buffer.from(hex, 'hex').toString('base64')
  152. }