PersistorHelper.js 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. const Crypto = require('crypto')
  2. const Stream = require('stream')
  3. const { pipeline } = require('stream/promises')
  4. const Logger = require('@overleaf/logger')
  5. const Metrics = require('@overleaf/metrics')
  6. const { WriteError, NotFoundError } = 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 {string} metric prefix for metrics
  28. * @param {string} bucket name of source/target bucket
  29. * @param {string} hash optional hash algorithm, e.g. 'md5'
  30. */
  31. constructor({ metric, bucket, hash = '' }) {
  32. super({ autoDestroy: true })
  33. this.bytes = 0
  34. this.start = performance.now()
  35. if (hash) {
  36. this.hash = Crypto.createHash(hash)
  37. }
  38. const onEnd = status => {
  39. const size = this.bytes < _128KiB ? 'lt-128KiB' : 'gte-128KiB'
  40. const labels = { size, bucket, status }
  41. // Keep this counter metric to allow rendering long-term charts.
  42. Metrics.count(metric, this.bytes, 1, labels)
  43. Metrics.inc(`${metric}.hit`, 1, labels)
  44. if (status === 'error') return
  45. // The below metrics are only relevant for successfully fetched objects.
  46. Metrics.histogram(`${metric}.size`, this.bytes, SIZE_BUCKETS, {
  47. status,
  48. bucket,
  49. })
  50. if (this.firstByteAfterMs) {
  51. Metrics.histogram(
  52. `${metric}.latency.first-byte`,
  53. this.firstByteAfterMs,
  54. TIMING_BUCKETS,
  55. labels
  56. )
  57. }
  58. Metrics.histogram(
  59. `${metric}.latency`,
  60. this.#getMsSinceStart(),
  61. TIMING_BUCKETS,
  62. labels
  63. )
  64. }
  65. this.once('error', () => onEnd('error'))
  66. this.once('end', () => onEnd('success'))
  67. }
  68. #getMsSinceStart() {
  69. return performance.now() - this.start
  70. }
  71. _transform(chunk, encoding, done) {
  72. if (this.bytes === 0) {
  73. this.firstByteAfterMs = this.#getMsSinceStart()
  74. }
  75. if (this.hash) {
  76. this.hash.update(chunk)
  77. }
  78. this.bytes += chunk.length
  79. this.push(chunk)
  80. done()
  81. }
  82. getHash() {
  83. return this.hash && this.hash.digest('hex')
  84. }
  85. }
  86. module.exports = {
  87. ObserverStream,
  88. calculateStreamMd5,
  89. verifyMd5,
  90. wrapError,
  91. hexToBase64,
  92. base64ToHex,
  93. }
  94. // returns a promise which resolves with the md5 hash of the stream
  95. // - consumes the stream
  96. async function calculateStreamMd5(stream) {
  97. const hash = Crypto.createHash('md5')
  98. hash.setEncoding('hex')
  99. await pipeline(stream, hash)
  100. return hash.read()
  101. }
  102. // verifies the md5 hash of a file against the supplied md5 or the one stored in
  103. // storage if not supplied - deletes the new file if the md5 does not match and
  104. // throws an error
  105. async function verifyMd5(persistor, bucket, key, sourceMd5, destMd5 = null) {
  106. if (!destMd5) {
  107. destMd5 = await persistor.getObjectMd5Hash(bucket, key)
  108. }
  109. if (sourceMd5 !== destMd5) {
  110. try {
  111. await persistor.deleteObject(bucket, key)
  112. } catch (err) {
  113. Logger.warn(err, 'error deleting file for invalid upload')
  114. }
  115. throw new WriteError('source and destination hashes do not match', {
  116. sourceMd5,
  117. destMd5,
  118. bucket,
  119. key,
  120. })
  121. }
  122. }
  123. function wrapError(error, message, params, ErrorType) {
  124. if (
  125. error instanceof NotFoundError ||
  126. ['NoSuchKey', 'NotFound', 404, 'AccessDenied', 'ENOENT'].includes(
  127. error.code
  128. ) ||
  129. (error.response && error.response.statusCode === 404)
  130. ) {
  131. return new NotFoundError('no such file', params, error)
  132. } else {
  133. return new ErrorType(message, params, error)
  134. }
  135. }
  136. function base64ToHex(base64) {
  137. return Buffer.from(base64, 'base64').toString('hex')
  138. }
  139. function hexToBase64(hex) {
  140. return Buffer.from(hex, 'hex').toString('base64')
  141. }