UNPKG

@parse/s3-files-adapter

Version:
284 lines (249 loc) 9.04 kB
// S3Adapter // // Stores Parse files in AWS S3. const { S3Client, CreateBucketCommand, PutObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadBucketCommand, } = require('@aws-sdk/client-s3'); const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); const optionsFromArguments = require('./lib/optionsFromArguments'); const awsCredentialsDeprecationNotice = function awsCredentialsDeprecationNotice() { // eslint-disable-next-line no-console console.warn( 'Passing AWS credentials to this adapter is now DEPRECATED and will be removed in a future version', 'See: https://github.com/parse-server-modules/parse-server-s3-adapter#aws-credentials for details' ); }; const serialize = obj => { const str = []; Object.keys(obj).forEach(key => { if (obj[key]) { str.push(`${encodeURIComponent(key)}=${encodeURIComponent(obj[key])}`); } }); return str.join('&'); }; function buildDirectAccessUrl(baseUrl, baseUrlFileKey, presignedUrl, config, filename) { let directAccessUrl; if (typeof baseUrl === 'function') { directAccessUrl = `${baseUrl(config, filename)}/${baseUrlFileKey}`; } else { directAccessUrl = `${baseUrl}/${baseUrlFileKey}`; } if (presignedUrl) { directAccessUrl += presignedUrl.substring(presignedUrl.indexOf('?')); } 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 // Region will use sane defaults if omitted constructor(...args) { const options = optionsFromArguments(args); this._region = options.region; this._bucket = options.bucket; this._bucketPrefix = options.bucketPrefix; this._directAccess = options.directAccess; this._fileAcl = options.fileAcl; this._baseUrl = options.baseUrl; this._baseUrlDirect = options.baseUrlDirect; this._signatureVersion = options.signatureVersion; this._globalCacheControl = options.globalCacheControl; this._presignedUrl = options.presignedUrl; this._presignedUrlExpires = parseInt(options.presignedUrlExpires, 10); this._encryption = options.ServerSideEncryption; this._generateKey = options.generateKey; this._endpoint = options.s3overrides?.endpoint; // Optional FilesAdaptor method this.validateFilename = options.validateFilename; const s3Options = { params: { Bucket: this._bucket }, region: this._region, signatureVersion: this._signatureVersion, 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; s3Options.secretAccessKey = options.secretKey; } Object.assign(s3Options, options.s3overrides); this._s3Client = new S3Client(s3Options); this._hasBucket = false; } async createBucket() { if (this._hasBucket) { return; } try { // Check if the bucket exists await this._s3Client.send(new HeadBucketCommand({ Bucket: this._bucket })); this._hasBucket = true; } catch (error) { if (error.name !== 'NotFound') { // If the error is something other than "NotFound", rethrow it throw error; } // If the bucket does not exist, attempt to create it try { await this._s3Client.send(new CreateBucketCommand({ Bucket: this._bucket })); this._hasBucket = true; } catch (creationError) { // Handle specific errors during bucket creation if (creationError.name === 'BucketAlreadyExists' || creationError.name === 'BucketAlreadyOwnedByYou') { this._hasBucket = true; } else { throw creationError; } } } } // For a given config object, filename, and data, store a file in S3 // Returns a promise containing the S3 object creation response async createFile(filename, data, contentType, options = {}) { const params = { Bucket: this._bucket, Key: this._bucketPrefix + filename, Body: data, }; if (this._generateKey instanceof Function) { params.Key = this._bucketPrefix + this._generateKey(filename); } if (this._fileAcl) { if (this._fileAcl === 'none') { delete params.ACL; } else { params.ACL = this._fileAcl; } } else if (this._directAccess) { params.ACL = 'public-read'; } if (contentType) { params.ContentType = contentType; } if (this._globalCacheControl) { params.CacheControl = this._globalCacheControl; } if (this._encryption === 'AES256' || this._encryption === 'aws:kms') { params.ServerSideEncryption = this._encryption; } if (options.metadata && typeof options.metadata === 'object') { params.Metadata = options.metadata; } if (options.tags && typeof options.tags === 'object') { const serializedTags = serialize(options.tags); params.Tagging = serializedTags; } await this.createBucket(); const command = new PutObjectCommand(params); const response = await this._s3Client.send(command); const endpoint = this._endpoint || `https://${this._bucket}.s3.${this._region}.amazonaws.com`; const location = `${endpoint}/${params.Key}`; return Object.assign(response || {}, { Location: location }); } 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 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 getFileSignedUrl(client, command, options) { return getSignedUrl(client, command, options); } // Generates and returns the location of a file stored in S3 for the given request and filename // The location is the direct S3 link if the option is set, // otherwise we serve the file through parse-server async getFileLocation(config, filename) { const fileName = filename.split('/').map(encodeURIComponent).join('/'); if (!this._directAccess) { return `${config.mount}/files/${config.applicationId}/${fileName}`; } const fileKey = `${this._bucketPrefix}${fileName}`; let presignedUrl = ''; if (this._presignedUrl) { const params = { Bucket: this._bucket, Key: fileKey }; const options = this._presignedUrlExpires ? { expiresIn: this._presignedUrlExpires } : {}; const command = new GetObjectCommand(params); presignedUrl = await this.getFileSignedUrl(this._s3Client, command, options); if (!this._baseUrl) { return presignedUrl; } } if (!this._baseUrl) { return `https://${this._bucket}.s3.amazonaws.com/${fileKey}`; } const baseUrlFileKey = this._baseUrlDirect ? fileName : fileKey; return buildDirectAccessUrl(this._baseUrl, baseUrlFileKey, presignedUrl, config, filename); } async handleFileStream(filename, req, res) { const params = { Bucket: this._bucket, Key: this._bucketPrefix + filename, Range: req.get('Range'), }; 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); } } module.exports = S3Adapter; module.exports.default = S3Adapter;