Cloud Storage with Amazon S3
Cloud Storage with Amazon S3
Modern applications often need to handle file uploads, document storage, and media management at scale. While local file storage works for development and small applications, production systems require the reliability, scalability, and global distribution that cloud storage provides. This chapter explores how to integrate Amazon S3 with your Hypermodern applications for robust file management.
Understanding Cloud Storage Integration
The Hypermodern platform includes comprehensive S3 integration that allows you to seamlessly switch between local and cloud storage without changing your application logic. This flexibility means you can develop locally and deploy to production with confidence, knowing your file operations will work consistently across environments.
Why Choose S3?
- Scalability: Handle files from bytes to terabytes without infrastructure concerns
- Durability: 99.999999999% (11 9's) durability for your critical files
- Global Distribution: Serve files from edge locations worldwide
- Security: Fine-grained access controls and encryption options
- Cost Effectiveness: Pay only for what you use with multiple storage classes
Setting Up S3 Integration
AWS Account Configuration
Before integrating S3, you'll need an AWS account and proper credentials. The Hypermodern S3 integration supports multiple authentication methods:
# Environment variables (recommended for development)
export AWS_ACCESS_KEY_ID=your_access_key_here
export AWS_SECRET_ACCESS_KEY=your_secret_key_here
export AWS_DEFAULT_REGION=us-east-1
export S3_BUCKET_NAME=your-app-bucket
Using the Credentials Service
For production applications, use Hypermodern's credentials service for secure credential management:
// Configure credentials through the service
await CredentialsService.set('aws_access_key_id', 'your_access_key');
await CredentialsService.set('aws_secret_access_key', 'your_secret_key');
await CredentialsService.set('aws_region', 'us-east-1');
await CredentialsService.set('s3_bucket_name', 'your-app-bucket');
IAM Permissions
Create an IAM user with the minimum required permissions:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::your-app-bucket",
"arn:aws:s3:::your-app-bucket/*"
]
}
]
}
Basic S3 Operations
Direct S3 Client Usage
The S3Client provides low-level access to S3 operations:
import 'package:hypermodern_server/hypermodern_server.dart';
class FileController {
static final s3 = S3Client();
static Future<dynamic> uploadFile(RequestData data) async {
try {
// Get file data from request
final fileData = data.files['upload'];
if (fileData == null) {
return {'error': 'No file provided'};
}
// Upload to S3
final result = await s3.uploadFile(
'uploads/${DateTime.now().millisecondsSinceEpoch}_${fileData.filename}',
fileData.bytes,
contentType: fileData.contentType,
metadata: {
'original_name': fileData.filename,
'uploaded_by': data.user?.id ?? 'anonymous',
'upload_time': DateTime.now().toIso8601String(),
},
);
return {
'success': true,
'key': result.key,
'url': result.url,
'size': fileData.bytes.length,
};
} catch (e) {
return {'error': 'Upload failed: $e'};
}
}
static Future<dynamic> downloadFile(RequestData data) async {
final key = data.pathParams['key'];
try {
final fileData = await s3.downloadFile(key);
// Return file with appropriate headers
return FileResponse(
data: fileData,
contentType: 'application/octet-stream',
filename: key.split('/').last,
);
} catch (e) {
return {'error': 'File not found'};
}
}
static Future<dynamic> deleteFile(RequestData data) async {
final key = data.pathParams['key'];
try {
await s3.deleteFile(key);
return {'success': true, 'message': 'File deleted'};
} catch (e) {
return {'error': 'Delete failed: $e'};
}
}
}
File Existence and Metadata
Check if files exist and retrieve metadata without downloading:
class FileInfoController {
static final s3 = S3Client();
static Future<dynamic> checkFile(RequestData data) async {
final key = data.pathParams['key'];
final exists = await s3.fileExists(key);
if (!exists) {
return {'exists': false};
}
final metadata = await s3.getFileMetadata(key);
return {
'exists': true,
'size': metadata['content-length'],
'lastModified': metadata['last-modified'],
'contentType': metadata['content-type'],
'customMetadata': metadata['metadata'] ?? {},
};
}
}
File Storage Service Integration
The FileStorageService provides a higher-level abstraction that works with both local and S3 storage:
Configuration
class StorageConfig {
static FileStorageConfig get production => FileStorageConfig(
backend: StorageBackend.s3,
s3KeyPrefix: 'app-files',
organizeByDate: true,
maxFileSize: 50 * 1024 * 1024, // 50MB
allowedExtensions: ['.jpg', '.jpeg', '.png', '.pdf', '.doc', '.docx'],
);
static FileStorageConfig get development => FileStorageConfig(
backend: StorageBackend.local,
baseDirectory: 'storage/uploads',
organizeByDate: true,
maxFileSize: 10 * 1024 * 1024, // 10MB for dev
allowedExtensions: ['.jpg', '.jpeg', '.png', '.pdf'],
);
}
Service Implementation
class DocumentService {
static final storage = FileStorageService(
config: Environment.isProduction
? StorageConfig.production
: StorageConfig.development
);
static Future<StoredFile> uploadDocument(
UploadedFile file,
String userId,
) async {
// Validate file
if (!_isValidDocument(file)) {
throw ValidationException('Invalid document type');
}
// Store with metadata
final storedFile = await storage.storeFile(
file,
metadata: {
'user_id': userId,
'document_type': _getDocumentType(file.filename),
'upload_timestamp': DateTime.now().toIso8601String(),
},
);
// Log the upload
await AuditService.logFileUpload(userId, storedFile.id);
return storedFile;
}
static Future<Uint8List> getDocument(String fileId, String userId) async {
// Check permissions
final file = await FileMetadataService.getFile(fileId);
if (file.metadata['user_id'] != userId) {
throw UnauthorizedException('Access denied');
}
return await storage.getFileBytes(fileId);
}
static Future<String> generateShareLink(
String fileId,
Duration expiration,
) async {
return await storage.generatePresignedUrl(fileId, expiration: expiration);
}
static bool _isValidDocument(UploadedFile file) {
final allowedTypes = ['application/pdf', 'image/jpeg', 'image/png'];
return allowedTypes.contains(file.contentType);
}
static String _getDocumentType(String filename) {
final extension = filename.toLowerCase().split('.').last;
switch (extension) {
case 'pdf': return 'document';
case 'jpg':
case 'jpeg':
case 'png': return 'image';
default: return 'unknown';
}
}
}
Advanced S3 Features
Presigned URLs for Direct Upload
Allow clients to upload directly to S3 without going through your server:
class DirectUploadController {
static final s3 = S3Client();
static Future<dynamic> generateUploadUrl(RequestData data) async {
final filename = data.body['filename'];
final contentType = data.body['contentType'];
final userId = data.user!.id;
// Generate unique key
final key = 'direct-uploads/$userId/${DateTime.now().millisecondsSinceEpoch}_$filename';
// Generate presigned URL for upload
final uploadUrl = await s3.generatePresignedUploadUrl(
key,
contentType: contentType,
expiration: Duration(minutes: 15),
metadata: {
'user_id': userId,
'original_filename': filename,
},
);
return {
'uploadUrl': uploadUrl,
'key': key,
'expiresIn': 900, // 15 minutes
};
}
static Future<dynamic> confirmUpload(RequestData data) async {
final key = data.body['key'];
// Verify the file was uploaded
final exists = await s3.fileExists(key);
if (!exists) {
return {'error': 'Upload not found'};
}
// Move from temporary to permanent location
final permanentKey = key.replaceFirst('direct-uploads/', 'files/');
await s3.copyFile(key, permanentKey);
await s3.deleteFile(key);
// Store metadata in database
await FileMetadataService.createRecord(permanentKey, data.user!.id);
return {
'success': true,
'fileId': permanentKey,
};
}
}
Multipart Uploads for Large Files
Handle large file uploads efficiently:
class LargeFileUploadService {
static final s3 = S3Client();
static const int chunkSize = 5 * 1024 * 1024; // 5MB chunks
static Future<String> initiateMultipartUpload(
String key,
String contentType,
) async {
return await s3.initiateMultipartUpload(key, contentType);
}
static Future<String> uploadPart(
String uploadId,
String key,
int partNumber,
Uint8List data,
) async {
return await s3.uploadPart(uploadId, key, partNumber, data);
}
static Future<void> completeMultipartUpload(
String uploadId,
String key,
List<String> etags,
) async {
await s3.completeMultipartUpload(uploadId, key, etags);
}
static Future<void> abortMultipartUpload(
String uploadId,
String key,
) async {
await s3.abortMultipartUpload(uploadId, key);
}
}
File Organization Strategies
Date-Based Organization
Organize files by date for better management:
class FileOrganizer {
static String generateDateBasedKey(String filename, String prefix) {
final now = DateTime.now();
final year = now.year.toString();
final month = now.month.toString().padLeft(2, '0');
final day = now.day.toString().padLeft(2, '0');
final fileId = generateUniqueId();
final extension = filename.split('.').last;
return '$prefix/$year/$month/$day/${fileId}.$extension';
}
static String generateUserBasedKey(String userId, String filename) {
final fileId = generateUniqueId();
final extension = filename.split('.').last;
return 'users/$userId/files/${fileId}.$extension';
}
static String generateCategoryKey(String category, String filename) {
final now = DateTime.now();
final timestamp = now.millisecondsSinceEpoch;
final extension = filename.split('.').last;
return '$category/${timestamp}_$filename';
}
}
Content-Type Based Processing
Handle different file types appropriately:
class FileProcessor {
static Future<ProcessedFile> processUpload(UploadedFile file) async {
switch (file.contentType) {
case 'image/jpeg':
case 'image/png':
return await _processImage(file);
case 'application/pdf':
return await _processPDF(file);
case 'video/mp4':
return await _processVideo(file);
default:
return await _processGenericFile(file);
}
}
static Future<ProcessedFile> _processImage(UploadedFile file) async {
// Generate thumbnails
final thumbnail = await ImageProcessor.generateThumbnail(file.bytes);
// Store original and thumbnail
final originalKey = FileOrganizer.generateCategoryKey('images/original', file.filename);
final thumbnailKey = FileOrganizer.generateCategoryKey('images/thumbnails', file.filename);
await S3Client().uploadFile(originalKey, file.bytes, contentType: file.contentType);
await S3Client().uploadFile(thumbnailKey, thumbnail, contentType: 'image/jpeg');
return ProcessedFile(
originalKey: originalKey,
thumbnailKey: thumbnailKey,
metadata: {
'type': 'image',
'has_thumbnail': true,
},
);
}
static Future<ProcessedFile> _processPDF(UploadedFile file) async {
// Extract text for search indexing
final text = await PDFProcessor.extractText(file.bytes);
final key = FileOrganizer.generateCategoryKey('documents', file.filename);
await S3Client().uploadFile(
key,
file.bytes,
contentType: file.contentType,
metadata: {'extracted_text': text},
);
return ProcessedFile(
originalKey: key,
metadata: {
'type': 'document',
'searchable': true,
'text_length': text.length,
},
);
}
}
Security and Access Control
Secure File Access
Implement proper access controls for file operations:
class SecureFileController {
static Future<dynamic> downloadFile(RequestData data) async {
final fileId = data.pathParams['fileId'];
final user = data.user;
// Check permissions
final hasAccess = await FilePermissionService.checkAccess(fileId, user?.id);
if (!hasAccess) {
return {'error': 'Access denied', 'code': 403};
}
// Generate temporary access URL
final presignedUrl = await S3Client().generatePresignedUrl(
fileId,
expiration: Duration(minutes: 5),
);
// Log access
await AuditService.logFileAccess(fileId, user?.id);
return {'downloadUrl': presignedUrl};
}
static Future<dynamic> shareFile(RequestData data) async {
final fileId = data.pathParams['fileId'];
final expirationHours = data.body['expirationHours'] ?? 24;
final user = data.user!;
// Verify ownership
final isOwner = await FileOwnershipService.isOwner(fileId, user.id);
if (!isOwner) {
return {'error': 'Only file owners can create share links'};
}
// Generate share token
final shareToken = await ShareTokenService.create(
fileId: fileId,
createdBy: user.id,
expiresAt: DateTime.now().add(Duration(hours: expirationHours)),
);
return {
'shareUrl': '/shared/${shareToken.id}',
'expiresAt': shareToken.expiresAt.toIso8601String(),
};
}
}
File Encryption
Encrypt sensitive files before storing in S3:
class EncryptedFileService {
static final _encryptionKey = CredentialsService.get('file_encryption_key');
static Future<String> storeEncryptedFile(
UploadedFile file,
String userId,
) async {
// Encrypt file data
final encryptedData = await CryptoService.encrypt(file.bytes, _encryptionKey);
// Store encrypted file
final key = FileOrganizer.generateUserBasedKey(userId, '${file.filename}.encrypted');
await S3Client().uploadFile(
key,
encryptedData,
contentType: 'application/octet-stream',
metadata: {
'original_content_type': file.contentType,
'original_filename': file.filename,
'encrypted': 'true',
},
);
return key;
}
static Future<Uint8List> retrieveEncryptedFile(String key) async {
// Download encrypted file
final encryptedData = await S3Client().downloadFile(key);
// Decrypt and return
return await CryptoService.decrypt(encryptedData, _encryptionKey);
}
}
Performance Optimization
Caching Strategies
Implement intelligent caching for frequently accessed files:
class CachedFileService {
static final _cache = <String, CachedFile>{};
static const maxCacheSize = 100 * 1024 * 1024; // 100MB
static int _currentCacheSize = 0;
static Future<Uint8List> getFile(String key) async {
// Check cache first
if (_cache.containsKey(key)) {
final cached = _cache[key]!;
if (cached.isValid) {
return cached.data;
} else {
_removeFromCache(key);
}
}
// Download from S3
final data = await S3Client().downloadFile(key);
// Cache if small enough
if (data.length < 10 * 1024 * 1024) { // Cache files under 10MB
_addToCache(key, data);
}
return data;
}
static void _addToCache(String key, Uint8List data) {
// Evict old entries if needed
while (_currentCacheSize + data.length > maxCacheSize && _cache.isNotEmpty) {
final oldestKey = _cache.keys.first;
_removeFromCache(oldestKey);
}
_cache[key] = CachedFile(
data: data,
cachedAt: DateTime.now(),
ttl: Duration(minutes: 30),
);
_currentCacheSize += data.length;
}
static void _removeFromCache(String key) {
final cached = _cache.remove(key);
if (cached != null) {
_currentCacheSize -= cached.data.length;
}
}
}
CDN Integration
Serve files through CloudFront for better performance:
class CDNFileService {
static const cdnDomain = 'https://d1234567890.cloudfront.net';
static String getCDNUrl(String s3Key) {
return '$cdnDomain/$s3Key';
}
static Future<String> uploadWithCDN(
UploadedFile file,
String key,
) async {
// Upload to S3
await S3Client().uploadFile(
key,
file.bytes,
contentType: file.contentType,
cacheControl: 'public, max-age=31536000', // 1 year
);
// Return CDN URL
return getCDNUrl(key);
}
static Future<void> invalidateCDNCache(List<String> keys) async {
// Invalidate CloudFront cache
final paths = keys.map((key) => '/$key').toList();
await CloudFrontService.createInvalidation(paths);
}
}
Monitoring and Analytics
File Usage Tracking
Track file operations for analytics and billing:
class FileAnalyticsService {
static Future<void> trackUpload(String fileId, String userId, int fileSize) async {
await AnalyticsService.track('file_upload', {
'file_id': fileId,
'user_id': userId,
'file_size': fileSize,
'timestamp': DateTime.now().toIso8601String(),
});
}
static Future<void> trackDownload(String fileId, String? userId) async {
await AnalyticsService.track('file_download', {
'file_id': fileId,
'user_id': userId,
'timestamp': DateTime.now().toIso8601String(),
});
}
static Future<Map<String, dynamic>> getUsageStats(String userId) async {
final uploads = await AnalyticsService.count('file_upload', {'user_id': userId});
final downloads = await AnalyticsService.count('file_download', {'user_id': userId});
final totalSize = await FileMetadataService.getTotalSize(userId);
return {
'uploads': uploads,
'downloads': downloads,
'total_size': totalSize,
'storage_cost': calculateStorageCost(totalSize),
};
}
}
Migration and Backup Strategies
Migrating from Local to S3
Migrate existing local files to S3:
class StorageMigrationService {
static Future<void> migrateToS3() async {
final localFiles = await _getLocalFiles();
final s3 = S3Client();
for (final localFile in localFiles) {
try {
// Read local file
final data = await File(localFile.path).readAsBytes();
// Generate S3 key
final s3Key = _generateS3Key(localFile.path);
// Upload to S3
await s3.uploadFile(s3Key, data, contentType: localFile.contentType);
// Update database references
await FileMetadataService.updateStorageLocation(
localFile.id,
StorageBackend.s3,
s3Key,
);
// Delete local file after successful upload
await File(localFile.path).delete();
print('Migrated: ${localFile.path} -> $s3Key');
} catch (e) {
print('Failed to migrate ${localFile.path}: $e');
}
}
}
static String _generateS3Key(String localPath) {
// Convert local path to S3 key
return localPath.replaceFirst('storage/', 'migrated/');
}
}
Backup and Disaster Recovery
Implement backup strategies for critical files:
class BackupService {
static Future<void> backupToSecondaryRegion() async {
final s3Primary = S3Client();
final s3Backup = S3Client(region: 'us-west-2');
final files = await FileMetadataService.getCriticalFiles();
for (final file in files) {
try {
// Download from primary
final data = await s3Primary.downloadFile(file.s3Key);
// Upload to backup region
final backupKey = 'backup/${file.s3Key}';
await s3Backup.uploadFile(backupKey, data);
// Record backup
await BackupMetadataService.recordBackup(file.id, backupKey);
} catch (e) {
print('Backup failed for ${file.s3Key}: $e');
}
}
}
static Future<void> restoreFromBackup(String fileId) async {
final backup = await BackupMetadataService.getBackup(fileId);
if (backup == null) {
throw Exception('No backup found for file $fileId');
}
final s3Backup = S3Client(region: 'us-west-2');
final s3Primary = S3Client();
// Download from backup
final data = await s3Backup.downloadFile(backup.backupKey);
// Restore to primary
final originalFile = await FileMetadataService.getFile(fileId);
await s3Primary.uploadFile(originalFile.s3Key, data);
}
}
Best Practices and Recommendations
1. File Naming Conventions
Use consistent, predictable file naming:
class FileNamingService {
static String generateFileName(String originalName, String userId) {
final timestamp = DateTime.now().millisecondsSinceEpoch;
final extension = originalName.split('.').last.toLowerCase();
final sanitizedName = _sanitizeFilename(originalName);
return '${userId}_${timestamp}_$sanitizedName.$extension';
}
static String _sanitizeFilename(String filename) {
return filename
.replaceAll(RegExp(r'[^a-zA-Z0-9._-]'), '_')
.replaceAll(RegExp(r'_+'), '_')
.toLowerCase();
}
}
2. Error Handling
Implement comprehensive error handling:
class RobustFileService {
static Future<StoredFile> uploadWithRetry(
UploadedFile file,
{int maxRetries = 3}
) async {
for (int attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await _performUpload(file);
} on S3Exception catch (e) {
if (attempt == maxRetries || !_isRetryableError(e)) {
rethrow;
}
// Exponential backoff
await Future.delayed(Duration(seconds: attempt * 2));
}
}
throw Exception('Upload failed after $maxRetries attempts');
}
static bool _isRetryableError(S3Exception e) {
return e.statusCode >= 500 || e.statusCode == 429;
}
}
3. Cost Optimization
Monitor and optimize S3 costs:
class CostOptimizationService {
static Future<void> implementLifecyclePolicy() async {
// Move files to cheaper storage classes over time
final policy = S3LifecyclePolicy([
LifecycleRule(
id: 'optimize-storage-costs',
status: 'Enabled',
transitions: [
Transition(days: 30, storageClass: 'STANDARD_IA'),
Transition(days: 90, storageClass: 'GLACIER'),
Transition(days: 365, storageClass: 'DEEP_ARCHIVE'),
],
),
]);
await S3Client().putBucketLifecycleConfiguration(policy);
}
static Future<void> cleanupOldFiles() async {
final cutoffDate = DateTime.now().subtract(Duration(days: 365));
final oldFiles = await FileMetadataService.getFilesOlderThan(cutoffDate);
for (final file in oldFiles) {
if (!file.isImportant) {
await S3Client().deleteFile(file.s3Key);
await FileMetadataService.markAsDeleted(file.id);
}
}
}
}
The S3 integration in Hypermodern provides a production-ready solution for file storage that scales with your application. By leveraging cloud storage, you can build applications that handle files efficiently, securely, and cost-effectively while maintaining the developer experience that makes Hypermodern special.
No Comments