PersistorHelper.js 5.0 KB

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