| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178 |
- const Crypto = require('node:crypto')
- const Stream = require('node:stream')
- const { pipeline } = require('node:stream/promises')
- const Logger = require('@overleaf/logger')
- const Metrics = require('@overleaf/metrics')
- const { WriteError, NotFoundError, AlreadyWrittenError } = require('./Errors')
- const _128KiB = 128 * 1024
- const TIMING_BUCKETS = [
- 0, 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 50000,
- ]
- const SIZE_BUCKETS = [
- 0,
- 1_000,
- 10_000,
- 100_000,
- _128KiB,
- 1_000_000,
- 10_000_000,
- 50_000_000,
- 100_000_000,
- ]
- /**
- * Observes data that passes through and optionally computes hash for content.
- */
- class ObserverStream extends Stream.Transform {
- /**
- * @param {Object} opts
- * @param {string} opts.metric prefix for metrics
- * @param {string} opts.bucket name of source/target bucket
- * @param {string} [opts.hash] optional hash algorithm, e.g. 'md5'
- */
- constructor(opts) {
- super({ autoDestroy: true })
- const { metric, bucket, hash = '' } = opts
- this.bytes = 0
- this.start = performance.now()
- if (hash) {
- this.hash = Crypto.createHash(hash)
- }
- const onEnd = status => {
- const size = this.bytes < _128KiB ? 'lt-128KiB' : 'gte-128KiB'
- const labels = { size, bucket, status }
- // Keep this counter metric to allow rendering long-term charts.
- Metrics.count(metric, this.bytes, 1, labels)
- Metrics.inc(`${metric}.hit`, 1, labels)
- if (status === 'error') return
- // The below metrics are only relevant for successfully fetched objects.
- Metrics.histogram(`${metric}.size`, this.bytes, SIZE_BUCKETS, {
- status,
- bucket,
- })
- if (this.firstByteAfterMs) {
- Metrics.histogram(
- `${metric}.latency.first-byte`,
- this.firstByteAfterMs,
- TIMING_BUCKETS,
- labels
- )
- }
- Metrics.histogram(
- `${metric}.latency`,
- this.#getMsSinceStart(),
- TIMING_BUCKETS,
- labels
- )
- }
- this.once('error', () => onEnd('error'))
- this.once('end', () => onEnd('success'))
- }
- #getMsSinceStart() {
- return performance.now() - this.start
- }
- _transform(chunk, encoding, done) {
- if (this.bytes === 0) {
- this.firstByteAfterMs = this.#getMsSinceStart()
- }
- if (this.hash) {
- this.hash.update(chunk)
- }
- this.bytes += chunk.length
- this.push(chunk)
- done()
- }
- getHash() {
- return this.hash && this.hash.digest('hex')
- }
- }
- module.exports = {
- ObserverStream,
- calculateStreamMd5,
- verifyMd5,
- wrapError,
- hexToBase64,
- base64ToHex,
- }
- // returns a promise which resolves with the md5 hash of the stream
- // - consumes the stream
- async function calculateStreamMd5(stream) {
- const hash = Crypto.createHash('md5')
- hash.setEncoding('hex')
- await pipeline(stream, hash)
- return hash.read()
- }
- // verifies the md5 hash of a file against the supplied md5 or the one stored in
- // storage if not supplied - deletes the new file if the md5 does not match and
- // throws an error
- async function verifyMd5(persistor, bucket, key, sourceMd5, destMd5 = null) {
- if (!destMd5) {
- destMd5 = await persistor.getObjectMd5Hash(bucket, key)
- }
- if (sourceMd5 !== destMd5) {
- try {
- await persistor.deleteObject(bucket, key)
- } catch (err) {
- Logger.warn(err, 'error deleting file for invalid upload')
- }
- throw new WriteError('source and destination hashes do not match', {
- sourceMd5,
- destMd5,
- bucket,
- key,
- })
- }
- }
- function wrapError(error, message, params, ErrorType) {
- params = {
- ...params,
- cause: error,
- }
- // aws-sdk v3 renames `code` to `Code`, but it's not always present, so we
- // add a fallback to `name` for compatibility.
- const errorCode = error.code || error.Code || error.name
- if (
- error instanceof NotFoundError ||
- ['NoSuchKey', 'NotFound', 404, 'AccessDenied', 'ENOENT'].includes(
- errorCode
- ) ||
- (error.response && error.response.statusCode === 404)
- ) {
- return new NotFoundError('no such file', params, error)
- } else if (
- params.ifNoneMatch === '*' &&
- (errorCode === 'PreconditionFailed' ||
- error.response?.statusCode === 412 ||
- error instanceof AlreadyWrittenError)
- ) {
- return new AlreadyWrittenError(message, params, error)
- } else {
- return new ErrorType(message, params, error)
- }
- }
- function base64ToHex(base64) {
- return Buffer.from(base64, 'base64').toString('hex')
- }
- function hexToBase64(hex) {
- return Buffer.from(hex, 'hex').toString('base64')
- }
|