20dd43c093
Mirror to GitHub / mirror (push) Successful in 28s
Test and Lint / backend-test (push) Successful in 1m26s
continuous-integration/drone/push Build is failing
Test and Lint / frontend-test (push) Failing after 2m18s
Version and Release / version-bump (push) Successful in 42s
Version and Release / trigger-drone (push) Successful in 3s
- Add S3/MinIO storage adapter with multipart upload support - Implement database backup service for SQLite and PostgreSQL - Create backup manifest generator for tracking backup contents - Enhance backup service with S3 integration and incremental backups - Add restore service with safety measures and rollback capability - Create comprehensive test suite for all backup functionality - Add admin API endpoints for backup/restore management - Implement frontend UI with dashboard, configuration, and restore wizard - Add roadmap section to README with implemented backup feature This implementation provides: - Multiple backup destinations (local, rsync, S3/MinIO) - Intelligent change detection to minimize backup frequency - Full database backups with compression - Manifest-based restore with integrity validation - Pre-restore safety backups with rollback - Comprehensive error handling and monitoring - User-friendly admin interface 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
733 lines
23 KiB
JavaScript
733 lines
23 KiB
JavaScript
const { S3Client, HeadBucketCommand, HeadObjectCommand, GetObjectCommand, PutObjectCommand, DeleteObjectCommand, DeleteObjectsCommand, ListObjectsV2Command, CopyObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand } = require('@aws-sdk/client-s3');
|
|
const { Upload } = require('@aws-sdk/lib-storage');
|
|
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
|
const fs = require('fs');
|
|
const fsPromises = require('fs').promises;
|
|
const path = require('path');
|
|
const stream = require('stream');
|
|
const crypto = require('crypto');
|
|
const logger = require('../../utils/logger');
|
|
|
|
/**
|
|
* S3 Storage Adapter for handling file uploads to S3 and S3-compatible services
|
|
*
|
|
* Features:
|
|
* - Support for S3 and S3-compatible services (MinIO, DigitalOcean Spaces, etc.)
|
|
* - Multipart upload for large files (>100MB)
|
|
* - Progress tracking with event emitter
|
|
* - Retry logic with exponential backoff
|
|
* - Stream support for memory efficiency
|
|
* - Path-style URL support for MinIO
|
|
* - Connection testing
|
|
* - Comprehensive error handling
|
|
*
|
|
* @class S3StorageAdapter
|
|
*/
|
|
class S3StorageAdapter extends stream.EventEmitter {
|
|
/**
|
|
* Creates an instance of S3StorageAdapter
|
|
*
|
|
* @param {Object} config - Configuration object
|
|
* @param {string} config.bucket - S3 bucket name
|
|
* @param {string} [config.region='us-east-1'] - AWS region
|
|
* @param {string} [config.endpoint] - Custom endpoint URL for S3-compatible services
|
|
* @param {string} [config.accessKeyId] - AWS access key ID
|
|
* @param {string} [config.secretAccessKey] - AWS secret access key
|
|
* @param {boolean} [config.forcePathStyle=false] - Force path-style URLs (required for MinIO)
|
|
* @param {boolean} [config.sslEnabled=true] - Enable SSL for connections
|
|
* @param {number} [config.multipartThreshold=104857600] - Threshold for multipart upload (default 100MB)
|
|
* @param {number} [config.partSize=10485760] - Part size for multipart upload (default 10MB)
|
|
* @param {number} [config.maxRetries=3] - Maximum number of retry attempts
|
|
* @param {number} [config.retryDelay=1000] - Initial retry delay in milliseconds
|
|
*/
|
|
constructor(config) {
|
|
super();
|
|
|
|
// Validate required config
|
|
if (!config.bucket) {
|
|
throw new Error('S3 bucket name is required');
|
|
}
|
|
|
|
// Set defaults
|
|
this.config = {
|
|
region: 'us-east-1',
|
|
forcePathStyle: false,
|
|
sslEnabled: true,
|
|
multipartThreshold: 100 * 1024 * 1024, // 100MB
|
|
partSize: 10 * 1024 * 1024, // 10MB
|
|
maxRetries: 3,
|
|
retryDelay: 1000,
|
|
...config
|
|
};
|
|
|
|
// Initialize S3 client
|
|
const s3Config = {
|
|
region: this.config.region,
|
|
forcePathStyle: this.config.forcePathStyle
|
|
};
|
|
|
|
// Add credentials if provided
|
|
if (this.config.accessKeyId && this.config.secretAccessKey) {
|
|
s3Config.credentials = {
|
|
accessKeyId: this.config.accessKeyId,
|
|
secretAccessKey: this.config.secretAccessKey
|
|
};
|
|
}
|
|
|
|
// Add custom endpoint if provided (for S3-compatible services)
|
|
if (this.config.endpoint) {
|
|
s3Config.endpoint = this.config.endpoint;
|
|
// For MinIO and other S3-compatible services
|
|
if (!this.config.endpoint.startsWith('https://') && this.config.sslEnabled) {
|
|
s3Config.endpoint = `https://${this.config.endpoint}`;
|
|
} else if (!this.config.endpoint.startsWith('http://') && !this.config.sslEnabled) {
|
|
s3Config.endpoint = `http://${this.config.endpoint}`;
|
|
}
|
|
}
|
|
|
|
this.s3Client = new S3Client(s3Config);
|
|
this.bucket = this.config.bucket;
|
|
|
|
// Bind methods to preserve context
|
|
this.upload = this.upload.bind(this);
|
|
this.uploadStream = this.uploadStream.bind(this);
|
|
this.download = this.download.bind(this);
|
|
this.downloadStream = this.downloadStream.bind(this);
|
|
this.delete = this.delete.bind(this);
|
|
this.exists = this.exists.bind(this);
|
|
this.list = this.list.bind(this);
|
|
this.copy = this.copy.bind(this);
|
|
this.move = this.move.bind(this);
|
|
this.getSignedUrl = this.getSignedUrl.bind(this);
|
|
this.testConnection = this.testConnection.bind(this);
|
|
}
|
|
|
|
/**
|
|
* Test connection to S3 bucket
|
|
*
|
|
* @returns {Promise<boolean>} - True if connection successful
|
|
* @throws {Error} - If connection fails
|
|
*/
|
|
async testConnection() {
|
|
try {
|
|
await this.s3Client.send(new HeadBucketCommand({ Bucket: this.bucket }));
|
|
logger.info(`Successfully connected to S3 bucket: ${this.bucket}`);
|
|
return true;
|
|
} catch (error) {
|
|
logger.error(`Failed to connect to S3 bucket ${this.bucket}:`, error);
|
|
throw new Error(`S3 connection test failed: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Upload a file to S3 with automatic multipart for large files
|
|
*
|
|
* @param {string} localPath - Local file path to upload
|
|
* @param {string} s3Key - S3 object key (path in bucket)
|
|
* @param {Object} [options={}] - Additional options
|
|
* @param {Object} [options.metadata] - Object metadata
|
|
* @param {string} [options.contentType] - Content type
|
|
* @param {string} [options.cacheControl] - Cache control header
|
|
* @param {Function} [options.onProgress] - Progress callback function(loaded, total)
|
|
* @returns {Promise<Object>} - Upload result with Location, ETag, etc.
|
|
*/
|
|
async upload(localPath, s3Key, options = {}) {
|
|
try {
|
|
const stats = await fsPromises.stat(localPath);
|
|
const fileSize = stats.size;
|
|
|
|
// Emit upload start event
|
|
this.emit('uploadStart', { key: s3Key, size: fileSize });
|
|
|
|
// Create file stream
|
|
const fileStream = fs.createReadStream(localPath);
|
|
|
|
// Prepare upload parameters
|
|
const uploadParams = {
|
|
Bucket: this.bucket,
|
|
Key: s3Key,
|
|
Body: fileStream,
|
|
ContentType: options.contentType || 'application/octet-stream',
|
|
Metadata: options.metadata || {},
|
|
CacheControl: options.cacheControl
|
|
};
|
|
|
|
// Remove undefined values
|
|
Object.keys(uploadParams).forEach(key => uploadParams[key] === undefined && delete uploadParams[key]);
|
|
|
|
// Use AWS SDK v3 Upload for automatic multipart handling
|
|
const parallelUploads3 = new Upload({
|
|
client: this.s3Client,
|
|
params: uploadParams,
|
|
queueSize: 4, // Optional: concurrent uploads
|
|
partSize: this.config.partSize,
|
|
leavePartsOnError: false
|
|
});
|
|
|
|
// Track upload progress
|
|
parallelUploads3.on('httpUploadProgress', (progress) => {
|
|
if (options.onProgress) {
|
|
options.onProgress(progress.loaded, progress.total);
|
|
}
|
|
this.emit('uploadProgress', { key: s3Key, loaded: progress.loaded, total: progress.total });
|
|
});
|
|
|
|
// Perform upload with retry
|
|
const result = await this._retryOperation(() => parallelUploads3.done());
|
|
|
|
this.emit('uploadComplete', { key: s3Key, location: result.Location });
|
|
return result;
|
|
} catch (error) {
|
|
logger.error(`Failed to upload file ${localPath} to S3:`, error);
|
|
this.emit('uploadError', { key: s3Key, error });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Upload a stream to S3
|
|
*
|
|
* @param {stream.Readable} readStream - Readable stream to upload
|
|
* @param {string} s3Key - S3 object key
|
|
* @param {Object} [options={}] - Additional options
|
|
* @returns {Promise<Object>} - Upload result
|
|
*/
|
|
async uploadStream(readStream, s3Key, options = {}) {
|
|
const uploadParams = {
|
|
Bucket: this.bucket,
|
|
Key: s3Key,
|
|
Body: readStream,
|
|
ContentType: options.contentType || 'application/octet-stream',
|
|
Metadata: options.metadata || {},
|
|
CacheControl: options.cacheControl
|
|
};
|
|
|
|
// Remove undefined values
|
|
Object.keys(uploadParams).forEach(key => uploadParams[key] === undefined && delete uploadParams[key]);
|
|
|
|
const parallelUploads3 = new Upload({
|
|
client: this.s3Client,
|
|
params: uploadParams,
|
|
queueSize: 4,
|
|
partSize: this.config.partSize,
|
|
leavePartsOnError: false
|
|
});
|
|
|
|
// Track progress if callback provided
|
|
if (options.onProgress) {
|
|
parallelUploads3.on('httpUploadProgress', (progress) => {
|
|
options.onProgress(progress.loaded, progress.total);
|
|
this.emit('uploadProgress', { key: s3Key, ...progress });
|
|
});
|
|
}
|
|
|
|
return await this._retryOperation(() => parallelUploads3.done());
|
|
}
|
|
|
|
/**
|
|
* Download a file from S3
|
|
*
|
|
* @param {string} s3Key - S3 object key
|
|
* @param {string} localPath - Local file path to save to
|
|
* @param {Object} [options={}] - Additional options
|
|
* @param {Function} [options.onProgress] - Progress callback
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async download(s3Key, localPath, options = {}) {
|
|
try {
|
|
// Ensure directory exists
|
|
await fsPromises.mkdir(path.dirname(localPath), { recursive: true });
|
|
|
|
// Get object metadata first for progress tracking
|
|
const headResult = await this._retryOperation(() =>
|
|
this.s3Client.send(new HeadObjectCommand({
|
|
Bucket: this.bucket,
|
|
Key: s3Key
|
|
}))
|
|
);
|
|
|
|
const fileSize = headResult.ContentLength;
|
|
this.emit('downloadStart', { key: s3Key, size: fileSize });
|
|
|
|
// Get object
|
|
const getObjectResult = await this._retryOperation(() =>
|
|
this.s3Client.send(new GetObjectCommand({
|
|
Bucket: this.bucket,
|
|
Key: s3Key
|
|
}))
|
|
);
|
|
|
|
// Create write stream
|
|
const writeStream = fs.createWriteStream(localPath);
|
|
|
|
// Download with progress tracking
|
|
await new Promise((resolve, reject) => {
|
|
let downloaded = 0;
|
|
|
|
const bodyStream = getObjectResult.Body;
|
|
|
|
bodyStream.on('data', (chunk) => {
|
|
downloaded += chunk.length;
|
|
if (options.onProgress) {
|
|
options.onProgress(downloaded, fileSize);
|
|
}
|
|
this.emit('downloadProgress', { key: s3Key, loaded: downloaded, total: fileSize });
|
|
});
|
|
|
|
bodyStream.on('error', reject);
|
|
writeStream.on('error', reject);
|
|
writeStream.on('finish', resolve);
|
|
|
|
bodyStream.pipe(writeStream);
|
|
});
|
|
|
|
this.emit('downloadComplete', { key: s3Key });
|
|
} catch (error) {
|
|
logger.error(`Failed to download file ${s3Key} from S3:`, error);
|
|
this.emit('downloadError', { key: s3Key, error });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a download stream from S3
|
|
*
|
|
* @param {string} s3Key - S3 object key
|
|
* @param {Object} [options={}] - Additional options
|
|
* @param {string} [options.range] - Byte range to download (e.g., 'bytes=0-1023')
|
|
* @returns {Promise<stream.Readable>} - Readable stream
|
|
*/
|
|
async downloadStream(s3Key, options = {}) {
|
|
const params = {
|
|
Bucket: this.bucket,
|
|
Key: s3Key,
|
|
Range: options.range
|
|
};
|
|
|
|
// Remove undefined values
|
|
Object.keys(params).forEach(key => params[key] === undefined && delete params[key]);
|
|
|
|
const result = await this.s3Client.send(new GetObjectCommand(params));
|
|
return result.Body;
|
|
}
|
|
|
|
/**
|
|
* Delete a file from S3
|
|
*
|
|
* @param {string} s3Key - S3 object key
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async delete(s3Key) {
|
|
return await this._retryOperation(() =>
|
|
this.s3Client.send(new DeleteObjectCommand({
|
|
Bucket: this.bucket,
|
|
Key: s3Key
|
|
}))
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Delete multiple files from S3
|
|
*
|
|
* @param {string[]} s3Keys - Array of S3 object keys
|
|
* @returns {Promise<Object>} - Delete result
|
|
*/
|
|
async deleteMany(s3Keys) {
|
|
if (!s3Keys || s3Keys.length === 0) {
|
|
return { Deleted: [], Errors: [] };
|
|
}
|
|
|
|
// S3 deleteObjects has a limit of 1000 keys per request
|
|
const chunks = [];
|
|
for (let i = 0; i < s3Keys.length; i += 1000) {
|
|
chunks.push(s3Keys.slice(i, i + 1000));
|
|
}
|
|
|
|
const results = await Promise.all(
|
|
chunks.map(chunk =>
|
|
this._retryOperation(() =>
|
|
this.s3Client.send(new DeleteObjectsCommand({
|
|
Bucket: this.bucket,
|
|
Delete: {
|
|
Objects: chunk.map(key => ({ Key: key })),
|
|
Quiet: false
|
|
}
|
|
}))
|
|
)
|
|
)
|
|
);
|
|
|
|
// Combine results
|
|
return {
|
|
Deleted: results.flatMap(r => r.Deleted || []),
|
|
Errors: results.flatMap(r => r.Errors || [])
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check if a file exists in S3
|
|
*
|
|
* @param {string} s3Key - S3 object key
|
|
* @returns {Promise<boolean>} - True if exists
|
|
*/
|
|
async exists(s3Key) {
|
|
try {
|
|
await this.s3Client.send(new HeadObjectCommand({
|
|
Bucket: this.bucket,
|
|
Key: s3Key
|
|
}));
|
|
return true;
|
|
} catch (error) {
|
|
if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) {
|
|
return false;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List files in S3
|
|
*
|
|
* @param {string} prefix - S3 prefix to list
|
|
* @param {Object} [options={}] - Additional options
|
|
* @param {number} [options.maxKeys=1000] - Maximum number of keys to return
|
|
* @param {string} [options.continuationToken] - Continuation token for pagination
|
|
* @returns {Promise<Object>} - List result with Contents array and NextContinuationToken
|
|
*/
|
|
async list(prefix, options = {}) {
|
|
const params = {
|
|
Bucket: this.bucket,
|
|
Prefix: prefix,
|
|
MaxKeys: options.maxKeys || 1000,
|
|
ContinuationToken: options.continuationToken
|
|
};
|
|
|
|
return await this._retryOperation(() =>
|
|
this.s3Client.send(new ListObjectsV2Command(params))
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Copy a file within S3
|
|
*
|
|
* @param {string} sourceKey - Source S3 object key
|
|
* @param {string} targetKey - Target S3 object key
|
|
* @param {Object} [options={}] - Additional options
|
|
* @returns {Promise<Object>} - Copy result
|
|
*/
|
|
async copy(sourceKey, targetKey, options = {}) {
|
|
const copySource = `${this.bucket}/${sourceKey}`;
|
|
|
|
const params = {
|
|
Bucket: this.bucket,
|
|
CopySource: copySource,
|
|
Key: targetKey,
|
|
MetadataDirective: options.metadata ? 'REPLACE' : 'COPY',
|
|
Metadata: options.metadata,
|
|
ContentType: options.contentType,
|
|
CacheControl: options.cacheControl
|
|
};
|
|
|
|
// Remove undefined values
|
|
Object.keys(params).forEach(key => params[key] === undefined && delete params[key]);
|
|
|
|
return await this._retryOperation(() =>
|
|
this.s3Client.send(new CopyObjectCommand(params))
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Move a file within S3 (copy then delete)
|
|
*
|
|
* @param {string} sourceKey - Source S3 object key
|
|
* @param {string} targetKey - Target S3 object key
|
|
* @param {Object} [options={}] - Additional options
|
|
* @returns {Promise<Object>} - Move result
|
|
*/
|
|
async move(sourceKey, targetKey, options = {}) {
|
|
// First copy the object
|
|
const copyResult = await this.copy(sourceKey, targetKey, options);
|
|
|
|
// Then delete the original
|
|
await this.delete(sourceKey);
|
|
|
|
return copyResult;
|
|
}
|
|
|
|
/**
|
|
* Get a pre-signed URL for downloading or uploading
|
|
*
|
|
* @param {string} operation - Operation type ('getObject' or 'putObject')
|
|
* @param {string} s3Key - S3 object key
|
|
* @param {Object} [options={}] - Additional options
|
|
* @param {number} [options.expiresIn=3600] - URL expiration in seconds
|
|
* @param {Object} [options.params] - Additional parameters for the operation
|
|
* @returns {Promise<string>} - Pre-signed URL
|
|
*/
|
|
async getSignedUrl(operation, s3Key, options = {}) {
|
|
const params = {
|
|
Bucket: this.bucket,
|
|
Key: s3Key,
|
|
...options.params
|
|
};
|
|
|
|
let command;
|
|
switch (operation.toLowerCase()) {
|
|
case 'getobject':
|
|
command = new GetObjectCommand(params);
|
|
break;
|
|
case 'putobject':
|
|
command = new PutObjectCommand(params);
|
|
break;
|
|
default:
|
|
throw new Error(`Unsupported operation: ${operation}`);
|
|
}
|
|
|
|
return await getSignedUrl(this.s3Client, command, {
|
|
expiresIn: options.expiresIn || 3600
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get object metadata
|
|
*
|
|
* @param {string} s3Key - S3 object key
|
|
* @returns {Promise<Object>} - Object metadata
|
|
*/
|
|
async getMetadata(s3Key) {
|
|
return await this._retryOperation(() =>
|
|
this.s3Client.send(new HeadObjectCommand({
|
|
Bucket: this.bucket,
|
|
Key: s3Key
|
|
}))
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Update object metadata
|
|
*
|
|
* @param {string} s3Key - S3 object key
|
|
* @param {Object} metadata - New metadata
|
|
* @returns {Promise<Object>} - Update result
|
|
*/
|
|
async updateMetadata(s3Key, metadata) {
|
|
// S3 requires copying the object to itself to update metadata
|
|
return await this.copy(s3Key, s3Key, { metadata });
|
|
}
|
|
|
|
/**
|
|
* Manual multipart upload for advanced use cases
|
|
*
|
|
* @param {string} localPath - Local file path
|
|
* @param {string} s3Key - S3 object key
|
|
* @param {number} fileSize - File size in bytes
|
|
* @param {Object} options - Upload options
|
|
* @returns {Promise<Object>} - Upload result
|
|
* @private
|
|
*/
|
|
async _manualMultipartUpload(localPath, s3Key, fileSize, options) {
|
|
logger.info(`Starting manual multipart upload for ${s3Key} (${fileSize} bytes)`);
|
|
|
|
// Initiate multipart upload
|
|
const multipartParams = {
|
|
Bucket: this.bucket,
|
|
Key: s3Key,
|
|
ContentType: options.contentType || 'application/octet-stream',
|
|
Metadata: options.metadata || {},
|
|
CacheControl: options.cacheControl
|
|
};
|
|
|
|
// Remove undefined values
|
|
Object.keys(multipartParams).forEach(key => multipartParams[key] === undefined && delete multipartParams[key]);
|
|
|
|
const multipart = await this._retryOperation(() =>
|
|
this.s3Client.send(new CreateMultipartUploadCommand(multipartParams))
|
|
);
|
|
|
|
const uploadId = multipart.UploadId;
|
|
const partSize = this.config.partSize;
|
|
const numParts = Math.ceil(fileSize / partSize);
|
|
|
|
let uploaded = 0;
|
|
const parts = [];
|
|
|
|
try {
|
|
// Upload parts
|
|
for (let partNum = 1; partNum <= numParts; partNum++) {
|
|
const start = (partNum - 1) * partSize;
|
|
const end = Math.min(start + partSize, fileSize);
|
|
|
|
const partStream = fs.createReadStream(localPath, {
|
|
start,
|
|
end: end - 1
|
|
});
|
|
|
|
const partParams = {
|
|
Bucket: this.bucket,
|
|
Key: s3Key,
|
|
PartNumber: partNum,
|
|
UploadId: uploadId,
|
|
Body: partStream
|
|
};
|
|
|
|
// Upload part with retry
|
|
const partResult = await this._retryOperation(() =>
|
|
this.s3Client.send(new UploadPartCommand(partParams))
|
|
);
|
|
|
|
parts.push({
|
|
ETag: partResult.ETag,
|
|
PartNumber: partNum
|
|
});
|
|
|
|
uploaded += (end - start);
|
|
|
|
if (options.onProgress) {
|
|
options.onProgress(uploaded, fileSize);
|
|
}
|
|
this.emit('uploadProgress', { key: s3Key, loaded: uploaded, total: fileSize });
|
|
|
|
logger.info(`Uploaded part ${partNum}/${numParts} for ${s3Key}`);
|
|
}
|
|
|
|
// Complete multipart upload
|
|
const completeParams = {
|
|
Bucket: this.bucket,
|
|
Key: s3Key,
|
|
UploadId: uploadId,
|
|
MultipartUpload: { Parts: parts }
|
|
};
|
|
|
|
const result = await this._retryOperation(() =>
|
|
this.s3Client.send(new CompleteMultipartUploadCommand(completeParams))
|
|
);
|
|
|
|
this.emit('uploadComplete', { key: s3Key, location: result.Location });
|
|
logger.info(`Completed multipart upload for ${s3Key}`);
|
|
|
|
return result;
|
|
} catch (error) {
|
|
// Abort multipart upload on error
|
|
logger.error(`Multipart upload failed for ${s3Key}, aborting:`, error);
|
|
|
|
try {
|
|
await this.s3Client.send(new AbortMultipartUploadCommand({
|
|
Bucket: this.bucket,
|
|
Key: s3Key,
|
|
UploadId: uploadId
|
|
}));
|
|
} catch (abortError) {
|
|
logger.error(`Failed to abort multipart upload:`, abortError);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retry operation with exponential backoff
|
|
* @private
|
|
*/
|
|
async _retryOperation(operation, retryCount = 0) {
|
|
try {
|
|
return await operation();
|
|
} catch (error) {
|
|
if (retryCount >= this.config.maxRetries) {
|
|
throw error;
|
|
}
|
|
|
|
// Check if error is retryable
|
|
const retryableErrors = ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ESOCKETTIMEDOUT', 'RequestTimeout', 'SlowDown', 'ServiceUnavailable', 'InternalError'];
|
|
const isRetryable = retryableErrors.some(code =>
|
|
error.code === code ||
|
|
error.name === code ||
|
|
error.$metadata?.httpStatusCode === 503 ||
|
|
error.$metadata?.httpStatusCode === 500
|
|
);
|
|
|
|
if (!isRetryable) {
|
|
throw error;
|
|
}
|
|
|
|
// Calculate delay with exponential backoff and jitter
|
|
const delay = Math.min(
|
|
this.config.retryDelay * Math.pow(2, retryCount) + Math.random() * 1000,
|
|
30000 // Max 30 seconds
|
|
);
|
|
|
|
logger.warn(`Retrying operation after ${delay}ms (attempt ${retryCount + 1}/${this.config.maxRetries}):`, error.message);
|
|
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
|
|
return this._retryOperation(operation, retryCount + 1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate a unique S3 key for a file
|
|
*
|
|
* @param {string} originalName - Original filename
|
|
* @param {string} [prefix=''] - Optional prefix for the key
|
|
* @returns {string} - Generated S3 key
|
|
*/
|
|
generateKey(originalName, prefix = '') {
|
|
const timestamp = Date.now();
|
|
const randomStr = crypto.randomBytes(8).toString('hex');
|
|
const ext = path.extname(originalName);
|
|
const basename = path.basename(originalName, ext);
|
|
|
|
// Sanitize basename
|
|
const safeName = basename.replace(/[^a-zA-Z0-9-_]/g, '_');
|
|
|
|
const key = `${timestamp}_${randomStr}_${safeName}${ext}`;
|
|
|
|
return prefix ? path.posix.join(prefix, key) : key;
|
|
}
|
|
|
|
/**
|
|
* Get storage statistics
|
|
*
|
|
* @param {string} [prefix=''] - Optional prefix to filter
|
|
* @returns {Promise<Object>} - Storage statistics
|
|
*/
|
|
async getStats(prefix = '') {
|
|
let totalSize = 0;
|
|
let totalCount = 0;
|
|
let continuationToken;
|
|
|
|
do {
|
|
const result = await this.list(prefix, { continuationToken });
|
|
|
|
if (result.Contents) {
|
|
totalCount += result.Contents.length;
|
|
totalSize += result.Contents.reduce((sum, obj) => sum + (obj.Size || 0), 0);
|
|
}
|
|
|
|
continuationToken = result.NextContinuationToken;
|
|
} while (continuationToken);
|
|
|
|
return {
|
|
totalSize,
|
|
totalCount,
|
|
totalSizeFormatted: this._formatBytes(totalSize)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Format bytes to human readable format
|
|
* @private
|
|
*/
|
|
_formatBytes(bytes, decimals = 2) {
|
|
if (bytes === 0) return '0 Bytes';
|
|
|
|
const k = 1024;
|
|
const dm = decimals < 0 ? 0 : decimals;
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
|
}
|
|
}
|
|
|
|
module.exports = S3StorageAdapter; |