UNPKG

@d3oxy/s3-pilot

Version:

A TypeScript wrapper for AWS S3 with support for multiple clients, buckets, and secure file downloads.

546 lines (545 loc) • 26.3 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __asyncValues = (this && this.__asyncValues) || function (o) { if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined."); var m = o[Symbol.asyncIterator], i; return m ? m.call(o) : (o = typeof __values === "function" ? __values(o) : o[Symbol.iterator](), i = {}, verb("next"), verb("throw"), verb("return"), i[Symbol.asyncIterator] = function () { return this; }, i); function verb(n) { i[n] = o[n] && function (v) { return new Promise(function (resolve, reject) { v = o[n](v), settle(resolve, reject, v.done, v.value); }); }; } function settle(resolve, reject, d, v) { Promise.resolve(v).then(function(v) { resolve({ value: v, done: d }); }, reject); } }; Object.defineProperty(exports, "__esModule", { value: true }); exports.S3Pilot = void 0; const client_s3_1 = require("@aws-sdk/client-s3"); const s3_request_presigner_1 = require("@aws-sdk/s3-request-presigner"); /** * S3Pilot class abstracts interactions with AWS S3 SDK to provide a cleaner API. * @template Clients - Mapping of client names to their configurations. */ class S3Pilot { /** * Creates an instance of the S3Pilot class. * @param config - The configuration object for setting up S3 clients. */ constructor(config) { this.clientInstances = {}; for (const clientName in config) { const { region, accessKeyId, secretAccessKey, buckets, additionalConfig, keyPrefix, enableDefaultAllowedExtensions, allowedExtensions, validateBucketOnInit = false, } = config[clientName]; const s3Client = new client_s3_1.S3Client(Object.assign({ region, credentials: { accessKeyId, secretAccessKey, } }, additionalConfig)); // Validate each bucket if (validateBucketOnInit) { for (const bucket of buckets) { this.validateBucketOnInit(s3Client, bucket); } } this.clientInstances[clientName] = { s3: s3Client, buckets: new Set(buckets), keyPrefix: keyPrefix, region: region, enableDefaultAllowedExtensions: enableDefaultAllowedExtensions, allowedExtensions: allowedExtensions, validateBucketOnInit: validateBucketOnInit, }; } } /** * Retrieves the URL for a given object key in a specific bucket of a client. * @param clientName - The name of the client. * @param bucket - The bucket object from the client. * @param key - The object key. * @returns A promise that resolves to the URL of the object. */ getUrl(clientName, bucket, key) { return __awaiter(this, void 0, void 0, function* () { this.validateBucket(clientName, bucket); const client = this.getClient(clientName); return `https://${bucket}.s3.${client.region}.amazonaws.com/${key}`; }); } /** * Validates the specified bucket on initialization. * * @param client - The S3 client. * @param bucket - The name of the bucket to validate. * @throws An error if the bucket is not valid or the user does not have access to it. */ validateBucketOnInit(client, bucket) { return __awaiter(this, void 0, void 0, function* () { try { yield client.send(new client_s3_1.HeadBucketCommand({ Bucket: bucket })); } catch (error) { throw new Error(`Bucket ${bucket} is not valid or you do not have access to it.`); } }); } /** * Validates if a bucket is available for a given client. * * @template ClientName - The name of the client. * @param clientName - The name of the client. * @param bucket - The bucket to validate. * @throws {Error} If the bucket is not available for the client. */ validateBucket(clientName, bucket) { if (!this.clientInstances[clientName].buckets.has(bucket)) { throw new Error(`Bucket ${bucket} is not available for client ${String(clientName)}`); } } validateExtension(clientName, extension) { const client = this.getClient(clientName); let allowedExtensions = []; if (client.enableDefaultAllowedExtensions) { allowedExtensions = [ "pdf", "jpg", "jpeg", "JEPG", "png", "heic", "heif", "gif", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "csv", "mp4", "mov", "avi", "mkv", "zip", "rar", "webp", "svg", "json", "ts", ]; } else { allowedExtensions = client.allowedExtensions || []; } if (allowedExtensions.length > 0 && !allowedExtensions.includes(extension)) { throw new Error(`Invalid file extension: ${extension}`); } return; } /** * Retrieves the client instance for the specified client name. * * @template ClientName - The type of the client name. * @param {ClientName} clientName - The name of the client. * @returns {Object} - An object containing the S3 client, a set of buckets, and an optional key prefix. */ getClient(clientName) { return this.clientInstances[clientName]; } /** * Uploads a file stream to the specified bucket for the given client. * * @template ClientName - The name of the client. * @param {ClientName} clientName - The name of the client. * @param {Clients[ClientName]["buckets"][number]} bucket - The bucket to upload the file to. * @param {Object} params - The parameters for the file upload. * @param {string} params.filename - The name of the file. * @param {string} [params.folder] - The folder to upload the file to (optional). * @param {Readable} params.stream - The readable stream to upload. * @param {string} params.contentType - The content type of the file. * @param {number} [params.contentLength] - The content length of the stream (optional, but recommended for large files). * @param {Omit<PutObjectCommand, "Bucket" | "Key" | "Body">} [params.additionalParams] - Additional parameters for the PutObjectCommand (optional). * @returns {Promise<UploadFileStreamResponse>} A promise that resolves to an object containing the URL, key, etag, and versionId of the uploaded file. * @throws {Error} If the file extension is invalid or if the file fails to upload. */ uploadFileStream(clientName, bucket, params) { return __awaiter(this, void 0, void 0, function* () { this.validateBucket(clientName, bucket); const client = this.getClient(clientName); const { filename, folder, stream, contentType, contentLength, additionalParams } = params; // check if the extension is valid const extension = filename.split(".").pop(); if (!extension) { throw new Error("Invalid file extension."); } this.validateExtension(clientName, extension); const key = `${client.keyPrefix ? `${client.keyPrefix}/` : ""}${folder ? `${folder}/` : ""}${filename}`; const command = new client_s3_1.PutObjectCommand(Object.assign(Object.assign(Object.assign({}, additionalParams), { Bucket: bucket, Key: key, Body: stream, ContentType: contentType }), (contentLength && { ContentLength: contentLength }))); const res = yield client.s3.send(command); if (res.$metadata.httpStatusCode !== 200) { throw new Error(`Failed to upload file stream to ${bucket}/${key}`); } // construct the URL of the uploaded file const url = yield this.getUrl(clientName, bucket, key); return { url: url, key: key, etag: res.ETag, versionId: res.VersionId, }; }); } /** * Uploads a file to the specified bucket for the given client. * * @template ClientName - The name of the client. * @param {ClientName} clientName - The name of the client. * @param {Clients[ClientName]["buckets"][number]} bucket - The bucket to upload the file to. * @param {Object} params - The parameters for the file upload. * @param {string} params.filename - The name of the file. * @param {string} [params.folder] - The folder to upload the file to (optional). * @param {Buffer | Uint8Array | Blob | string} params.file - The file to upload. * @param {string} params.contentType - The content type of the file. * @param {Omit<PutObjectCommand, "Bucket" | "Key" | "Body">} [params.additionalParams] - Additional parameters for the PutObjectCommand (optional). * @returns {Promise<UploadFileResponse>} A promise that resolves to an object containing the URL and key of the uploaded file. * @throws {Error} If the file extension is invalid or if the file fails to upload. */ uploadFile(clientName, bucket, params) { return __awaiter(this, void 0, void 0, function* () { this.validateBucket(clientName, bucket); const client = this.getClient(clientName); const { filename, folder, file, contentType, additionalParams } = params; // check if the extension is valid const extension = filename.split(".").pop(); if (!extension) { throw new Error("Invalid file extension."); } this.validateExtension(clientName, extension); const key = `${client.keyPrefix ? `${client.keyPrefix}/` : ""}${folder ? `${folder}/` : ""}${filename}`; const command = new client_s3_1.PutObjectCommand(Object.assign(Object.assign({}, additionalParams), { Bucket: bucket, Key: key, Body: file, ContentType: contentType })); const res = yield client.s3.send(command); if (res.$metadata.httpStatusCode !== 200) { throw new Error(`Failed to upload file to ${bucket}/${key}`); } // construct the URL of the uploaded file const url = yield this.getUrl(clientName, bucket, key); return { url: url, key: key, }; }); } /** * Deletes a file from the specified bucket. * * @param clientName - The name of the client. * @param bucket - The bucket from which to delete the file. * @param params - The parameters for deleting the file. * @param params.key - The key of the file to delete. * @returns A Promise that resolves when the file is successfully deleted. */ deleteFile(clientName, bucket, params) { return __awaiter(this, void 0, void 0, function* () { const { key } = params; this.validateBucket(clientName, bucket); const client = this.getClient(clientName); const command = new client_s3_1.DeleteObjectCommand({ Bucket: bucket, Key: key, }); yield client.s3.send(command); }); } /** * Renames a file in the specified bucket of a client. * * @template ClientName - The name of the client. * @param {ClientName} clientName - The name of the client. * @param {Clients[ClientName]["buckets"][number]} bucket - The bucket in which the file is located. * @param {Object} params - The parameters for renaming the file. * @param {string} params.oldKey - The key of the file to be renamed. * @param {string} [params.newFolder] - The new folder for the renamed file (optional). * @param {string} [params.newFilename] - The new filename for the renamed file (optional). * @returns {Promise<{ * url: string; * key: string; * acl: ObjectCannedACL; * }>} - A promise that resolves to an object containing the URL, key, and ACL of the renamed file. * @deprecated This function is not stable and should not be used. */ renameFile(clientName, bucket, params) { return __awaiter(this, void 0, void 0, function* () { this.validateBucket(clientName, bucket); const client = this.getClient(clientName); const { oldKey, newFolder, newFilename } = params; const extension = oldKey.split(".").pop(); const newKey = `${client.keyPrefix ? `${client.keyPrefix}/` : ""}${newFolder ? `${newFolder}/` : ""}${newFilename}.${extension}`; const copyCommand = new client_s3_1.CopyObjectCommand({ Bucket: bucket, CopySource: `${bucket}/${oldKey}`, Key: newKey, }); yield client.s3.send(copyCommand); yield this.deleteFile(clientName, bucket, { key: oldKey, }); const url = yield this.getUrl(clientName, bucket, newKey); return { url: url, key: newKey, }; }); } /** * Downloads a file from S3 and returns its content as a Buffer. * This method is useful for server-side file processing or when you need to * stream file content to clients through your API server. * * @template ClientName - The name of the client. * @param clientName - The name of the client. * @param bucket - The bucket containing the file. * @param params - The parameters for downloading the file. * @param params.key - The key of the file to download. * @returns A promise that resolves to an object containing the file buffer and metadata. * @throws {Error} If the file cannot be downloaded or doesn't exist. */ getFile(clientName, bucket, params) { return __awaiter(this, void 0, void 0, function* () { var _a, e_1, _b, _c; this.validateBucket(clientName, bucket); const client = this.getClient(clientName); const { key } = params; const command = new client_s3_1.GetObjectCommand({ Bucket: bucket, Key: key, }); try { const response = yield client.s3.send(command); if (!response.Body) { throw new Error(`File ${key} not found in bucket ${bucket}`); } // Convert the readable stream to a buffer const chunks = []; const stream = response.Body; try { for (var _d = true, stream_1 = __asyncValues(stream), stream_1_1; stream_1_1 = yield stream_1.next(), _a = stream_1_1.done, !_a; _d = true) { _c = stream_1_1.value; _d = false; const chunk = _c; chunks.push(Buffer.from(chunk)); } } catch (e_1_1) { e_1 = { error: e_1_1 }; } finally { try { if (!_d && !_a && (_b = stream_1.return)) yield _b.call(stream_1); } finally { if (e_1) throw e_1.error; } } const buffer = Buffer.concat(chunks); return { buffer, contentType: response.ContentType, contentLength: response.ContentLength, etag: response.ETag, lastModified: response.LastModified, }; } catch (error) { if (error instanceof Error && error.name === "NoSuchKey") { throw new Error(`File ${key} not found in bucket ${bucket}`); } throw error; } }); } /** * Downloads a file from S3 and returns its content as a readable stream. * This method is useful for streaming large files efficiently without loading * the entire file into memory. * * @template ClientName - The name of the client. * @param clientName - The name of the client. * @param bucket - The bucket containing the file. * @param params - The parameters for downloading the file. * @param params.key - The key of the file to download. * @returns A promise that resolves to an object containing the file stream and metadata. * @throws {Error} If the file cannot be downloaded or doesn't exist. */ getFileStream(clientName, bucket, params) { return __awaiter(this, void 0, void 0, function* () { this.validateBucket(clientName, bucket); const client = this.getClient(clientName); const { key } = params; const command = new client_s3_1.GetObjectCommand({ Bucket: bucket, Key: key, }); try { const response = yield client.s3.send(command); if (!response.Body) { throw new Error(`File ${key} not found in bucket ${bucket}`); } return { stream: response.Body, contentType: response.ContentType, contentLength: response.ContentLength, etag: response.ETag, lastModified: response.LastModified, }; } catch (error) { if (error instanceof Error && error.name === "NoSuchKey") { throw new Error(`File ${key} not found in bucket ${bucket}`); } throw error; } }); } /** * Generates a signed URL for accessing an object in a specific bucket of a client. * Enhanced version with support for download-specific headers to force browser download * and avoid CORS issues. * * @param clientName - The name of the client. * @param bucket - The bucket to generate the signed URL for. * @param params - Additional parameters for generating the signed URL. * @param params.key - The key of the object. * @param params.expiresIn - The expiration time of the signed URL in seconds. Defaults to 1 hour (3600 seconds). * @param params.responseContentDisposition - Optional content disposition header to force download behavior. * @param params.responseContentType - Optional content type header. * @param params.responseCacheControl - Optional cache control header. * @param params.responseContentLanguage - Optional content language header. * @param params.responseContentEncoding - Optional content encoding header. * @param params.responseExpires - Optional expires header. * @returns A promise that resolves to the signed URL. */ generateSignedUrl(clientName, bucket, params) { return __awaiter(this, void 0, void 0, function* () { this.validateBucket(clientName, bucket); const client = this.getClient(clientName); const { key, expiresIn = 3600, responseContentDisposition, responseContentType, responseCacheControl, responseContentLanguage, responseContentEncoding, responseExpires, } = params; const command = new client_s3_1.GetObjectCommand({ Bucket: bucket, Key: key, ResponseContentDisposition: responseContentDisposition, ResponseContentType: responseContentType, ResponseCacheControl: responseCacheControl, ResponseContentLanguage: responseContentLanguage, ResponseContentEncoding: responseContentEncoding, ResponseExpires: responseExpires, }); return (0, s3_request_presigner_1.getSignedUrl)(client.s3, command, { expiresIn }); }); } /** * Extracts the key from the given URL. * @param url - The URL from which to extract the key. * @returns A Promise that resolves to the extracted key. * @throws An error if the URL is invalid or the key cannot be extracted. */ getKeyFromUrl(url) { return __awaiter(this, void 0, void 0, function* () { const parsedUrl = new URL(url); let key; // Extract the bucket name and key from the hostname or pathname if (parsedUrl.hostname.includes("s3") && parsedUrl.hostname.endsWith("amazonaws.com")) { // For virtual-hosted–style URL (with or without region) const bucketNameEndIndex = parsedUrl.hostname.indexOf(".s3"); const bucketName = parsedUrl.hostname.substring(0, bucketNameEndIndex); key = decodeURIComponent(parsedUrl.pathname.substring(1)); // Decode and remove leading '/' return key; // Return key } else if (parsedUrl.pathname.includes("/")) { // For path-style URL const pathSegments = parsedUrl.pathname.split("/"); if (pathSegments.length > 2) { // Ensure there's at least a bucket and a key const bucketName = pathSegments[1]; // Bucket name is the first segment key = decodeURIComponent(pathSegments.slice(2).join("/")); // Decode and join the rest as the key return key; // Return key } } throw new Error(`Invalid URL provided. Unable to extract key from ${url}`); }); } /** * Moves a file from one key to another, possibly across buckets, for the given client. * * @template ClientName - The name of the client. * @param clientName - The name of the client. * @param sourceBucket - The source bucket. * @param destinationBucket - The destination bucket. * @param params - The parameters for moving the file. * @param params.sourceKey - The key of the file to move. * @param params.destinationKey - The new key for the file. * @returns A promise that resolves to an object containing the URL and key of the moved file. */ moveFile(clientName, sourceBucket, destinationBucket, params) { return __awaiter(this, void 0, void 0, function* () { this.validateBucket(clientName, sourceBucket); this.validateBucket(clientName, destinationBucket); const client = this.getClient(clientName); const { sourceKey, destinationKey } = params; const copyCommand = new client_s3_1.CopyObjectCommand({ Bucket: destinationBucket, CopySource: `${sourceBucket}/${sourceKey}`, Key: destinationKey, }); yield client.s3.send(copyCommand); yield this.deleteFile(clientName, sourceBucket, { key: sourceKey }); const url = yield this.getUrl(clientName, destinationBucket, destinationKey); return { url, key: destinationKey, }; }); } /** * Deletes a folder and all its contents from the specified bucket. * * @template ClientName - The name of the client. * @param clientName - The name of the client. * @param bucket - The bucket from which to delete the folder. * @param params - The parameters for deleting the folder. * @param params.folder - The folder to delete. * @returns A promise that resolves when the folder and its contents are successfully deleted. */ deleteFolder(clientName, bucket, params) { return __awaiter(this, void 0, void 0, function* () { var _a; this.validateBucket(clientName, bucket); const client = this.getClient(clientName); const { folder } = params; const prefix = `${client.keyPrefix ? `${client.keyPrefix}/` : ""}${folder}/`; let isTruncated = true; let continuationToken; while (isTruncated) { const command = new client_s3_1.ListObjectsV2Command({ Bucket: bucket, Prefix: prefix, ContinuationToken: continuationToken, }); const res = yield client.s3.send(command); if (res.Contents && res.Contents.length > 0) { const deleteCommands = res.Contents.map((item) => ({ Key: item.Key, })); const deleteCommand = new client_s3_1.DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects: deleteCommands, Quiet: false, }, }); yield client.s3.send(deleteCommand); } isTruncated = (_a = res.IsTruncated) !== null && _a !== void 0 ? _a : false; continuationToken = res.NextContinuationToken; } }); } } exports.S3Pilot = S3Pilot;