UNPKG

linode-s3-utils

Version:

Clean, intuitive class-based S3-compatible object storage utilities with named parameters for Linode Object Storage and AWS S3

270 lines (243 loc) 8.41 kB
const { S3Client, PutObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3') const { getSignedUrl } = require('@aws-sdk/s3-request-presigner') const fs = require('fs') const path = require('path') /** * S3-compatible object storage client class * Supports both Linode Object Storage and AWS S3 */ class S3Utils { /** * Create an S3Utils instance * @param {Object} config - Configuration options * @param {string} config.region - AWS/Linode region * @param {string} config.accessKeyId - Access key ID * @param {string} config.secretAccessKey - Secret access key * @param {string} [config.endpoint] - Custom endpoint (auto-generated for Linode if not provided) * @param {boolean} [config.forcePathStyle=true] - Force path-style addressing */ constructor(config = {}) { // Merge with environment variables as fallback this.config = { region: config.region || process.env.LINODE_BUCKET_REGION || process.env.AWS_REGION, accessKeyId: config.accessKeyId || process.env.LINODE_S3_ACCESS_KEY || process.env.AWS_ACCESS_KEY_ID, secretAccessKey: config.secretAccessKey || process.env.LINODE_S3_SECRET_KEY || process.env.AWS_SECRET_ACCESS_KEY, endpoint: config.endpoint, forcePathStyle: config.forcePathStyle !== undefined ? config.forcePathStyle : true, } // Validate required configuration if (!this.config.region || !this.config.accessKeyId || !this.config.secretAccessKey) { throw new Error('Missing required S3 configuration. Please provide region, accessKeyId, and secretAccessKey.') } // Auto-generate endpoint for Linode Object Storage if not provided if (!this.config.endpoint) { this.config.endpoint = `https://${this.config.region}.linodeobjects.com` } // Initialize S3 client this.s3Client = new S3Client({ region: this.config.region, endpoint: this.config.endpoint, credentials: { accessKeyId: this.config.accessKeyId, secretAccessKey: this.config.secretAccessKey, }, forcePathStyle: this.config.forcePathStyle, }) } /** * Infer content type from file extension * @param {string} filePath - File path or filename * @returns {string} MIME type */ inferContentType(filePath) { const ext = path.extname(filePath).toLowerCase() const mimeTypes = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.pdf': 'application/pdf', '.txt': 'text/plain', '.json': 'application/json', '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', '.mp3': 'audio/mpeg', '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.wav': 'audio/wav', } return mimeTypes[ext] || 'application/octet-stream' } /** * Generate public URL for a given key and bucket * @param {string} key - Object key * @param {string} bucket - Bucket name * @returns {string} Public URL */ getPublicUrl(key, bucket) { return `https://${bucket}.${this.config.region}.linodeobjects.com/${key}` } /** * Upload a local file to S3 * @param {Object} params - Upload parameters * @param {string} params.filePath - Local path to the file * @param {string} params.key - Object key (path) in the bucket * @param {string} params.bucket - Bucket name * @param {string} [params.contentType] - MIME type (auto-detected if not provided) * @param {string} [params.acl='public-read'] - Access control list * @param {Object} [params.metadata] - Additional metadata to store with the object * @returns {Promise<Object>} Upload result with publicUrl */ async uploadFile({ filePath, key, bucket, contentType = null, acl = 'public-read', metadata = null }) { try { // Check if file exists if (!fs.existsSync(filePath)) { throw new Error(`File not found: ${filePath}`) } // Read the file const fileContent = fs.readFileSync(filePath) // Infer content type if not provided if (!contentType) { contentType = this.inferContentType(filePath) } // Create the upload command const command = new PutObjectCommand({ Bucket: bucket, Key: key, Body: fileContent, ContentType: contentType, ACL: acl, ...metadata && { Metadata: metadata } }) // Upload the file const result = await this.s3Client.send(command) // Return success response with public URL const publicUrl = this.getPublicUrl(key, bucket) return { success: true, result, publicUrl, key, bucket, contentType, } } catch (error) { console.error('Upload failed:', error) return { success: false, error: error.message, key, bucket, } } } /** * Upload data (Buffer or string) directly to S3 * @param {Object} params - Upload parameters * @param {Buffer|string} params.data - Data to upload * @param {string} params.key - Object key (path) in the bucket * @param {string} params.bucket - Bucket name * @param {string} params.contentType - MIME type * @param {string} [params.acl='public-read'] - Access control list * @param {Object} [params.metadata] - Additional metadata to store with the object * @returns {Promise<Object>} Upload result with publicUrl */ async uploadData({ data, key, bucket, contentType, acl = 'public-read', metadata = null }) { try { // Create the upload command const command = new PutObjectCommand({ Bucket: bucket, Key: key, Body: data, ContentType: contentType, ACL: acl, ...metadata && { Metadata: metadata } }) // Upload the data const result = await this.s3Client.send(command) // Return success response with public URL const publicUrl = this.getPublicUrl(key, bucket) return { success: true, result, publicUrl, key, bucket, contentType, } } catch (error) { console.error('Upload failed:', error) return { success: false, error: error.message, key, bucket, } } } /** * Generate a presigned URL for client-side uploads * @param {Object} params - Parameters for presigned URL generation * @param {string} params.key - Object key (path) in the bucket * @param {string} params.bucket - Bucket name * @param {string} [params.contentType] - MIME type (auto-detected if not provided) * @param {number} [params.expiresIn=180] - URL expiration time in seconds * @param {string} [params.acl='public-read'] - Access control list * @returns {Promise<string>} Presigned URL */ async getPresignedUploadUrl({ key, bucket, contentType = null, expiresIn = 180, acl = 'public-read' }) { // Infer content type if not provided if (!contentType) { contentType = this.inferContentType(key) } const command = new PutObjectCommand({ Bucket: bucket, Key: key, ContentType: contentType, ACL: acl, }) return getSignedUrl(this.s3Client, command, { expiresIn: expiresIn, signableHeaders: new Set(['host', 'x-amz-acl']), }) } /** * Delete an object from S3 * @param {Object} params - Deletion parameters * @param {string} params.key - Object key to delete * @param {string} params.bucket - Bucket name * @returns {Promise<Object>} Deletion result */ async deleteObject({ key, bucket }) { try { // Create the delete command const command = new DeleteObjectCommand({ Bucket: bucket, Key: key, }) // Delete the object const result = await this.s3Client.send(command) return { success: true, result, key, bucket, message: `Successfully deleted object: ${key} from bucket: ${bucket}`, } } catch (error) { console.error('Delete failed:', error) return { success: false, error: error.message, key, bucket, } } } } // Export the class as the main export module.exports = S3Utils // Also provide named exports for flexibility module.exports.S3Utils = S3Utils module.exports.default = S3Utils