|
|
@@ -5,15 +5,13 @@ https.globalAgent.maxSockets = 300
|
|
|
|
|
|
const settings = require('settings-sharelatex')
|
|
|
const metrics = require('metrics-sharelatex')
|
|
|
-const logger = require('logger-sharelatex')
|
|
|
|
|
|
-const meter = require('stream-meter')
|
|
|
-const Stream = require('stream')
|
|
|
-const crypto = require('crypto')
|
|
|
+const PersistorHelper = require('./PersistorHelper')
|
|
|
+
|
|
|
const fs = require('fs')
|
|
|
const S3 = require('aws-sdk/clients/s3')
|
|
|
const { URL } = require('url')
|
|
|
-const { callbackify, promisify } = require('util')
|
|
|
+const { callbackify } = require('util')
|
|
|
const {
|
|
|
WriteError,
|
|
|
ReadError,
|
|
|
@@ -21,7 +19,7 @@ const {
|
|
|
SettingsError
|
|
|
} = require('./Errors')
|
|
|
|
|
|
-module.exports = {
|
|
|
+const S3Persistor = {
|
|
|
sendFile: callbackify(sendFile),
|
|
|
sendStream: callbackify(sendStream),
|
|
|
getFileStream: callbackify(getFileStream),
|
|
|
@@ -46,7 +44,7 @@ module.exports = {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-const pipeline = promisify(Stream.pipeline)
|
|
|
+module.exports = S3Persistor
|
|
|
|
|
|
function hexToBase64(hex) {
|
|
|
return Buffer.from(hex, 'hex').toString('base64')
|
|
|
@@ -57,7 +55,7 @@ async function sendFile(bucketName, key, fsPath) {
|
|
|
try {
|
|
|
readStream = fs.createReadStream(fsPath)
|
|
|
} catch (err) {
|
|
|
- throw _wrapError(
|
|
|
+ throw PersistorHelper.wrapError(
|
|
|
err,
|
|
|
'error reading file from disk',
|
|
|
{ bucketName, key, fsPath },
|
|
|
@@ -76,26 +74,16 @@ async function sendStream(bucketName, key, readStream, sourceMd5) {
|
|
|
if (sourceMd5) {
|
|
|
b64Hash = hexToBase64(sourceMd5)
|
|
|
} else {
|
|
|
- const hash = crypto.createHash('md5')
|
|
|
- hash.setEncoding('hex')
|
|
|
- pipeline(readStream, hash)
|
|
|
- hashPromise = new Promise((resolve, reject) => {
|
|
|
- readStream.on('end', () => {
|
|
|
- hash.end()
|
|
|
- resolve(hash.read())
|
|
|
- })
|
|
|
- readStream.on('error', err => {
|
|
|
- reject(err)
|
|
|
- })
|
|
|
- })
|
|
|
+ hashPromise = PersistorHelper.calculateStreamMd5(readStream)
|
|
|
}
|
|
|
|
|
|
- const meteredStream = meter()
|
|
|
- meteredStream.on('finish', () => {
|
|
|
- metrics.count('s3.egress', meteredStream.bytes)
|
|
|
- })
|
|
|
-
|
|
|
- pipeline(readStream, meteredStream)
|
|
|
+ const meteredStream = PersistorHelper.getMeteredStream(
|
|
|
+ readStream,
|
|
|
+ (_, byteCount) => {
|
|
|
+ // ignore the error parameter and just log the byte count
|
|
|
+ metrics.count('s3.egress', byteCount)
|
|
|
+ }
|
|
|
+ )
|
|
|
|
|
|
// if we have an md5 hash, pass this to S3 to verify the upload
|
|
|
const uploadOptions = {
|
|
|
@@ -112,30 +100,21 @@ async function sendStream(bucketName, key, readStream, sourceMd5) {
|
|
|
.promise()
|
|
|
const destMd5 = _md5FromResponse(response)
|
|
|
|
|
|
- // if we didn't have an md5 hash, compare our computed one with S3's
|
|
|
+ // if we didn't have an md5 hash, we should compare our computed one with S3's
|
|
|
+ // as we couldn't tell S3 about it beforehand
|
|
|
if (hashPromise) {
|
|
|
sourceMd5 = await hashPromise
|
|
|
-
|
|
|
- if (sourceMd5 !== destMd5) {
|
|
|
- try {
|
|
|
- await deleteFile(bucketName, key)
|
|
|
- } catch (err) {
|
|
|
- logger.warn(err, 'error deleting file for invalid upload')
|
|
|
- }
|
|
|
-
|
|
|
- throw new WriteError({
|
|
|
- message: 'source and destination hashes do not match',
|
|
|
- info: {
|
|
|
- sourceMd5,
|
|
|
- destMd5,
|
|
|
- bucketName,
|
|
|
- key
|
|
|
- }
|
|
|
- })
|
|
|
- }
|
|
|
+ // throws on mismatch
|
|
|
+ await PersistorHelper.verifyMd5(
|
|
|
+ S3Persistor,
|
|
|
+ bucketName,
|
|
|
+ key,
|
|
|
+ sourceMd5,
|
|
|
+ destMd5
|
|
|
+ )
|
|
|
}
|
|
|
} catch (err) {
|
|
|
- throw _wrapError(
|
|
|
+ throw PersistorHelper.wrapError(
|
|
|
err,
|
|
|
'upload to S3 failed',
|
|
|
{ bucketName, key },
|
|
|
@@ -155,25 +134,29 @@ async function getFileStream(bucketName, key, opts) {
|
|
|
params.Range = `bytes=${opts.start}-${opts.end}`
|
|
|
}
|
|
|
|
|
|
- return new Promise((resolve, reject) => {
|
|
|
- const stream = _getClientForBucket(bucketName)
|
|
|
- .getObject(params)
|
|
|
- .createReadStream()
|
|
|
-
|
|
|
- const meteredStream = meter()
|
|
|
- meteredStream.on('finish', () => {
|
|
|
- metrics.count('s3.ingress', meteredStream.bytes)
|
|
|
- })
|
|
|
+ const stream = _getClientForBucket(bucketName)
|
|
|
+ .getObject(params)
|
|
|
+ .createReadStream()
|
|
|
|
|
|
- const onStreamReady = function() {
|
|
|
- stream.removeListener('readable', onStreamReady)
|
|
|
- resolve(stream.pipe(meteredStream))
|
|
|
+ const meteredStream = PersistorHelper.getMeteredStream(
|
|
|
+ stream,
|
|
|
+ (_, byteCount) => {
|
|
|
+ // ignore the error parameter and just log the byte count
|
|
|
+ metrics.count('s3.ingress', byteCount)
|
|
|
}
|
|
|
- stream.on('readable', onStreamReady)
|
|
|
- stream.on('error', err => {
|
|
|
- reject(_wrapError(err, 'error reading from S3', params, ReadError))
|
|
|
- })
|
|
|
- })
|
|
|
+ )
|
|
|
+
|
|
|
+ try {
|
|
|
+ await PersistorHelper.waitForStreamReady(stream)
|
|
|
+ return meteredStream
|
|
|
+ } catch (err) {
|
|
|
+ throw PersistorHelper.wrapError(
|
|
|
+ err,
|
|
|
+ 'error reading file from S3',
|
|
|
+ { bucketName, key, opts },
|
|
|
+ ReadError
|
|
|
+ )
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
async function deleteDirectory(bucketName, key) {
|
|
|
@@ -184,7 +167,7 @@ async function deleteDirectory(bucketName, key) {
|
|
|
.listObjects({ Bucket: bucketName, Prefix: key })
|
|
|
.promise()
|
|
|
} catch (err) {
|
|
|
- throw _wrapError(
|
|
|
+ throw PersistorHelper.wrapError(
|
|
|
err,
|
|
|
'failed to list objects in S3',
|
|
|
{ bucketName, key },
|
|
|
@@ -205,7 +188,7 @@ async function deleteDirectory(bucketName, key) {
|
|
|
})
|
|
|
.promise()
|
|
|
} catch (err) {
|
|
|
- throw _wrapError(
|
|
|
+ throw PersistorHelper.wrapError(
|
|
|
err,
|
|
|
'failed to delete objects in S3',
|
|
|
{ bucketName, key },
|
|
|
@@ -222,7 +205,7 @@ async function getFileSize(bucketName, key) {
|
|
|
.promise()
|
|
|
return response.ContentLength
|
|
|
} catch (err) {
|
|
|
- throw _wrapError(
|
|
|
+ throw PersistorHelper.wrapError(
|
|
|
err,
|
|
|
'error getting size of s3 object',
|
|
|
{ bucketName, key },
|
|
|
@@ -239,7 +222,7 @@ async function getFileMd5Hash(bucketName, key) {
|
|
|
const md5 = _md5FromResponse(response)
|
|
|
return md5
|
|
|
} catch (err) {
|
|
|
- throw _wrapError(
|
|
|
+ throw PersistorHelper.wrapError(
|
|
|
err,
|
|
|
'error getting hash of s3 object',
|
|
|
{ bucketName, key },
|
|
|
@@ -255,7 +238,7 @@ async function deleteFile(bucketName, key) {
|
|
|
.promise()
|
|
|
} catch (err) {
|
|
|
// s3 does not give us a NotFoundError here
|
|
|
- throw _wrapError(
|
|
|
+ throw PersistorHelper.wrapError(
|
|
|
err,
|
|
|
'failed to delete file in S3',
|
|
|
{ bucketName, key },
|
|
|
@@ -275,7 +258,12 @@ async function copyFile(bucketName, sourceKey, destKey) {
|
|
|
.copyObject(params)
|
|
|
.promise()
|
|
|
} catch (err) {
|
|
|
- throw _wrapError(err, 'failed to copy file in S3', params, WriteError)
|
|
|
+ throw PersistorHelper.wrapError(
|
|
|
+ err,
|
|
|
+ 'failed to copy file in S3',
|
|
|
+ params,
|
|
|
+ WriteError
|
|
|
+ )
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -287,7 +275,7 @@ async function checkIfFileExists(bucketName, key) {
|
|
|
if (err instanceof NotFoundError) {
|
|
|
return false
|
|
|
}
|
|
|
- throw _wrapError(
|
|
|
+ throw PersistorHelper.wrapError(
|
|
|
err,
|
|
|
'error checking whether S3 object exists',
|
|
|
{ bucketName, key },
|
|
|
@@ -304,7 +292,7 @@ async function directorySize(bucketName, key) {
|
|
|
|
|
|
return response.Contents.reduce((acc, item) => item.Size + acc, 0)
|
|
|
} catch (err) {
|
|
|
- throw _wrapError(
|
|
|
+ throw PersistorHelper.wrapError(
|
|
|
err,
|
|
|
'error getting directory size in S3',
|
|
|
{ bucketName, key },
|
|
|
@@ -313,26 +301,6 @@ async function directorySize(bucketName, key) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-function _wrapError(error, message, params, ErrorType) {
|
|
|
- // the AWS client can return one of 'NoSuchKey', 'NotFound' or 404 (integer)
|
|
|
- // when something is not found, depending on the endpoint
|
|
|
- if (
|
|
|
- ['NoSuchKey', 'NotFound', 404, 'AccessDenied', 'ENOENT'].includes(
|
|
|
- error.code
|
|
|
- )
|
|
|
- ) {
|
|
|
- return new NotFoundError({
|
|
|
- message: 'no such file',
|
|
|
- info: params
|
|
|
- }).withCause(error)
|
|
|
- } else {
|
|
|
- return new ErrorType({
|
|
|
- message: message,
|
|
|
- info: params
|
|
|
- }).withCause(error)
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
const _clients = new Map()
|
|
|
let _defaultClient
|
|
|
|