PersistorHelper.js 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. const Crypto = require('node:crypto')
  2. const Stream = require('node:stream')
  3. const { pipeline } = require('node:stream/promises')
  4. const Logger = require('@overleaf/logger')
  5. const Metrics = require('@overleaf/metrics')
  6. const { WriteError, NotFoundError, AlreadyWrittenError } = require('./Errors')
  7. const _128KiB = 128 * 1024
  8. const TIMING_BUCKETS = [
  9. 0, 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 50000,
  10. ]
  11. const SIZE_BUCKETS = [
  12. 0,
  13. 1_000,
  14. 10_000,
  15. 100_000,
  16. _128KiB,
  17. 1_000_000,
  18. 10_000_000,
  19. 50_000_000,
  20. 100_000_000,
  21. ]
  22. /**
  23. * Observes data that passes through and optionally computes hash for content.
  24. */
  25. class ObserverStream extends Stream.Transform {
  26. /**
  27. * @param {Object} opts
  28. * @param {string} opts.metric prefix for metrics
  29. * @param {string} opts.bucket name of source/target bucket
  30. * @param {string} [opts.hash] optional hash algorithm, e.g. 'md5'
  31. */
  32. constructor(opts) {
  33. super({ autoDestroy: true })
  34. const { metric, bucket, hash = '' } = opts
  35. this.bytes = 0
  36. this.start = performance.now()
  37. if (hash) {
  38. this.hash = Crypto.createHash(hash)
  39. }
  40. const onEnd = status => {
  41. const size = this.bytes < _128KiB ? 'lt-128KiB' : 'gte-128KiB'
  42. const labels = { size, bucket, status }
  43. // Keep this counter metric to allow rendering long-term charts.
  44. Metrics.count(metric, this.bytes, 1, labels)
  45. Metrics.inc(`${metric}.hit`, 1, labels)
  46. if (status === 'error') return
  47. // The below metrics are only relevant for successfully fetched objects.
  48. Metrics.histogram(`${metric}.size`, this.bytes, SIZE_BUCKETS, {
  49. status,
  50. bucket,
  51. })
  52. if (this.firstByteAfterMs) {
  53. Metrics.histogram(
  54. `${metric}.latency.first-byte`,
  55. this.firstByteAfterMs,
  56. TIMING_BUCKETS,
  57. labels
  58. )
  59. }
  60. Metrics.histogram(
  61. `${metric}.latency`,
  62. this.#getMsSinceStart(),
  63. TIMING_BUCKETS,
  64. labels
  65. )
  66. }
  67. this.once('error', () => onEnd('error'))
  68. this.once('end', () => onEnd('success'))
  69. }
  70. #getMsSinceStart() {
  71. return performance.now() - this.start
  72. }
  73. _transform(chunk, encoding, done) {
  74. if (this.bytes === 0) {
  75. this.firstByteAfterMs = this.#getMsSinceStart()
  76. }
  77. if (this.hash) {
  78. this.hash.update(chunk)
  79. }
  80. this.bytes += chunk.length
  81. this.push(chunk)
  82. done()
  83. }
  84. getHash() {
  85. return this.hash && this.hash.digest('hex')
  86. }
  87. }
  88. module.exports = {
  89. ObserverStream,
  90. calculateStreamMd5,
  91. verifyMd5,
  92. wrapError,
  93. hexToBase64,
  94. base64ToHex,
  95. }
  96. // returns a promise which resolves with the md5 hash of the stream
  97. // - consumes the stream
  98. async function calculateStreamMd5(stream) {
  99. const hash = Crypto.createHash('md5')
  100. hash.setEncoding('hex')
  101. await pipeline(stream, hash)
  102. return hash.read()
  103. }
  104. // verifies the md5 hash of a file against the supplied md5 or the one stored in
  105. // storage if not supplied - deletes the new file if the md5 does not match and
  106. // throws an error
  107. async function verifyMd5(persistor, bucket, key, sourceMd5, destMd5 = null) {
  108. if (!destMd5) {
  109. destMd5 = await persistor.getObjectMd5Hash(bucket, key)
  110. }
  111. if (sourceMd5 !== destMd5) {
  112. try {
  113. await persistor.deleteObject(bucket, key)
  114. } catch (err) {
  115. Logger.warn(err, 'error deleting file for invalid upload')
  116. }
  117. throw new WriteError('source and destination hashes do not match', {
  118. sourceMd5,
  119. destMd5,
  120. bucket,
  121. key,
  122. })
  123. }
  124. }
  125. function wrapError(error, message, params, ErrorType) {
  126. params = {
  127. ...params,
  128. cause: error,
  129. }
  130. // aws-sdk v3 renames `code` to `Code`, but it's not always present, so we
  131. // add a fallback to `name` for compatibility.
  132. const errorCode = error.code || error.Code || error.name
  133. if (
  134. error instanceof NotFoundError ||
  135. ['NoSuchKey', 'NotFound', 404, 'AccessDenied', 'ENOENT'].includes(
  136. errorCode
  137. ) ||
  138. (error.response && error.response.statusCode === 404)
  139. ) {
  140. return new NotFoundError('no such file', params, error)
  141. } else if (
  142. params.ifNoneMatch === '*' &&
  143. (errorCode === 'PreconditionFailed' ||
  144. error.response?.statusCode === 412 ||
  145. error instanceof AlreadyWrittenError)
  146. ) {
  147. return new AlreadyWrittenError(message, params, error)
  148. } else {
  149. return new ErrorType(message, params, error)
  150. }
  151. }
  152. function base64ToHex(base64) {
  153. return Buffer.from(base64, 'base64').toString('hex')
  154. }
  155. function hexToBase64(hex) {
  156. return Buffer.from(hex, 'hex').toString('base64')
  157. }