mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 17:19:37 +02:00
Merge pull request #27679 from overleaf/msm-aws-sdk-upgrade
Upgrade `aws-sdk` to v3 GitOrigin-RevId: 4989ae920d8b7fd9e79623947b7c40bcc2e56d92
This commit is contained in:
@@ -20,12 +20,15 @@
|
||||
"author": "Overleaf (https://www.overleaf.com/)",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.864.0",
|
||||
"@aws-sdk/lib-storage": "^3.864.0",
|
||||
"@aws-sdk/node-http-handler": "^3.370.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.864.0",
|
||||
"@google-cloud/storage": "^6.10.1",
|
||||
"@overleaf/logger": "*",
|
||||
"@overleaf/metrics": "*",
|
||||
"@overleaf/o-error": "*",
|
||||
"@overleaf/stream-utils": "*",
|
||||
"aws-sdk": "^2.1691.0",
|
||||
"fast-crc32c": "overleaf/node-fast-crc32c#aae6b2a4c7a7a159395df9cc6c38dfde702d6f51",
|
||||
"glob": "^7.1.6",
|
||||
"range-parser": "^1.2.1",
|
||||
|
||||
@@ -20,10 +20,6 @@ const hkdf = promisify(Crypto.hkdf)
|
||||
|
||||
const AES256_KEY_LENGTH = 32
|
||||
|
||||
/**
|
||||
* @typedef {import('aws-sdk').AWSError} AWSError
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Settings
|
||||
* @property {boolean} automaticallyRotateDEKEncryption
|
||||
@@ -37,16 +33,6 @@ const AES256_KEY_LENGTH = 32
|
||||
* @typedef {import('./types').ListDirectoryResult} ListDirectoryResult
|
||||
*/
|
||||
|
||||
/**
|
||||
* Helper function to make TS happy when accessing error properties
|
||||
* AWSError is not an actual class, so we cannot use instanceof.
|
||||
* @param {any} err
|
||||
* @return {err is AWSError}
|
||||
*/
|
||||
function isAWSError(err) {
|
||||
return !!err
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} err
|
||||
* @return {boolean}
|
||||
@@ -55,9 +41,8 @@ function isForbiddenError(err) {
|
||||
if (!err || !(err instanceof ReadError || err instanceof NotFoundError)) {
|
||||
return false
|
||||
}
|
||||
const cause = err.cause
|
||||
if (!isAWSError(cause)) return false
|
||||
return cause.statusCode === 403
|
||||
// @ts-ignore
|
||||
return err?.cause.statusCode === 403 || err?.cause.Code === 'AccessDenied'
|
||||
}
|
||||
|
||||
class RootKeyEncryptionKey {
|
||||
|
||||
@@ -144,17 +144,22 @@ function wrapError(error, message, params, ErrorType) {
|
||||
...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(
|
||||
error.code
|
||||
errorCode
|
||||
) ||
|
||||
(error.response && error.response.statusCode === 404)
|
||||
) {
|
||||
return new NotFoundError('no such file', params, error)
|
||||
} else if (
|
||||
params.ifNoneMatch === '*' &&
|
||||
(error.code === 'PreconditionFailed' ||
|
||||
(errorCode === 'PreconditionFailed' ||
|
||||
error.response?.statusCode === 412 ||
|
||||
error instanceof AlreadyWrittenError)
|
||||
) {
|
||||
|
||||
49
libraries/object-persistor/src/S3Md5Middleware.js
Normal file
49
libraries/object-persistor/src/S3Md5Middleware.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const Crypto = require('node:crypto')
|
||||
|
||||
/**
|
||||
* Creates an S3 client that uses MD5 checksums for DeleteObjects operations
|
||||
* https://github.com/aws/aws-sdk-js-v3/blob/main/supplemental-docs/MD5_FALLBACK.md
|
||||
*/
|
||||
const md5Middleware = (next, context) => async args => {
|
||||
if (context.commandName !== 'DeleteObjectsCommand') {
|
||||
return next(args)
|
||||
}
|
||||
|
||||
const headers = args.request.headers
|
||||
|
||||
// Remove any checksum headers added by default middleware
|
||||
// This ensures our Content-MD5 is the primary integrity check
|
||||
Object.keys(headers).forEach(header => {
|
||||
const lowerHeader = header.toLowerCase()
|
||||
if (
|
||||
lowerHeader.startsWith('x-amz-checksum-') ||
|
||||
lowerHeader.startsWith('x-amz-sdk-checksum-')
|
||||
) {
|
||||
delete headers[header]
|
||||
}
|
||||
})
|
||||
|
||||
if (args.request.body) {
|
||||
const bodyContent = Buffer.from(args.request.body)
|
||||
headers['Content-MD5'] = Crypto.createHash('md5')
|
||||
.update(bodyContent)
|
||||
.digest('base64')
|
||||
}
|
||||
|
||||
return await next(args)
|
||||
}
|
||||
|
||||
function addMd5Middleware(client) {
|
||||
// Add the middleware relative to the flexible checksums middleware
|
||||
// This ensures it runs after default checksums might be added, but before signing
|
||||
client.middlewareStack.add(md5Middleware, {
|
||||
step: 'build',
|
||||
toMiddleware: 'flexibleChecksumsMiddleware',
|
||||
name: 'addMD5ChecksumForDeleteObjects',
|
||||
tags: ['MD5_FALLBACK'],
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
addMd5Middleware,
|
||||
}
|
||||
@@ -15,18 +15,23 @@ const PersistorHelper = require('./PersistorHelper')
|
||||
|
||||
const { pipeline, PassThrough } = require('node:stream')
|
||||
const fs = require('node:fs')
|
||||
const S3 = require('aws-sdk/clients/s3')
|
||||
const { URL } = require('node:url')
|
||||
const {
|
||||
S3Client,
|
||||
CreateBucketCommand,
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
HeadObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
DeleteObjectsCommand,
|
||||
CopyObjectCommand,
|
||||
} = require('@aws-sdk/client-s3')
|
||||
const { Upload } = require('@aws-sdk/lib-storage')
|
||||
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner')
|
||||
const { NodeHttpHandler } = require('@aws-sdk/node-http-handler')
|
||||
const { WriteError, ReadError, NotFoundError } = require('./Errors')
|
||||
const zlib = require('node:zlib')
|
||||
|
||||
/**
|
||||
* @typedef {import('aws-sdk/clients/s3').ListObjectsV2Output} ListObjectsV2Output
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import('aws-sdk/clients/s3').Object} S3Object
|
||||
*/
|
||||
const { addMd5Middleware } = require('./S3Md5Middleware')
|
||||
|
||||
/**
|
||||
* @typedef {import('./types').ListDirectoryResult} ListDirectoryResult
|
||||
@@ -73,7 +78,7 @@ class SSECOptions {
|
||||
}
|
||||
|
||||
class S3Persistor extends AbstractPersistor {
|
||||
/** @type {Map<string, S3>} */
|
||||
/** @type {Map<string, S3Client>} */
|
||||
#clients = new Map()
|
||||
|
||||
constructor(settings = {}) {
|
||||
@@ -117,7 +122,7 @@ class S3Persistor extends AbstractPersistor {
|
||||
// observer will catch errors, clean up and log a warning
|
||||
pipeline(readStream, observer, () => {})
|
||||
|
||||
/** @type {S3.PutObjectRequest} */
|
||||
/** @type {import('@aws-sdk/client-s3').PutObjectCommandInput} */
|
||||
const uploadOptions = {
|
||||
Bucket: bucketName,
|
||||
Key: key,
|
||||
@@ -154,13 +159,16 @@ class S3Persistor extends AbstractPersistor {
|
||||
}
|
||||
|
||||
if (this.settings.disableMultiPartUpload) {
|
||||
await this._getClientForBucket(bucketName, computeChecksums)
|
||||
.putObject(uploadOptions)
|
||||
.promise()
|
||||
await this._getClientForBucket(bucketName, computeChecksums).send(
|
||||
new PutObjectCommand(uploadOptions)
|
||||
)
|
||||
} else {
|
||||
await this._getClientForBucket(bucketName, computeChecksums)
|
||||
.upload(uploadOptions, { partSize: this.settings.partSize })
|
||||
.promise()
|
||||
const upload = new Upload({
|
||||
client: this._getClientForBucket(bucketName, computeChecksums),
|
||||
params: uploadOptions,
|
||||
partSize: this.settings.partSize,
|
||||
})
|
||||
await upload.done()
|
||||
}
|
||||
} catch (err) {
|
||||
throw PersistorHelper.wrapError(
|
||||
@@ -185,6 +193,7 @@ class S3Persistor extends AbstractPersistor {
|
||||
async getObjectStream(bucketName, key, opts) {
|
||||
opts = opts || {}
|
||||
|
||||
/** @type {import('@aws-sdk/client-s3').GetObjectCommandInput} */
|
||||
const params = {
|
||||
Bucket: bucketName,
|
||||
Key: key,
|
||||
@@ -200,32 +209,18 @@ class S3Persistor extends AbstractPersistor {
|
||||
bucket: bucketName,
|
||||
})
|
||||
|
||||
const req = this._getClientForBucket(bucketName).getObject(params)
|
||||
const stream = req.createReadStream()
|
||||
|
||||
let contentEncoding
|
||||
const abortController = new AbortController()
|
||||
let stream, contentEncoding
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
req.on('httpHeaders', (statusCode, headers) => {
|
||||
switch (statusCode) {
|
||||
case 200: // full response
|
||||
case 206: // partial response
|
||||
contentEncoding = headers['content-encoding']
|
||||
return resolve(undefined)
|
||||
case 403: // AccessDenied
|
||||
return // handled by stream.on('error') handler below
|
||||
case 404: // NoSuchKey
|
||||
return reject(new NotFoundError('not found'))
|
||||
default:
|
||||
// handled by stream.on('error') handler below
|
||||
}
|
||||
})
|
||||
// The AWS SDK is forwarding any errors from the request to the stream.
|
||||
// The AWS SDK is emitting additional errors on the stream ahead of starting to stream.
|
||||
stream.on('error', reject)
|
||||
// The AWS SDK is kicking off the request in the next event loop cycle.
|
||||
const { Body, ContentEncoding } = await this._getClientForBucket(
|
||||
bucketName
|
||||
).send(new GetObjectCommand(params), {
|
||||
abortSignal: abortController.signal,
|
||||
})
|
||||
stream = Body
|
||||
contentEncoding = ContentEncoding
|
||||
} catch (err) {
|
||||
abortController.abort()
|
||||
throw PersistorHelper.wrapError(
|
||||
err,
|
||||
'error reading file from S3',
|
||||
@@ -239,8 +234,11 @@ class S3Persistor extends AbstractPersistor {
|
||||
if (contentEncoding === 'gzip' && opts.autoGunzip) {
|
||||
transformer.push(zlib.createGunzip())
|
||||
}
|
||||
// @ts-ignore stream (Body) can be undefined in GetObjectCommand
|
||||
pipeline(stream, observer, ...transformer, pass, err => {
|
||||
if (err) req.abort()
|
||||
if (err) {
|
||||
abortController.abort()
|
||||
}
|
||||
})
|
||||
return pass
|
||||
}
|
||||
@@ -253,13 +251,13 @@ class S3Persistor extends AbstractPersistor {
|
||||
async getRedirectUrl(bucketName, key) {
|
||||
const expiresSeconds = Math.round(this.settings.signedUrlExpiryInMs / 1000)
|
||||
try {
|
||||
return await this._getClientForBucket(bucketName).getSignedUrlPromise(
|
||||
'getObject',
|
||||
{
|
||||
return await getSignedUrl(
|
||||
this._getClientForBucket(bucketName),
|
||||
new GetObjectCommand({
|
||||
Bucket: bucketName,
|
||||
Key: key,
|
||||
Expires: expiresSeconds,
|
||||
}
|
||||
}),
|
||||
{ expiresIn: expiresSeconds }
|
||||
)
|
||||
} catch (err) {
|
||||
throw PersistorHelper.wrapError(
|
||||
@@ -286,15 +284,15 @@ class S3Persistor extends AbstractPersistor {
|
||||
const objects = contents.map(item => ({ Key: item.Key || '' }))
|
||||
if (objects?.length) {
|
||||
try {
|
||||
await this._getClientForBucket(bucketName)
|
||||
.deleteObjects({
|
||||
await this._getClientForBucket(bucketName).send(
|
||||
new DeleteObjectsCommand({
|
||||
Bucket: bucketName,
|
||||
Delete: {
|
||||
Objects: objects,
|
||||
Quiet: true,
|
||||
},
|
||||
})
|
||||
.promise()
|
||||
)
|
||||
} catch (err) {
|
||||
throw PersistorHelper.wrapError(
|
||||
err,
|
||||
@@ -329,9 +327,9 @@ class S3Persistor extends AbstractPersistor {
|
||||
}
|
||||
|
||||
try {
|
||||
response = await this._getClientForBucket(bucketName)
|
||||
.listObjectsV2(options)
|
||||
.promise()
|
||||
response = await this._getClientForBucket(bucketName).send(
|
||||
new ListObjectsV2Command(options)
|
||||
)
|
||||
} catch (err) {
|
||||
throw PersistorHelper.wrapError(
|
||||
err,
|
||||
@@ -349,7 +347,7 @@ class S3Persistor extends AbstractPersistor {
|
||||
* @param {string} key
|
||||
* @param {Object} opts
|
||||
* @param {SSECOptions} [opts.ssecOptions]
|
||||
* @return {Promise<S3.HeadObjectOutput>}
|
||||
* @return {Promise<import('@aws-sdk/client-s3').HeadObjectOutput>}
|
||||
*/
|
||||
async #headObject(bucketName, key, opts = {}) {
|
||||
const params = { Bucket: bucketName, Key: key }
|
||||
@@ -357,9 +355,8 @@ class S3Persistor extends AbstractPersistor {
|
||||
Object.assign(params, opts.ssecOptions.getGetOptions())
|
||||
}
|
||||
try {
|
||||
return await this._getClientForBucket(bucketName)
|
||||
.headObject(params)
|
||||
.promise()
|
||||
const client = await this._getClientForBucket(bucketName)
|
||||
return await client.send(new HeadObjectCommand(params))
|
||||
} catch (err) {
|
||||
throw PersistorHelper.wrapError(
|
||||
err,
|
||||
@@ -433,9 +430,9 @@ class S3Persistor extends AbstractPersistor {
|
||||
*/
|
||||
async deleteObject(bucketName, key) {
|
||||
try {
|
||||
await this._getClientForBucket(bucketName)
|
||||
.deleteObject({ Bucket: bucketName, Key: key })
|
||||
.promise()
|
||||
await this._getClientForBucket(bucketName).send(
|
||||
new DeleteObjectCommand({ Bucket: bucketName, Key: key })
|
||||
)
|
||||
} catch (err) {
|
||||
// s3 does not give us a NotFoundError here
|
||||
throw PersistorHelper.wrapError(
|
||||
@@ -460,7 +457,7 @@ class S3Persistor extends AbstractPersistor {
|
||||
const params = {
|
||||
Bucket: bucketName,
|
||||
Key: destKey,
|
||||
CopySource: `${bucketName}/${sourceKey}`,
|
||||
CopySource: `/${bucketName}/${sourceKey}`,
|
||||
}
|
||||
if (opts.ssecSrcOptions) {
|
||||
Object.assign(params, opts.ssecSrcOptions.getCopyOptions())
|
||||
@@ -469,7 +466,9 @@ class S3Persistor extends AbstractPersistor {
|
||||
Object.assign(params, opts.ssecOptions.getPutOptions())
|
||||
}
|
||||
try {
|
||||
await this._getClientForBucket(bucketName).copyObject(params).promise()
|
||||
await this._getClientForBucket(bucketName).send(
|
||||
new CopyObjectCommand(params)
|
||||
)
|
||||
} catch (err) {
|
||||
throw PersistorHelper.wrapError(
|
||||
err,
|
||||
@@ -519,9 +518,9 @@ class S3Persistor extends AbstractPersistor {
|
||||
if (continuationToken) {
|
||||
options.ContinuationToken = continuationToken
|
||||
}
|
||||
const response = await this._getClientForBucket(bucketName)
|
||||
.listObjectsV2(options)
|
||||
.promise()
|
||||
const response = await this._getClientForBucket(bucketName).send(
|
||||
new ListObjectsV2Command(options)
|
||||
)
|
||||
|
||||
const size =
|
||||
response.Contents?.reduce((acc, item) => (item.Size || 0) + acc, 0) || 0
|
||||
@@ -549,33 +548,38 @@ class S3Persistor extends AbstractPersistor {
|
||||
/**
|
||||
* @param {string} bucket
|
||||
* @param {boolean} computeChecksums
|
||||
* @return {S3}
|
||||
* @return {S3Client}
|
||||
* @private
|
||||
*/
|
||||
_getClientForBucket(bucket, computeChecksums = false) {
|
||||
/** @type {S3.Types.ClientConfiguration} */
|
||||
/** @type {import('@aws-sdk/client-s3').S3ClientConfig} */
|
||||
const clientOptions = {}
|
||||
const cacheKey = `${bucket}:${computeChecksums}`
|
||||
if (computeChecksums) {
|
||||
clientOptions.computeChecksums = true
|
||||
clientOptions.requestChecksumCalculation = 'WHEN_SUPPORTED'
|
||||
clientOptions.responseChecksumValidation = 'WHEN_SUPPORTED'
|
||||
}
|
||||
let client = this.#clients.get(cacheKey)
|
||||
if (!client) {
|
||||
client = new S3(
|
||||
client = new S3Client(
|
||||
this._buildClientOptions(
|
||||
this.settings.bucketCreds?.[bucket],
|
||||
clientOptions
|
||||
)
|
||||
)
|
||||
this.#clients.set(cacheKey, client)
|
||||
|
||||
// https://github.com/aws/aws-sdk-js-v3/blob/main/supplemental-docs/MD5_FALLBACK.md
|
||||
addMd5Middleware(client)
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} bucketCredentials
|
||||
* @param {S3.Types.ClientConfiguration} clientOptions
|
||||
* @return {S3.Types.ClientConfiguration}
|
||||
* @param {import('@aws-sdk/client-s3').S3ClientConfig} clientOptions
|
||||
* @return {import('@aws-sdk/client-s3').S3ClientConfig}
|
||||
* @private
|
||||
*/
|
||||
_buildClientOptions(bucketCredentials, clientOptions) {
|
||||
@@ -593,39 +597,63 @@ class S3Persistor extends AbstractPersistor {
|
||||
}
|
||||
} else {
|
||||
// Use the default credentials provider (process.env -> SSP -> ini -> IAM)
|
||||
// Docs: https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/CredentialProviderChain.html#defaultProviders-property
|
||||
// Docs: https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-credentials-node.html
|
||||
}
|
||||
|
||||
let sslEnabled = false
|
||||
if (this.settings.endpoint) {
|
||||
const endpoint = new URL(this.settings.endpoint)
|
||||
options.endpoint = this.settings.endpoint
|
||||
options.sslEnabled = endpoint.protocol === 'https:'
|
||||
sslEnabled = endpoint.protocol === 'https:'
|
||||
}
|
||||
|
||||
// path-style access is only used for acceptance tests
|
||||
if (this.settings.pathStyle) {
|
||||
options.s3ForcePathStyle = true
|
||||
options.forcePathStyle = true
|
||||
}
|
||||
|
||||
for (const opt of ['httpOptions', 'maxRetries', 'region']) {
|
||||
for (const opt of ['httpOptions', 'region']) {
|
||||
if (this.settings[opt]) {
|
||||
options[opt] = this.settings[opt]
|
||||
}
|
||||
}
|
||||
|
||||
if (options.sslEnabled && this.settings.ca && !options.httpOptions?.agent) {
|
||||
options.httpOptions = options.httpOptions || {}
|
||||
options.httpOptions.agent = new https.Agent({
|
||||
// maxRetries has been moved to maxAttempts in aws-sdk v3,
|
||||
// we're keeping the existing setting
|
||||
if (this.settings.maxRetries) {
|
||||
options.maxAttempts = this.settings.maxRetries + 1
|
||||
}
|
||||
|
||||
if (sslEnabled && this.settings.ca) {
|
||||
const agent = new https.Agent({
|
||||
rejectUnauthorized: true,
|
||||
ca: this.settings.ca,
|
||||
})
|
||||
options.requestHandler = new NodeHttpHandler({
|
||||
httpAgent: agent,
|
||||
httpsAgent: agent,
|
||||
})
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
// test-only
|
||||
_createBucket(bucketName) {
|
||||
return this._getClientForBucket(bucketName).send(
|
||||
new CreateBucketCommand({ Bucket: bucketName })
|
||||
)
|
||||
}
|
||||
|
||||
// test-only
|
||||
_upload(bucketName, uploadOptions) {
|
||||
return this._getClientForBucket(bucketName).send(
|
||||
new PutObjectCommand(uploadOptions)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {S3.HeadObjectOutput} response
|
||||
* @param {import('@aws-sdk/client-s3').HeadObjectOutput} response
|
||||
* @return {string|null}
|
||||
* @private
|
||||
*/
|
||||
|
||||
4
libraries/object-persistor/src/types.d.ts
vendored
4
libraries/object-persistor/src/types.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
import type { ListObjectsV2Output, Object } from 'aws-sdk/clients/s3'
|
||||
import type { ListObjectsV2Output, _Object } from '@aws-sdk/client-s3'
|
||||
|
||||
export type ListDirectoryResult = {
|
||||
contents: Array<Object>
|
||||
contents: Array<_Object>
|
||||
response: ListObjectsV2Output
|
||||
}
|
||||
|
||||
112
libraries/object-persistor/test/unit/S3ClientMock.js
Normal file
112
libraries/object-persistor/test/unit/S3ClientMock.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/* eslint-disable new-cap */
|
||||
const sinon = require('sinon')
|
||||
const chai = require('chai')
|
||||
|
||||
const { expect, assert } = chai
|
||||
|
||||
module.exports = function () {
|
||||
const s3ClientStub = {
|
||||
send: sinon.stub(),
|
||||
middlewareStack: new Set(),
|
||||
}
|
||||
|
||||
const assertSendCalledWith = (s3Command, payload) => {
|
||||
for (let i = 0; i < s3ClientStub.send.callCount; i++) {
|
||||
const call = s3ClientStub.send.getCall(i)
|
||||
const callArg = call.args[0]
|
||||
if (callArg?.name === new s3Command().name) {
|
||||
if (payload) {
|
||||
expect(callArg.payload).to.deep.equal(payload)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
assert.fail(
|
||||
`Expected S3Client to be called with '${new s3Command().name}' command but it was not called`
|
||||
)
|
||||
}
|
||||
|
||||
const assertSendCallCount = (s3Command, expectedCount) => {
|
||||
const callCount = s3ClientStub.send.callCount
|
||||
let counter = 0
|
||||
for (let i = 0; i < callCount; i++) {
|
||||
const call = s3ClientStub.send.getCall(i)
|
||||
const callArg = call.args[0]
|
||||
if (callArg?.name === new s3Command().name) {
|
||||
counter++
|
||||
}
|
||||
}
|
||||
expect(counter).to.equal(expectedCount)
|
||||
}
|
||||
|
||||
const assertSendNotCalledWith = s3Command => {
|
||||
for (let i = 0; i < s3ClientStub.send.callCount; i++) {
|
||||
const call = s3ClientStub.send.getCall(i)
|
||||
const callArg = call.args[0]
|
||||
if (callArg?.name === new s3Command().name) {
|
||||
assert.fail(
|
||||
`Expected S3Client not to be called with '${new s3Command().name}' command but it was called`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mockSend = (s3Command, response, options = {}) => {
|
||||
const mock = s3ClientStub.send.withArgs(sinon.match.instanceOf(s3Command))
|
||||
const fn = options.rejects ? 'rejects' : 'resolves'
|
||||
|
||||
if (options.nextResponses?.length) {
|
||||
mock.onCall(0)[fn](response)
|
||||
for (let i = 0; i < options.nextResponses.length; i++) {
|
||||
mock.onCall(i + 1)[fn](options.nextResponses[i])
|
||||
}
|
||||
} else {
|
||||
mock[fn](response)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
s3ClientStub,
|
||||
assertSendCalledWith,
|
||||
assertSendCallCount,
|
||||
assertSendNotCalledWith,
|
||||
mockSend,
|
||||
S3Client: sinon.stub().returns(s3ClientStub),
|
||||
CopyObjectCommand: class DeleteObjectCommand {
|
||||
constructor(payload) {
|
||||
this.name = 'CopyObjectCommand'
|
||||
this.payload = payload
|
||||
}
|
||||
},
|
||||
DeleteObjectCommand: class DeleteObjectCommand {
|
||||
constructor(payload) {
|
||||
this.name = 'DeleteObjectCommand'
|
||||
this.payload = payload
|
||||
}
|
||||
},
|
||||
DeleteObjectsCommand: class DeleteObjectCommand {
|
||||
constructor(payload) {
|
||||
this.name = 'DeleteObjectsCommand'
|
||||
this.payload = payload
|
||||
}
|
||||
},
|
||||
GetObjectCommand: class DeleteObjectCommand {
|
||||
constructor(payload) {
|
||||
this.name = 'GetObjectCommand'
|
||||
this.payload = payload
|
||||
}
|
||||
},
|
||||
HeadObjectCommand: class DeleteObjectCommand {
|
||||
constructor(payload) {
|
||||
this.name = 'HeadObjectCommand'
|
||||
this.payload = payload
|
||||
}
|
||||
},
|
||||
ListObjectsV2Command: class DeleteObjectCommand {
|
||||
constructor(payload) {
|
||||
this.name = 'ListObjectsV2Command'
|
||||
this.payload = payload
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ const { expect } = chai
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const Errors = require('../../src/Errors')
|
||||
const { EventEmitter } = require('node:events')
|
||||
const { Readable } = require('node:stream')
|
||||
const mockS3 = require('./S3ClientMock')
|
||||
|
||||
const MODULE_PATH = '../../src/S3Persistor.js'
|
||||
|
||||
@@ -34,17 +36,18 @@ describe('S3PersistorTests', function () {
|
||||
Transform,
|
||||
PassThrough,
|
||||
S3,
|
||||
awsRequestPresigner,
|
||||
awsLibStorage,
|
||||
awsLibStorageUpload,
|
||||
abortSignal,
|
||||
Fs,
|
||||
ReadStream,
|
||||
Stream,
|
||||
StreamPromises,
|
||||
S3GetObjectRequest,
|
||||
S3Persistor,
|
||||
S3Client,
|
||||
S3NotFoundError,
|
||||
S3AccessDeniedError,
|
||||
FileNotFoundError,
|
||||
EmptyPromise,
|
||||
settings,
|
||||
Hash,
|
||||
crypto
|
||||
@@ -72,46 +75,7 @@ describe('S3PersistorTests', function () {
|
||||
pipeline: sinon.stub().resolves(),
|
||||
}
|
||||
|
||||
EmptyPromise = {
|
||||
promise: sinon.stub().resolves(),
|
||||
}
|
||||
|
||||
ReadStream = new EventEmitter()
|
||||
class FakeS3GetObjectRequest extends EventEmitter {
|
||||
constructor() {
|
||||
super()
|
||||
this.statusCode = 200
|
||||
this.err = null
|
||||
this.aborted = false
|
||||
}
|
||||
|
||||
abort() {
|
||||
this.aborted = true
|
||||
}
|
||||
|
||||
createReadStream() {
|
||||
setTimeout(() => {
|
||||
if (this.notFoundSSEC) {
|
||||
// special case for AWS S3: 404 NoSuchKey wrapped in a 400. A single request received a single response, and multiple httpHeaders events are triggered. Don't ask.
|
||||
this.emit('httpHeaders', 400, {})
|
||||
this.emit('httpHeaders', 404, {})
|
||||
ReadStream.emit('error', S3NotFoundError)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.err) return ReadStream.emit('error', this.err)
|
||||
this.emit('httpHeaders', this.statusCode, {})
|
||||
if (this.statusCode === 403) {
|
||||
ReadStream.emit('error', S3AccessDeniedError)
|
||||
}
|
||||
if (this.statusCode === 404) {
|
||||
ReadStream.emit('error', S3NotFoundError)
|
||||
}
|
||||
})
|
||||
return ReadStream
|
||||
}
|
||||
}
|
||||
S3GetObjectRequest = new FakeS3GetObjectRequest()
|
||||
|
||||
FileNotFoundError = new Error('File not found')
|
||||
FileNotFoundError.code = 'ENOENT'
|
||||
@@ -121,33 +85,23 @@ describe('S3PersistorTests', function () {
|
||||
}
|
||||
|
||||
S3NotFoundError = new Error('not found')
|
||||
S3NotFoundError.code = 'NoSuchKey'
|
||||
S3NotFoundError.name = 'NoSuchKey'
|
||||
|
||||
S3AccessDeniedError = new Error('access denied')
|
||||
S3AccessDeniedError.code = 'AccessDenied'
|
||||
|
||||
S3Client = {
|
||||
getObject: sinon.stub().returns(S3GetObjectRequest),
|
||||
headObject: sinon.stub().returns({
|
||||
promise: sinon.stub().resolves({
|
||||
ContentLength: objectSize,
|
||||
ETag: md5,
|
||||
}),
|
||||
}),
|
||||
listObjectsV2: sinon.stub().returns({
|
||||
promise: sinon.stub().resolves({
|
||||
Contents: files,
|
||||
}),
|
||||
}),
|
||||
upload: sinon
|
||||
.stub()
|
||||
.returns({ promise: sinon.stub().resolves({ ETag: `"${md5}"` }) }),
|
||||
copyObject: sinon.stub().returns(EmptyPromise),
|
||||
deleteObject: sinon.stub().returns(EmptyPromise),
|
||||
deleteObjects: sinon.stub().returns(EmptyPromise),
|
||||
getSignedUrlPromise: sinon.stub().resolves(redirectUrl),
|
||||
S3 = mockS3()
|
||||
|
||||
awsLibStorageUpload = sinon.stub().returns({
|
||||
done: sinon.stub().resolves(),
|
||||
})
|
||||
|
||||
awsLibStorage = {
|
||||
Upload: awsLibStorageUpload,
|
||||
}
|
||||
awsRequestPresigner = {
|
||||
getSignedUrl: sinon.stub().resolves(redirectUrl),
|
||||
}
|
||||
S3 = sinon.stub().callsFake(() => Object.assign({}, S3Client))
|
||||
|
||||
Hash = {
|
||||
end: sinon.stub(),
|
||||
@@ -162,17 +116,29 @@ describe('S3PersistorTests', function () {
|
||||
warn: sinon.stub(),
|
||||
}
|
||||
|
||||
abortSignal = sinon.stub()
|
||||
|
||||
const AbortCtrl = sinon.stub().returns({
|
||||
signal: {},
|
||||
abort: abortSignal,
|
||||
})
|
||||
|
||||
S3Persistor = new (SandboxedModule.require(MODULE_PATH, {
|
||||
requires: {
|
||||
'aws-sdk/clients/s3': S3,
|
||||
'@aws-sdk/client-s3': S3,
|
||||
'@aws-sdk/lib-storage': awsLibStorage,
|
||||
'@aws-sdk/s3-request-presigner': awsRequestPresigner,
|
||||
'@overleaf/logger': Logger,
|
||||
'@aws-sdk/node-http-handler': {
|
||||
NodeHttpHandler: sinon.stub(),
|
||||
},
|
||||
'./Errors': Errors,
|
||||
fs: Fs,
|
||||
stream: Stream,
|
||||
'stream/promises': StreamPromises,
|
||||
crypto,
|
||||
},
|
||||
globals: { console, Buffer },
|
||||
globals: { console, Buffer, AbortController: AbortCtrl },
|
||||
}).S3Persistor)(settings)
|
||||
})
|
||||
|
||||
@@ -181,6 +147,10 @@ describe('S3PersistorTests', function () {
|
||||
let stream
|
||||
|
||||
beforeEach(async function () {
|
||||
S3.mockSend(S3.GetObjectCommand, {
|
||||
Body: Readable.from('content'),
|
||||
ContentEncoding: 'gzip',
|
||||
})
|
||||
stream = await S3Persistor.getObjectStream(bucket, key)
|
||||
})
|
||||
|
||||
@@ -188,12 +158,8 @@ describe('S3PersistorTests', function () {
|
||||
expect(stream).to.be.instanceOf(PassThrough)
|
||||
})
|
||||
|
||||
it('sets the AWS client up with credentials from settings', function () {
|
||||
expect(S3).to.have.been.calledWith(defaultS3Credentials)
|
||||
})
|
||||
|
||||
it('fetches the right key from the right bucket', function () {
|
||||
expect(S3Client.getObject).to.have.been.calledWith({
|
||||
S3.assertSendCalledWith(S3.GetObjectCommand, {
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
})
|
||||
@@ -201,14 +167,14 @@ describe('S3PersistorTests', function () {
|
||||
|
||||
it('pipes the stream through the meter', async function () {
|
||||
expect(Stream.pipeline).to.have.been.calledWith(
|
||||
ReadStream,
|
||||
sinon.match.instanceOf(Readable),
|
||||
sinon.match.instanceOf(Transform),
|
||||
sinon.match.instanceOf(PassThrough)
|
||||
)
|
||||
})
|
||||
|
||||
it('does not abort the request', function () {
|
||||
expect(S3GetObjectRequest.aborted).to.equal(false)
|
||||
expect(abortSignal).not.to.have.been.called
|
||||
})
|
||||
})
|
||||
|
||||
@@ -216,6 +182,10 @@ describe('S3PersistorTests', function () {
|
||||
let stream
|
||||
|
||||
beforeEach(async function () {
|
||||
S3.mockSend(S3.GetObjectCommand, {
|
||||
Body: Readable.from('this is a longer content'),
|
||||
ContentEncoding: 'gzip',
|
||||
})
|
||||
stream = await S3Persistor.getObjectStream(bucket, key, {
|
||||
start: 5,
|
||||
end: 10,
|
||||
@@ -227,7 +197,7 @@ describe('S3PersistorTests', function () {
|
||||
})
|
||||
|
||||
it('passes the byte range on to S3', function () {
|
||||
expect(S3Client.getObject).to.have.been.calledWith({
|
||||
S3.assertSendCalledWith(S3.GetObjectCommand, {
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Range: 'bytes=5-10',
|
||||
@@ -239,6 +209,10 @@ describe('S3PersistorTests', function () {
|
||||
let stream
|
||||
|
||||
beforeEach(async function () {
|
||||
S3.mockSend(S3.GetObjectCommand, {
|
||||
Body: Readable.from('content'),
|
||||
ContentEncoding: 'gzip',
|
||||
})
|
||||
Stream.pipeline.yields(new Error())
|
||||
stream = await S3Persistor.getObjectStream(bucket, key)
|
||||
})
|
||||
@@ -248,7 +222,7 @@ describe('S3PersistorTests', function () {
|
||||
})
|
||||
|
||||
it('aborts the request', function () {
|
||||
expect(S3GetObjectRequest.aborted).to.equal(true)
|
||||
expect(abortSignal).to.have.been.calledOnce
|
||||
})
|
||||
})
|
||||
|
||||
@@ -264,6 +238,10 @@ describe('S3PersistorTests', function () {
|
||||
}
|
||||
|
||||
beforeEach(async function () {
|
||||
S3.mockSend(S3.GetObjectCommand, {
|
||||
Body: Readable.from('content'),
|
||||
ContentEncoding: 'gzip',
|
||||
})
|
||||
settings.bucketCreds = {}
|
||||
settings.bucketCreds[bucket] = {
|
||||
auth_key: alternativeKey,
|
||||
@@ -278,11 +256,11 @@ describe('S3PersistorTests', function () {
|
||||
})
|
||||
|
||||
it('sets the AWS client up with the alternative credentials', function () {
|
||||
expect(S3).to.have.been.calledWith(alternativeS3Credentials)
|
||||
expect(S3.S3Client).to.have.been.calledWith(alternativeS3Credentials)
|
||||
})
|
||||
|
||||
it('fetches the right key from the right bucket', function () {
|
||||
expect(S3Client.getObject).to.have.been.calledWith({
|
||||
S3.assertSendCalledWith(S3.GetObjectCommand, {
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
})
|
||||
@@ -291,9 +269,13 @@ describe('S3PersistorTests', function () {
|
||||
it('uses the default credentials for an unknown bucket', async function () {
|
||||
stream = await S3Persistor.getObjectStream('anotherBucket', key)
|
||||
|
||||
expect(S3).to.have.been.calledTwice
|
||||
expect(S3.firstCall).to.have.been.calledWith(alternativeS3Credentials)
|
||||
expect(S3.secondCall).to.have.been.calledWith(defaultS3Credentials)
|
||||
expect(S3.S3Client).to.have.been.calledTwice
|
||||
expect(S3.S3Client.firstCall).to.have.been.calledWith(
|
||||
alternativeS3Credentials
|
||||
)
|
||||
expect(S3.S3Client.secondCall).to.have.been.calledWith(
|
||||
defaultS3Credentials
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -302,9 +284,14 @@ describe('S3PersistorTests', function () {
|
||||
delete settings.key
|
||||
delete settings.secret
|
||||
|
||||
S3.mockSend(S3.GetObjectCommand, {
|
||||
Body: Readable.from('content'),
|
||||
ContentEncoding: 'gzip',
|
||||
})
|
||||
|
||||
await S3Persistor.getObjectStream(bucket, key)
|
||||
expect(S3).to.have.been.calledOnce
|
||||
expect(S3.args[0].credentials).to.not.exist
|
||||
expect(S3.S3Client).to.have.been.calledOnce
|
||||
expect(S3.S3Client.args[0].credentials).to.not.exist
|
||||
})
|
||||
})
|
||||
|
||||
@@ -315,11 +302,20 @@ describe('S3PersistorTests', function () {
|
||||
beforeEach(async function () {
|
||||
settings.httpOptions = httpOptions
|
||||
settings.maxRetries = maxRetries
|
||||
|
||||
S3.mockSend(S3.GetObjectCommand, {
|
||||
Body: Readable.from('content'),
|
||||
ContentEncoding: 'gzip',
|
||||
})
|
||||
|
||||
await S3Persistor.getObjectStream(bucket, key)
|
||||
})
|
||||
|
||||
it('configures the S3 client appropriately', function () {
|
||||
expect(S3).to.have.been.calledWithMatch({ httpOptions, maxRetries })
|
||||
expect(S3.S3Client).to.have.been.calledWithMatch({
|
||||
httpOptions,
|
||||
maxAttempts: maxRetries + 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -327,7 +323,7 @@ describe('S3PersistorTests', function () {
|
||||
let error, stream
|
||||
|
||||
beforeEach(async function () {
|
||||
S3GetObjectRequest.statusCode = 404
|
||||
S3.mockSend(S3.GetObjectCommand, S3NotFoundError, { rejects: true })
|
||||
try {
|
||||
stream = await S3Persistor.getObjectStream(bucket, key)
|
||||
} catch (err) {
|
||||
@@ -356,7 +352,7 @@ describe('S3PersistorTests', function () {
|
||||
let error, stream
|
||||
|
||||
beforeEach(async function () {
|
||||
S3GetObjectRequest.notFoundSSEC = 404
|
||||
S3.mockSend(S3.GetObjectCommand, S3NotFoundError, { rejects: true })
|
||||
try {
|
||||
stream = await S3Persistor.getObjectStream(bucket, key)
|
||||
} catch (err) {
|
||||
@@ -384,7 +380,7 @@ describe('S3PersistorTests', function () {
|
||||
let error, stream
|
||||
|
||||
beforeEach(async function () {
|
||||
S3GetObjectRequest.statusCode = 403
|
||||
S3.mockSend(S3.GetObjectCommand, S3AccessDeniedError, { rejects: true })
|
||||
try {
|
||||
stream = await S3Persistor.getObjectStream(bucket, key)
|
||||
} catch (err) {
|
||||
@@ -413,7 +409,7 @@ describe('S3PersistorTests', function () {
|
||||
let error, stream
|
||||
|
||||
beforeEach(async function () {
|
||||
S3GetObjectRequest.err = genericError
|
||||
S3.mockSend(S3.GetObjectCommand, genericError, { rejects: true })
|
||||
try {
|
||||
stream = await S3Persistor.getObjectStream(bucket, key)
|
||||
} catch (err) {
|
||||
@@ -447,7 +443,7 @@ describe('S3PersistorTests', function () {
|
||||
})
|
||||
|
||||
it('should request a signed URL', function () {
|
||||
expect(S3Client.getSignedUrlPromise).to.have.been.called
|
||||
expect(awsRequestPresigner.getSignedUrl).to.have.been.called
|
||||
})
|
||||
|
||||
it('should return the url', function () {
|
||||
@@ -460,6 +456,7 @@ describe('S3PersistorTests', function () {
|
||||
let size
|
||||
|
||||
beforeEach(async function () {
|
||||
S3.mockSend(S3.HeadObjectCommand, { ContentLength: objectSize })
|
||||
size = await S3Persistor.getObjectSize(bucket, key)
|
||||
})
|
||||
|
||||
@@ -468,7 +465,7 @@ describe('S3PersistorTests', function () {
|
||||
})
|
||||
|
||||
it('should pass the bucket and key to S3', function () {
|
||||
expect(S3Client.headObject).to.have.been.calledWith({
|
||||
S3.assertSendCalledWith(S3.HeadObjectCommand, {
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
})
|
||||
@@ -479,9 +476,7 @@ describe('S3PersistorTests', function () {
|
||||
let error
|
||||
|
||||
beforeEach(async function () {
|
||||
S3Client.headObject = sinon.stub().returns({
|
||||
promise: sinon.stub().rejects(S3NotFoundError),
|
||||
})
|
||||
S3.mockSend(S3.HeadObjectCommand, S3NotFoundError, { rejects: true })
|
||||
try {
|
||||
await S3Persistor.getObjectSize(bucket, key)
|
||||
} catch (err) {
|
||||
@@ -502,9 +497,7 @@ describe('S3PersistorTests', function () {
|
||||
let error
|
||||
|
||||
beforeEach(async function () {
|
||||
S3Client.headObject = sinon.stub().returns({
|
||||
promise: sinon.stub().rejects(genericError),
|
||||
})
|
||||
S3.mockSend(S3.HeadObjectCommand, genericError, { rejects: true })
|
||||
try {
|
||||
await S3Persistor.getObjectSize(bucket, key)
|
||||
} catch (err) {
|
||||
@@ -525,19 +518,17 @@ describe('S3PersistorTests', function () {
|
||||
describe('sendStream', function () {
|
||||
describe('with valid parameters', function () {
|
||||
beforeEach(async function () {
|
||||
return S3Persistor.sendStream(bucket, key, ReadStream)
|
||||
await S3Persistor.sendStream(bucket, key, ReadStream)
|
||||
})
|
||||
|
||||
it('should upload the stream', function () {
|
||||
expect(S3Client.upload).to.have.been.calledWith({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: sinon.match.instanceOf(Stream.Transform),
|
||||
})
|
||||
})
|
||||
|
||||
it('should upload files in a single part', function () {
|
||||
expect(S3Client.upload).to.have.been.calledWith(sinon.match.any, {
|
||||
it('should upload the stream in a single part', function () {
|
||||
expect(awsLibStorageUpload).to.have.been.calledWith({
|
||||
client: S3.s3ClientStub,
|
||||
params: {
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: sinon.match.instanceOf(Stream.Transform),
|
||||
},
|
||||
partSize: 100 * 1024 * 1024,
|
||||
})
|
||||
})
|
||||
@@ -552,17 +543,21 @@ describe('S3PersistorTests', function () {
|
||||
|
||||
describe('when a hash is supplied', function () {
|
||||
beforeEach(async function () {
|
||||
return S3Persistor.sendStream(bucket, key, ReadStream, {
|
||||
await S3Persistor.sendStream(bucket, key, ReadStream, {
|
||||
sourceMd5: 'aaaaaaaabbbbbbbbaaaaaaaabbbbbbbb',
|
||||
})
|
||||
})
|
||||
|
||||
it('sends the hash in base64', function () {
|
||||
expect(S3Client.upload).to.have.been.calledWith({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: sinon.match.instanceOf(Transform),
|
||||
ContentMD5: 'qqqqqru7u7uqqqqqu7u7uw==',
|
||||
expect(awsLibStorageUpload).to.have.been.calledWith({
|
||||
client: S3.s3ClientStub,
|
||||
params: {
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: sinon.match.instanceOf(Transform),
|
||||
ContentMD5: 'qqqqqru7u7uqqqqqu7u7uw==',
|
||||
},
|
||||
partSize: 100 * 1024 * 1024,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -572,19 +567,23 @@ describe('S3PersistorTests', function () {
|
||||
const contentEncoding = 'gzip'
|
||||
|
||||
beforeEach(async function () {
|
||||
return S3Persistor.sendStream(bucket, key, ReadStream, {
|
||||
await S3Persistor.sendStream(bucket, key, ReadStream, {
|
||||
contentType,
|
||||
contentEncoding,
|
||||
})
|
||||
})
|
||||
|
||||
it('sends the metadata to S3', function () {
|
||||
expect(S3Client.upload).to.have.been.calledWith({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: sinon.match.instanceOf(Transform),
|
||||
ContentType: contentType,
|
||||
ContentEncoding: contentEncoding,
|
||||
expect(awsLibStorageUpload).to.have.been.calledWith({
|
||||
client: S3.s3ClientStub,
|
||||
params: {
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: sinon.match.instanceOf(Transform),
|
||||
ContentType: contentType,
|
||||
ContentEncoding: contentEncoding,
|
||||
},
|
||||
partSize: 100 * 1024 * 1024,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -592,9 +591,7 @@ describe('S3PersistorTests', function () {
|
||||
describe('when the upload fails', function () {
|
||||
let error
|
||||
beforeEach(async function () {
|
||||
S3Client.upload = sinon.stub().returns({
|
||||
promise: sinon.stub().rejects(genericError),
|
||||
})
|
||||
awsLibStorageUpload.rejects(genericError)
|
||||
try {
|
||||
await S3Persistor.sendStream(bucket, key, ReadStream)
|
||||
} catch (err) {
|
||||
@@ -611,7 +608,8 @@ describe('S3PersistorTests', function () {
|
||||
describe('sendFile', function () {
|
||||
describe('with valid parameters', function () {
|
||||
beforeEach(async function () {
|
||||
return S3Persistor.sendFile(bucket, key, filename)
|
||||
S3.s3ClientStub.send.resolves()
|
||||
await S3Persistor.sendFile(bucket, key, filename)
|
||||
})
|
||||
|
||||
it('should create a read stream for the file', function () {
|
||||
@@ -619,10 +617,14 @@ describe('S3PersistorTests', function () {
|
||||
})
|
||||
|
||||
it('should upload the stream', function () {
|
||||
expect(S3Client.upload).to.have.been.calledWith({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: sinon.match.instanceOf(Transform),
|
||||
expect(awsLibStorageUpload).to.have.been.calledWith({
|
||||
client: S3.s3ClientStub,
|
||||
params: {
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: sinon.match.instanceOf(Transform),
|
||||
},
|
||||
partSize: settings.partSize,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -632,6 +634,10 @@ describe('S3PersistorTests', function () {
|
||||
describe('when the etag is a valid md5 hash', function () {
|
||||
let hash
|
||||
beforeEach(async function () {
|
||||
S3.mockSend(S3.HeadObjectCommand, {
|
||||
ContentLength: objectSize,
|
||||
ETag: md5,
|
||||
})
|
||||
hash = await S3Persistor.getObjectMd5Hash(bucket, key)
|
||||
})
|
||||
|
||||
@@ -640,33 +646,34 @@ describe('S3PersistorTests', function () {
|
||||
})
|
||||
|
||||
it('should get the hash from the object metadata', function () {
|
||||
expect(S3Client.headObject).to.have.been.calledWith({
|
||||
S3.assertSendCalledWith(S3.HeadObjectCommand, {
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
})
|
||||
})
|
||||
|
||||
it('should not download the object', function () {
|
||||
expect(S3Client.getObject).not.to.have.been.called
|
||||
S3.assertSendNotCalledWith(S3.GetObjectCommand)
|
||||
})
|
||||
})
|
||||
|
||||
describe("when the etag isn't a valid md5 hash", function () {
|
||||
let hash
|
||||
beforeEach(async function () {
|
||||
S3Client.headObject = sinon.stub().returns({
|
||||
promise: sinon.stub().resolves({
|
||||
ETag: 'somethingthatisntanmd5',
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
}),
|
||||
S3.mockSend(S3.GetObjectCommand, {
|
||||
ContentLength: objectSize,
|
||||
ETag: md5,
|
||||
})
|
||||
S3.mockSend(S3.HeadObjectCommand, {
|
||||
ETag: 'somethingthatisntanmd5',
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
})
|
||||
|
||||
hash = await S3Persistor.getObjectMd5Hash(bucket, key)
|
||||
})
|
||||
|
||||
it('should re-fetch the file to verify it', function () {
|
||||
expect(S3Client.getObject).to.have.been.calledWith({
|
||||
S3.assertSendCalledWith(S3.GetObjectCommand, {
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
})
|
||||
@@ -685,14 +692,15 @@ describe('S3PersistorTests', function () {
|
||||
describe('copyObject', function () {
|
||||
describe('with valid parameters', function () {
|
||||
beforeEach(async function () {
|
||||
return S3Persistor.copyObject(bucket, key, destKey)
|
||||
S3.mockSend(S3.CopyObjectCommand)
|
||||
await S3Persistor.copyObject(bucket, key, destKey)
|
||||
})
|
||||
|
||||
it('should copy the object', function () {
|
||||
expect(S3Client.copyObject).to.have.been.calledWith({
|
||||
S3.assertSendCalledWith(S3.CopyObjectCommand, {
|
||||
Bucket: bucket,
|
||||
Key: destKey,
|
||||
CopySource: `${bucket}/${key}`,
|
||||
CopySource: `/${bucket}/${key}`,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -701,9 +709,7 @@ describe('S3PersistorTests', function () {
|
||||
let error
|
||||
|
||||
beforeEach(async function () {
|
||||
S3Client.copyObject = sinon.stub().returns({
|
||||
promise: sinon.stub().rejects(S3NotFoundError),
|
||||
})
|
||||
S3.mockSend(S3.CopyObjectCommand, S3NotFoundError, { rejects: true })
|
||||
try {
|
||||
await S3Persistor.copyObject(bucket, key, destKey)
|
||||
} catch (err) {
|
||||
@@ -720,11 +726,12 @@ describe('S3PersistorTests', function () {
|
||||
describe('deleteObject', function () {
|
||||
describe('with valid parameters', function () {
|
||||
beforeEach(async function () {
|
||||
return S3Persistor.deleteObject(bucket, key)
|
||||
S3.mockSend(S3.DeleteObjectCommand)
|
||||
await S3Persistor.deleteObject(bucket, key)
|
||||
})
|
||||
|
||||
it('should delete the object', function () {
|
||||
expect(S3Client.deleteObject).to.have.been.calledWith({
|
||||
S3.assertSendCalledWith(S3.DeleteObjectCommand, {
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
})
|
||||
@@ -735,68 +742,76 @@ describe('S3PersistorTests', function () {
|
||||
describe('deleteDirectory', function () {
|
||||
describe('with valid parameters', function () {
|
||||
beforeEach(async function () {
|
||||
return S3Persistor.deleteDirectory(bucket, key)
|
||||
S3.mockSend(S3.ListObjectsV2Command, { Contents: files })
|
||||
await S3Persistor.deleteDirectory(bucket, key)
|
||||
})
|
||||
|
||||
it('should list the objects in the directory', function () {
|
||||
expect(S3Client.listObjectsV2).to.have.been.calledWith({
|
||||
S3.assertSendCalledWith(S3.ListObjectsV2Command, {
|
||||
Bucket: bucket,
|
||||
Prefix: key,
|
||||
})
|
||||
})
|
||||
|
||||
it('should delete the objects using their keys', function () {
|
||||
expect(S3Client.deleteObjects).to.have.been.calledWith({
|
||||
Bucket: bucket,
|
||||
Delete: {
|
||||
Objects: [{ Key: 'llama' }, { Key: 'hippo' }],
|
||||
Quiet: true,
|
||||
S3.s3ClientStub.send.withArgs(new S3.DeleteObjectsCommand()).resolves()
|
||||
S3.assertSendCalledWith(
|
||||
S3.DeleteObjectsCommand,
|
||||
{
|
||||
Bucket: bucket,
|
||||
Delete: {
|
||||
Objects: [{ Key: 'llama' }, { Key: 'hippo' }],
|
||||
Quiet: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
1
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when there are no files', function () {
|
||||
beforeEach(async function () {
|
||||
S3Client.listObjectsV2 = sinon
|
||||
.stub()
|
||||
.returns({ promise: sinon.stub().resolves({ Contents: [] }) })
|
||||
return S3Persistor.deleteDirectory(bucket, key)
|
||||
S3.mockSend(S3.ListObjectsV2Command, { Contents: [] })
|
||||
await S3Persistor.deleteDirectory(bucket, key)
|
||||
})
|
||||
|
||||
it('should list the objects in the directory', function () {
|
||||
expect(S3Client.listObjectsV2).to.have.been.calledWith({
|
||||
S3.assertSendCalledWith(S3.ListObjectsV2Command, {
|
||||
Bucket: bucket,
|
||||
Prefix: key,
|
||||
})
|
||||
})
|
||||
|
||||
it('should not try to delete any objects', function () {
|
||||
expect(S3Client.deleteObjects).not.to.have.been.called
|
||||
S3.assertSendNotCalledWith(S3.DeleteObjectsCommand)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when there are more files available', function () {
|
||||
const continuationToken = 'wombat'
|
||||
beforeEach(async function () {
|
||||
S3Client.listObjectsV2.onCall(0).returns({
|
||||
promise: sinon.stub().resolves({
|
||||
S3.mockSend(
|
||||
S3.ListObjectsV2Command,
|
||||
{
|
||||
Contents: files,
|
||||
IsTruncated: true,
|
||||
NextContinuationToken: continuationToken,
|
||||
}),
|
||||
})
|
||||
|
||||
},
|
||||
{
|
||||
nextResponses: [{ Contents: [{ Key: 'last-file', Size: 33 }] }],
|
||||
}
|
||||
)
|
||||
S3.mockSend(S3.DeleteObjectsCommand)
|
||||
return S3Persistor.deleteDirectory(bucket, key)
|
||||
})
|
||||
|
||||
it('should list the objects a second time, with a continuation token', function () {
|
||||
expect(S3Client.listObjectsV2).to.be.calledTwice
|
||||
expect(S3Client.listObjectsV2).to.be.calledWith({
|
||||
S3.assertSendCallCount(S3.ListObjectsV2Command, 2)
|
||||
expect(S3.s3ClientStub.send.firstCall.args[0].payload).to.deep.equal({
|
||||
Bucket: bucket,
|
||||
Prefix: key,
|
||||
})
|
||||
expect(S3Client.listObjectsV2).to.be.calledWith({
|
||||
expect(S3.s3ClientStub.send.thirdCall.args[0].payload).to.deep.equal({
|
||||
Bucket: bucket,
|
||||
Prefix: key,
|
||||
ContinuationToken: continuationToken,
|
||||
@@ -804,7 +819,7 @@ describe('S3PersistorTests', function () {
|
||||
})
|
||||
|
||||
it('should delete both sets of files', function () {
|
||||
expect(S3Client.deleteObjects).to.have.been.calledTwice
|
||||
S3.assertSendCallCount(S3.DeleteObjectsCommand, 2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -812,9 +827,7 @@ describe('S3PersistorTests', function () {
|
||||
let error
|
||||
|
||||
beforeEach(async function () {
|
||||
S3Client.listObjectsV2 = sinon
|
||||
.stub()
|
||||
.returns({ promise: sinon.stub().rejects(genericError) })
|
||||
S3.mockSend(S3.ListObjectsV2Command, genericError, { rejects: true })
|
||||
try {
|
||||
await S3Persistor.deleteDirectory(bucket, key)
|
||||
} catch (err) {
|
||||
@@ -831,7 +844,8 @@ describe('S3PersistorTests', function () {
|
||||
})
|
||||
|
||||
it('should not try to delete any objects', function () {
|
||||
expect(S3Client.deleteObjects).not.to.have.been.called
|
||||
// call count should be 1, only the ListObjectsV2Command tested above
|
||||
expect(S3.s3ClientStub.send.callCount).to.equal(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -839,9 +853,8 @@ describe('S3PersistorTests', function () {
|
||||
let error
|
||||
|
||||
beforeEach(async function () {
|
||||
S3Client.deleteObjects = sinon
|
||||
.stub()
|
||||
.returns({ promise: sinon.stub().rejects(genericError) })
|
||||
S3.mockSend(S3.ListObjectsV2Command, { Contents: files })
|
||||
S3.mockSend(S3.DeleteObjectsCommand, genericError, { rejects: true })
|
||||
try {
|
||||
await S3Persistor.deleteDirectory(bucket, key)
|
||||
} catch (err) {
|
||||
@@ -864,11 +877,12 @@ describe('S3PersistorTests', function () {
|
||||
let size
|
||||
|
||||
beforeEach(async function () {
|
||||
S3.mockSend(S3.ListObjectsV2Command, { Contents: files })
|
||||
size = await S3Persistor.directorySize(bucket, key)
|
||||
})
|
||||
|
||||
it('should list the objects in the directory', function () {
|
||||
expect(S3Client.listObjectsV2).to.have.been.calledWith({
|
||||
S3.assertSendCalledWith(S3.ListObjectsV2Command, {
|
||||
Bucket: bucket,
|
||||
Prefix: key,
|
||||
})
|
||||
@@ -883,14 +897,12 @@ describe('S3PersistorTests', function () {
|
||||
let size
|
||||
|
||||
beforeEach(async function () {
|
||||
S3Client.listObjectsV2 = sinon
|
||||
.stub()
|
||||
.returns({ promise: sinon.stub().resolves({ Contents: [] }) })
|
||||
S3.mockSend(S3.ListObjectsV2Command, { Contents: [] })
|
||||
size = await S3Persistor.directorySize(bucket, key)
|
||||
})
|
||||
|
||||
it('should list the objects in the directory', function () {
|
||||
expect(S3Client.listObjectsV2).to.have.been.calledWith({
|
||||
S3.assertSendCalledWith(S3.ListObjectsV2Command, {
|
||||
Bucket: bucket,
|
||||
Prefix: key,
|
||||
})
|
||||
@@ -905,24 +917,28 @@ describe('S3PersistorTests', function () {
|
||||
const continuationToken = 'wombat'
|
||||
let size
|
||||
beforeEach(async function () {
|
||||
S3Client.listObjectsV2.onCall(0).returns({
|
||||
promise: sinon.stub().resolves({
|
||||
S3.mockSend(
|
||||
S3.ListObjectsV2Command,
|
||||
{
|
||||
Contents: files,
|
||||
IsTruncated: true,
|
||||
NextContinuationToken: continuationToken,
|
||||
}),
|
||||
})
|
||||
},
|
||||
{
|
||||
nextResponses: [{ Contents: [{ Key: 'last-file', Size: 33 }] }],
|
||||
}
|
||||
)
|
||||
|
||||
size = await S3Persistor.directorySize(bucket, key)
|
||||
})
|
||||
|
||||
it('should list the objects a second time, with a continuation token', function () {
|
||||
expect(S3Client.listObjectsV2).to.be.calledTwice
|
||||
expect(S3Client.listObjectsV2).to.be.calledWith({
|
||||
S3.assertSendCallCount(S3.ListObjectsV2Command, 2)
|
||||
expect(S3.s3ClientStub.send.firstCall.args[0].payload).to.deep.equal({
|
||||
Bucket: bucket,
|
||||
Prefix: key,
|
||||
})
|
||||
expect(S3Client.listObjectsV2).to.be.calledWith({
|
||||
expect(S3.s3ClientStub.send.secondCall.args[0].payload).to.deep.equal({
|
||||
Bucket: bucket,
|
||||
Prefix: key,
|
||||
ContinuationToken: continuationToken,
|
||||
@@ -938,9 +954,7 @@ describe('S3PersistorTests', function () {
|
||||
let error
|
||||
|
||||
beforeEach(async function () {
|
||||
S3Client.listObjectsV2 = sinon
|
||||
.stub()
|
||||
.returns({ promise: sinon.stub().rejects(genericError) })
|
||||
S3.mockSend(S3.ListObjectsV2Command, genericError, { rejects: true })
|
||||
try {
|
||||
await S3Persistor.directorySize(bucket, key)
|
||||
} catch (err) {
|
||||
@@ -963,11 +977,12 @@ describe('S3PersistorTests', function () {
|
||||
let exists
|
||||
|
||||
beforeEach(async function () {
|
||||
S3.mockSend(S3.HeadObjectCommand, { ContentLength: objectSize })
|
||||
exists = await S3Persistor.checkIfObjectExists(bucket, key)
|
||||
})
|
||||
|
||||
it('should get the object header', function () {
|
||||
expect(S3Client.headObject).to.have.been.calledWith({
|
||||
S3.assertSendCalledWith(S3.HeadObjectCommand, {
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
})
|
||||
@@ -982,14 +997,13 @@ describe('S3PersistorTests', function () {
|
||||
let exists
|
||||
|
||||
beforeEach(async function () {
|
||||
S3Client.headObject = sinon
|
||||
.stub()
|
||||
.returns({ promise: sinon.stub().rejects(S3NotFoundError) })
|
||||
S3.mockSend(S3.HeadObjectCommand, S3NotFoundError, { rejects: true })
|
||||
S3.s3ClientStub.send.rejects(S3NotFoundError)
|
||||
exists = await S3Persistor.checkIfObjectExists(bucket, key)
|
||||
})
|
||||
|
||||
it('should get the object header', function () {
|
||||
expect(S3Client.headObject).to.have.been.calledWith({
|
||||
S3.assertSendCalledWith(S3.HeadObjectCommand, {
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
})
|
||||
@@ -1004,9 +1018,7 @@ describe('S3PersistorTests', function () {
|
||||
let error
|
||||
|
||||
beforeEach(async function () {
|
||||
S3Client.headObject = sinon
|
||||
.stub()
|
||||
.returns({ promise: sinon.stub().rejects(genericError) })
|
||||
S3.mockSend(S3.HeadObjectCommand, genericError, { rejects: true })
|
||||
try {
|
||||
await S3Persistor.checkIfObjectExists(bucket, key)
|
||||
} catch (err) {
|
||||
@@ -1027,22 +1039,4 @@ describe('S3PersistorTests', function () {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('_getClientForBucket', function () {
|
||||
it('should return same instance for same bucket', function () {
|
||||
const a = S3Persistor._getClientForBucket('foo')
|
||||
const b = S3Persistor._getClientForBucket('foo')
|
||||
expect(a).to.equal(b)
|
||||
})
|
||||
it('should return different instance for different bucket', function () {
|
||||
const a = S3Persistor._getClientForBucket('foo')
|
||||
const b = S3Persistor._getClientForBucket('bar')
|
||||
expect(a).to.not.equal(b)
|
||||
})
|
||||
it('should return different instance for same bucket different computeChecksums', function () {
|
||||
const a = S3Persistor._getClientForBucket('foo', false)
|
||||
const b = S3Persistor._getClientForBucket('foo', true)
|
||||
expect(a).to.not.equal(b)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
6895
package-lock.json
generated
6895
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -367,6 +367,7 @@ if (process.env.OVERLEAF_EMAIL_FROM_ADDRESS != null) {
|
||||
// AWS Creds
|
||||
AWSAccessKeyID: process.env.OVERLEAF_EMAIL_AWS_SES_ACCESS_KEY_ID,
|
||||
AWSSecretKey: process.env.OVERLEAF_EMAIL_AWS_SES_SECRET_KEY,
|
||||
region: process.env.OVERLEAF_EMAIL_AWS_SES_REGION || 'us-east-1',
|
||||
|
||||
// SMTP Creds
|
||||
host: process.env.OVERLEAF_EMAIL_SMTP_HOST,
|
||||
@@ -383,10 +384,6 @@ if (process.env.OVERLEAF_EMAIL_FROM_ADDRESS != null) {
|
||||
},
|
||||
}
|
||||
|
||||
if (process.env.OVERLEAF_EMAIL_AWS_SES_REGION != null) {
|
||||
settings.email.parameters.region = process.env.OVERLEAF_EMAIL_AWS_SES_REGION
|
||||
}
|
||||
|
||||
if (
|
||||
process.env.OVERLEAF_EMAIL_SMTP_USER != null ||
|
||||
process.env.OVERLEAF_EMAIL_SMTP_PASS != null
|
||||
|
||||
@@ -27,6 +27,7 @@ services:
|
||||
AWS_S3_PATH_STYLE: 'true'
|
||||
AWS_ACCESS_KEY_ID: OVERLEAF_FILESTORE_S3_ACCESS_KEY_ID
|
||||
AWS_SECRET_ACCESS_KEY: OVERLEAF_FILESTORE_S3_SECRET_ACCESS_KEY
|
||||
AWS_REGION: us-east-1
|
||||
MINIO_ROOT_USER: MINIO_ROOT_USER
|
||||
MINIO_ROOT_PASSWORD: MINIO_ROOT_PASSWORD
|
||||
GCS_API_ENDPOINT: http://gcs:9090
|
||||
|
||||
@@ -44,6 +44,7 @@ services:
|
||||
AWS_S3_PATH_STYLE: 'true'
|
||||
AWS_ACCESS_KEY_ID: OVERLEAF_FILESTORE_S3_ACCESS_KEY_ID
|
||||
AWS_SECRET_ACCESS_KEY: OVERLEAF_FILESTORE_S3_SECRET_ACCESS_KEY
|
||||
AWS_REGION: us-east-1
|
||||
MINIO_ROOT_USER: MINIO_ROOT_USER
|
||||
MINIO_ROOT_PASSWORD: MINIO_ROOT_PASSWORD
|
||||
GCS_API_ENDPOINT: http://gcs:9090
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"types:check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.864.0",
|
||||
"@overleaf/logger": "*",
|
||||
"@overleaf/metrics": "*",
|
||||
"@overleaf/o-error": "*",
|
||||
|
||||
@@ -13,6 +13,7 @@ const streamifier = require('streamifier')
|
||||
chai.use(require('chai-as-promised'))
|
||||
const { ObjectId } = require('mongodb')
|
||||
const ChildProcess = require('node:child_process')
|
||||
const { ListObjectsV2Command } = require('@aws-sdk/client-s3')
|
||||
|
||||
const fsWriteFile = promisify(fs.writeFile)
|
||||
const fsStat = promisify(fs.stat)
|
||||
@@ -532,19 +533,13 @@ describe('Filestore', function () {
|
||||
...s3Config(),
|
||||
key: process.env.MINIO_ROOT_USER,
|
||||
secret: process.env.MINIO_ROOT_PASSWORD,
|
||||
})._getClientForBucket(bucketName)
|
||||
await s3
|
||||
.createBucket({
|
||||
Bucket: bucketName,
|
||||
})
|
||||
.promise()
|
||||
await s3
|
||||
.upload({
|
||||
Bucket: bucketName,
|
||||
Key: fileId,
|
||||
Body: constantFileContent,
|
||||
})
|
||||
.promise()
|
||||
})
|
||||
await s3._createBucket(bucketName)
|
||||
await s3._upload(bucketName, {
|
||||
Bucket: bucketName,
|
||||
Key: fileId,
|
||||
Body: constantFileContent,
|
||||
})
|
||||
})
|
||||
|
||||
it('should get the file from the specified bucket', async function () {
|
||||
@@ -1274,22 +1269,22 @@ describe('Filestore', function () {
|
||||
}) {
|
||||
await createRandomContent(fileUrl1)
|
||||
|
||||
const { Contents: dekEntries } = await s3Client
|
||||
.listObjectsV2({
|
||||
const { Contents: dekEntries } = await s3Client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: process.env.AWS_S3_USER_FILES_DEK_BUCKET_NAME,
|
||||
Prefix: `${projectId}/`,
|
||||
})
|
||||
.promise()
|
||||
)
|
||||
expect(dekEntries).to.have.length(dekBucketKeys.length)
|
||||
// Order is not predictable, use members
|
||||
expect(dekEntries.map(o => o.Key)).to.have.members(dekBucketKeys)
|
||||
|
||||
const { Contents: userFilesEntries } = await s3Client
|
||||
.listObjectsV2({
|
||||
const { Contents: userFilesEntries } = await s3Client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: backendSettings.stores.user_files,
|
||||
Prefix: `${projectId}/`,
|
||||
})
|
||||
.promise()
|
||||
)
|
||||
expect(userFilesEntries).to.have.length(userFilesBucketKeys.length)
|
||||
// Order is not predictable, use members
|
||||
expect(userFilesEntries.map(o => o.Key)).to.have.members(
|
||||
|
||||
@@ -43,6 +43,7 @@ services:
|
||||
AWS_S3_PATH_STYLE: 'true'
|
||||
AWS_ACCESS_KEY_ID: OVERLEAF_HISTORY_S3_ACCESS_KEY_ID
|
||||
AWS_SECRET_ACCESS_KEY: OVERLEAF_HISTORY_S3_SECRET_ACCESS_KEY
|
||||
AWS_REGION: us-east-1
|
||||
MINIO_ROOT_USER: MINIO_ROOT_USER
|
||||
MINIO_ROOT_PASSWORD: MINIO_ROOT_PASSWORD
|
||||
GCS_API_ENDPOINT: http://gcs:9090
|
||||
|
||||
@@ -60,6 +60,7 @@ services:
|
||||
AWS_S3_PATH_STYLE: 'true'
|
||||
AWS_ACCESS_KEY_ID: OVERLEAF_HISTORY_S3_ACCESS_KEY_ID
|
||||
AWS_SECRET_ACCESS_KEY: OVERLEAF_HISTORY_S3_SECRET_ACCESS_KEY
|
||||
AWS_REGION: us-east-1
|
||||
MINIO_ROOT_USER: MINIO_ROOT_USER
|
||||
MINIO_ROOT_PASSWORD: MINIO_ROOT_PASSWORD
|
||||
GCS_API_ENDPOINT: http://gcs:9090
|
||||
|
||||
@@ -15,6 +15,7 @@ import { makeProjectKey } from '../../../../storage/lib/blob_store/index.js'
|
||||
import config from 'config'
|
||||
import Stream from 'stream'
|
||||
import projectKey from '../../../../storage/lib/project_key.js'
|
||||
import { ListObjectsV2Command } from '@aws-sdk/client-s3'
|
||||
|
||||
/**
|
||||
* @typedef {import("node-fetch").Response} Response
|
||||
@@ -32,9 +33,11 @@ const deletedProjectsCollection = db.collection('deletedProjects')
|
||||
async function listS3Bucket(bucket, prefix) {
|
||||
// @ts-ignore access to internal library helper
|
||||
const client = backupPersistor._getClientForBucket(bucket)
|
||||
const response = await client
|
||||
.listObjectsV2({ Bucket: bucket, Prefix: prefix })
|
||||
.promise()
|
||||
|
||||
const response = await client.send(
|
||||
new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix })
|
||||
)
|
||||
|
||||
return (response.Contents || []).map(item => item.Key || '')
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
chunksBucket,
|
||||
} from '../../../../storage/lib/backupPersistor.mjs'
|
||||
import { Readable } from 'node:stream'
|
||||
import { ListObjectsV2Command } from '@aws-sdk/client-s3'
|
||||
|
||||
const projectsCollection = client.db().collection('projects')
|
||||
|
||||
@@ -583,11 +584,13 @@ describe('backup script', function () {
|
||||
// Get all chunks and verify they were backed up
|
||||
const listing = await backupPersistor
|
||||
._getClientForBucket(chunksBucket)
|
||||
.listObjectsV2({
|
||||
Bucket: chunksBucket,
|
||||
Prefix: projectKey.format(historyId) + '/',
|
||||
})
|
||||
.promise()
|
||||
.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: chunksBucket,
|
||||
Prefix: projectKey.format(historyId) + '/',
|
||||
})
|
||||
)
|
||||
|
||||
const chunkKeys = listing.Contents.map(item => item.Key)
|
||||
expect(chunkKeys.length).to.equal(6) // Should have multiple chunks
|
||||
|
||||
|
||||
@@ -25,10 +25,11 @@ import {
|
||||
} from '../../../../storage/lib/backupPersistor.mjs'
|
||||
import { WritableBuffer } from '@overleaf/stream-utils'
|
||||
import cleanup from './support/cleanup.js'
|
||||
import { ListObjectsV2Command } from '@aws-sdk/client-s3'
|
||||
|
||||
async function listS3BucketRaw(bucket) {
|
||||
const client = backupPersistor._getClientForBucket(bucket)
|
||||
return await client.listObjectsV2({ Bucket: bucket }).promise()
|
||||
return await client.send(new ListObjectsV2Command({ Bucket: bucket }))
|
||||
}
|
||||
|
||||
async function listS3Bucket(bucket, wantStorageClass) {
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
"@overleaf/settings": "*",
|
||||
"@overleaf/stream-utils": "*",
|
||||
"async": "^3.2.5",
|
||||
"aws-sdk": "^2.650.0",
|
||||
"body-parser": "^1.20.3",
|
||||
"bunyan": "^1.8.15",
|
||||
"celebrate": "^15.0.3",
|
||||
|
||||
@@ -3,7 +3,7 @@ const logger = require('@overleaf/logger')
|
||||
const metrics = require('@overleaf/metrics')
|
||||
const Settings = require('@overleaf/settings')
|
||||
const nodemailer = require('nodemailer')
|
||||
const sesTransport = require('nodemailer-ses-transport')
|
||||
const aws = require('@aws-sdk/client-ses')
|
||||
const OError = require('@overleaf/o-error')
|
||||
const { RateLimiter } = require('../../infrastructure/RateLimiter')
|
||||
const _ = require('lodash')
|
||||
@@ -30,7 +30,15 @@ function getClient() {
|
||||
const emailParameters = EMAIL_SETTINGS.parameters
|
||||
if (emailParameters.AWSAccessKeyID || EMAIL_SETTINGS.driver === 'ses') {
|
||||
logger.debug('using aws ses for email')
|
||||
client = nodemailer.createTransport(sesTransport(emailParameters))
|
||||
const ses = new aws.SESClient({
|
||||
apiVersion: '2010-12-01',
|
||||
region: emailParameters.region,
|
||||
credentials: {
|
||||
accessKeyId: emailParameters.AWSAccessKeyID,
|
||||
secretAccessKey: emailParameters.AWSSecretKey,
|
||||
},
|
||||
})
|
||||
client = nodemailer.createTransport({ SES: { ses, aws } })
|
||||
} else if (emailParameters.sendgridApiKey) {
|
||||
throw new OError(
|
||||
'sendgridApiKey configuration option is deprecated, use SMTP instead'
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"safari > 14"
|
||||
],
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-ses": "^3.864.0",
|
||||
"@contentful/rich-text-html-renderer": "^16.0.2",
|
||||
"@contentful/rich-text-types": "^16.0.2",
|
||||
"@google-cloud/bigquery": "^6.0.1",
|
||||
@@ -150,7 +151,6 @@
|
||||
"nocache": "^2.1.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^6.7.0",
|
||||
"nodemailer-ses-transport": "^1.5.1",
|
||||
"on-headers": "^1.0.2",
|
||||
"otplib": "^12.0.1",
|
||||
"p-limit": "^2.3.0",
|
||||
|
||||
@@ -33,10 +33,12 @@ describe('EmailSender', function () {
|
||||
|
||||
this.ses = { createTransport: () => this.sesClient }
|
||||
|
||||
this.SESClient = sinon.stub()
|
||||
|
||||
this.EmailSender = SandboxedModule.require(MODULE_PATH, {
|
||||
requires: {
|
||||
nodemailer: this.ses,
|
||||
'nodemailer-ses-transport': sinon.stub(),
|
||||
'@aws-sdk/client-ses': { SESClient: this.SESClient },
|
||||
'@overleaf/settings': this.Settings,
|
||||
'../../infrastructure/RateLimiter': this.RateLimiter,
|
||||
'@overleaf/metrics': {
|
||||
|
||||
Reference in New Issue
Block a user