Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: Migrate S3 Client from AWS SDK v2 to v3 #220

Closed
wants to merge 19 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
logs
*.log
npm-debug.log*
.env

# Runtime data
pids
Expand Down
189 changes: 115 additions & 74 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
//
// Stores Parse files in AWS S3.

const AWS = require('aws-sdk');
const { S3Client, CreateBucketCommand, PutObjectCommand, DeleteObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const deasync = require('deasync');
const optionsFromArguments = require('./lib/optionsFromArguments');

const awsCredentialsDeprecationNotice = function awsCredentialsDeprecationNotice() {
Expand Down Expand Up @@ -36,6 +38,15 @@ function buildDirectAccessUrl(baseUrl, baseUrlFileKey, presignedUrl, config, fil
return directAccessUrl;
}

function responseToBuffer(response) {
return new Promise((resolve, reject) => {
const chunks = [];
response.Body.on('data', (chunk) => chunks.push(chunk));
response.Body.on('end', () => resolve(Buffer.concat(chunks)));
response.Body.on('error', reject);
});
}

class S3Adapter {
// Creates an S3 session.
// Providing AWS access, secret keys and bucket are mandatory
Expand Down Expand Up @@ -65,6 +76,16 @@ class S3Adapter {
globalCacheControl: this._globalCacheControl,
};

if (options.accessKey && options.secretKey) {
awsCredentialsDeprecationNotice();
s3Options.credentials = {
accessKeyId: options.accessKey,
secretAccessKey: options.secretKey,
};
} else if (options.credentials)
s3Options.credentials = options.credentials;


if (options.accessKey && options.secretKey) {
awsCredentialsDeprecationNotice();
s3Options.accessKeyId = options.accessKey;
Expand All @@ -73,29 +94,28 @@ class S3Adapter {

Object.assign(s3Options, options.s3overrides);

this._s3Client = new AWS.S3(s3Options);
this._s3Client = new S3Client(s3Options);
this._hasBucket = false;
}

createBucket() {
let promise;
if (this._hasBucket) {
promise = Promise.resolve();
} else {
promise = new Promise((resolve) => {
this._s3Client.createBucket(() => {
this._hasBucket = true;
resolve();
});
});
async createBucket() {
if (this._hasBucket) return;

try {
await this._s3Client.send(new CreateBucketCommand({ Bucket: this._bucket }));
this._hasBucket = true;
} catch (error) {
if (error.name === 'BucketAlreadyOwnedByYou')
this._hasBucket = true;
else throw error;
}
return promise;
}

// For a given config object, filename, and data, store a file in S3
// Returns a promise containing the S3 object creation response
createFile(filename, data, contentType, options = {}) {
async createFile(filename, data, contentType, options = {}) {
const params = {
Bucket: this._bucket,
Key: this._bucketPrefix + filename,
Body: data,
};
Expand Down Expand Up @@ -128,46 +148,67 @@ class S3Adapter {
const serializedTags = serialize(options.tags);
params.Tagging = serializedTags;
}
return this.createBucket().then(() => new Promise((resolve, reject) => {
this._s3Client.upload(params, (err, response) => {
if (err !== null) {
return reject(err);
}
return resolve(response);
});
}));
await this.createBucket();
const command = new PutObjectCommand(params);
const response = await this._s3Client.send(command);
const location = `https://${this._bucket}.s3.${this._region}.amazonaws.com/${params.Key}`;

return Object.assign(response || {}, { Location: location });
}

deleteFile(filename) {
return this.createBucket().then(() => new Promise((resolve, reject) => {
const params = {
Key: this._bucketPrefix + filename,
};
this._s3Client.deleteObject(params, (err, data) => {
if (err !== null) {
return reject(err);
}
return resolve(data);
});
}));
async deleteFile(filename) {
const params = {
Bucket: this._bucket,
Key: this._bucketPrefix + filename,
};
await this.createBucket()
const command = new DeleteObjectCommand(params);
const response = await this._s3Client.send(command)
return response;
}

// Search for and return a file if found by filename
// Returns a promise that succeeds with the buffer result from S3
getFileData(filename) {
const params = { Key: this._bucketPrefix + filename };
return this.createBucket().then(() => new Promise((resolve, reject) => {
this._s3Client.getObject(params, (err, data) => {
if (err !== null) {
return reject(err);
}
// Something happened here...
if (data && !data.Body) {
return reject(data);
}
return resolve(data.Body);
async getFileData(filename) {
const params = {
Bucket: this._bucket,
Key: this._bucketPrefix + filename,
};
await this.createBucket()
const command = new GetObjectCommand(params);
const response = await this._s3Client.send(command);
if (response && !response.Body) throw new Error(response);

const buffer = await responseToBuffer(response);
return buffer;
}

// Exposed only for testing purposes
getSignedUrlSync(client, command, options) {
let isDone = false;
let signedUrl = '';
let error = null;

getSignedUrl(client, command, options)
.then((url) => {
signedUrl = url;
isDone = true;
})
.catch((err) => {
error = err;
isDone = true;
});
}));

// Block the event loop until the promise resolves
while (!isDone) {
deasync.sleep(10); // Sleep for 100 milliseconds
}

if (error) {
throw error;
}

return signedUrl;
}

// Generates and returns the location of a file stored in S3 for the given request and filename
Expand All @@ -184,12 +225,11 @@ class S3Adapter {
let presignedUrl = '';
if (this._presignedUrl) {
const params = { Bucket: this._bucket, Key: fileKey };
if (this._presignedUrlExpires) {
params.Expires = this._presignedUrlExpires;
}
// Always use the "getObject" operation, and we recommend that you protect the URL
// appropriately: https://docs.aws.amazon.com/AmazonS3/latest/dev/ShareObjectPreSignedURL.html
presignedUrl = this._s3Client.getSignedUrl('getObject', params);
const options = this._presignedUrlExpires ? { expiresIn: this._presignedUrlExpires } : {};

const command = new GetObjectCommand(params);
presignedUrl = this.getSignedUrlSync(this._s3Client, command, options);

if (!this._baseUrl) {
return presignedUrl;
}
Expand All @@ -203,30 +243,31 @@ class S3Adapter {
return buildDirectAccessUrl(this._baseUrl, baseUrlFileKey, presignedUrl, config, filename);
}

handleFileStream(filename, req, res) {
async handleFileStream(filename, req, res) {
const params = {
Bucket: this._bucket,
Key: this._bucketPrefix + filename,
Range: req.get('Range'),
};
return this.createBucket().then(() => new Promise((resolve, reject) => {
this._s3Client.getObject(params, (error, data) => {
if (error !== null) {
return reject(error);
}
if (data && !data.Body) {
return reject(data);
}
res.writeHead(206, {
'Accept-Ranges': data.AcceptRanges,
'Content-Length': data.ContentLength,
'Content-Range': data.ContentRange,
'Content-Type': data.ContentType,
});
res.write(data.Body);
res.end();
return resolve(data.Body);
});
}));

await this.createBucket();
const command = new GetObjectCommand(params);
const data = await this._s3Client.send(command);
if (data && !data.Body) throw new Error("S3 object body is missing.");

res.writeHead(206, {
'Accept-Ranges': data.AcceptRanges,
'Content-Length': data.ContentLength,
'Content-Range': data.ContentRange,
'Content-Type': data.ContentType,
});
data.Body.on('data', (chunk) => res.write(chunk));
data.Body.on('end', () => res.end());
data.Body.on('error', (e) => {
res.status(404);
res.send(e.message);
});
return responseToBuffer(data);
}
}

Expand Down
1 change: 1 addition & 0 deletions lib/optionsFromArguments.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const optionsFromArguments = function optionsFromArguments(args) {

if (otherOptions) {
options.bucketPrefix = otherOptions.bucketPrefix;
options.credentials = otherOptions.credentials;
options.directAccess = otherOptions.directAccess;
options.fileAcl = otherOptions.fileAcl;
options.baseUrl = otherOptions.baseUrl;
Expand Down
Loading
Loading