feat(api): add storage module for MinIO/S3 integration

- Implement StorageService with MinIO client integration
- Add file upload, download, and metadata operations
- Support SHA-256 checksum calculation for deduplication
- Include presigned URL generation for secure downloads
- Add batch file management and cleanup operations
- Validate image MIME types for security

Resolves requirements §28-§30 for file storage architecture.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
DustyWalker 2025-08-05 17:23:18 +02:00
parent 0197a2f7ca
commit d2c988303f
2 changed files with 273 additions and 0 deletions

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { StorageService } from './storage.service';
@Module({
imports: [ConfigModule],
providers: [StorageService],
exports: [StorageService],
})
export class StorageModule {}

View file

@ -0,0 +1,263 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as Minio from 'minio';
import { v4 as uuidv4 } from 'uuid';
import * as crypto from 'crypto';
export interface StorageFile {
buffer: Buffer;
originalName: string;
mimeType: string;
size: number;
}
export interface UploadResult {
key: string;
etag: string;
size: number;
checksum: string;
}
@Injectable()
export class StorageService {
private readonly logger = new Logger(StorageService.name);
private readonly minioClient: Minio.Client;
private readonly bucketName: string;
constructor(private configService: ConfigService) {
// Initialize MinIO client
this.minioClient = new Minio.Client({
endPoint: this.configService.get<string>('MINIO_ENDPOINT', 'localhost'),
port: this.configService.get<number>('MINIO_PORT', 9000),
useSSL: this.configService.get<boolean>('MINIO_USE_SSL', false),
accessKey: this.configService.get<string>('MINIO_ACCESS_KEY', 'minioadmin'),
secretKey: this.configService.get<string>('MINIO_SECRET_KEY', 'minioadmin'),
});
this.bucketName = this.configService.get<string>('MINIO_BUCKET_NAME', 'seo-image-renamer');
this.initializeBucket();
}
/**
* Initialize the bucket if it doesn't exist
*/
private async initializeBucket(): Promise<void> {
try {
const bucketExists = await this.minioClient.bucketExists(this.bucketName);
if (!bucketExists) {
await this.minioClient.makeBucket(this.bucketName, 'us-east-1');
this.logger.log(`Created bucket: ${this.bucketName}`);
}
} catch (error) {
this.logger.error(`Failed to initialize bucket: ${error.message}`, error.stack);
}
}
/**
* Upload a file to MinIO storage
* @param file File data to upload
* @param batchId Batch UUID for organizing files
* @returns Upload result with key and metadata
*/
async uploadFile(file: StorageFile, batchId: string): Promise<UploadResult> {
try {
// Generate file checksum
const checksum = crypto.createHash('sha256').update(file.buffer).digest('hex');
// Generate unique filename with batch organization
const fileExtension = this.getFileExtension(file.originalName);
const fileName = `${uuidv4()}${fileExtension}`;
const objectKey = `batches/${batchId}/${fileName}`;
// Upload metadata
const metaData = {
'Content-Type': file.mimeType,
'Original-Name': file.originalName,
'Upload-Date': new Date().toISOString(),
'Checksum-SHA256': checksum,
};
// Upload file to MinIO
const uploadInfo = await this.minioClient.putObject(
this.bucketName,
objectKey,
file.buffer,
file.size,
metaData
);
this.logger.log(`File uploaded successfully: ${objectKey}`);
return {
key: objectKey,
etag: uploadInfo.etag,
size: file.size,
checksum,
};
} catch (error) {
this.logger.error(`Failed to upload file: ${error.message}`, error.stack);
throw new Error(`File upload failed: ${error.message}`);
}
}
/**
* Get a file from MinIO storage
* @param objectKey Object key to retrieve
* @returns File stream
*/
async getFile(objectKey: string): Promise<NodeJS.ReadableStream> {
try {
return await this.minioClient.getObject(this.bucketName, objectKey);
} catch (error) {
this.logger.error(`Failed to retrieve file: ${objectKey}`, error.stack);
throw new Error(`File retrieval failed: ${error.message}`);
}
}
/**
* Get file metadata
* @param objectKey Object key to get metadata for
* @returns File metadata
*/
async getFileMetadata(objectKey: string): Promise<any> {
try {
return await this.minioClient.statObject(this.bucketName, objectKey);
} catch (error) {
this.logger.error(`Failed to get file metadata: ${objectKey}`, error.stack);
throw new Error(`File metadata retrieval failed: ${error.message}`);
}
}
/**
* Delete a file from MinIO storage
* @param objectKey Object key to delete
*/
async deleteFile(objectKey: string): Promise<void> {
try {
await this.minioClient.removeObject(this.bucketName, objectKey);
this.logger.log(`File deleted successfully: ${objectKey}`);
} catch (error) {
this.logger.error(`Failed to delete file: ${objectKey}`, error.stack);
throw new Error(`File deletion failed: ${error.message}`);
}
}
/**
* List files in a batch folder
* @param batchId Batch UUID
* @returns Array of object keys
*/
async listBatchFiles(batchId: string): Promise<string[]> {
try {
const objects: string[] = [];
const objectStream = this.minioClient.listObjects(
this.bucketName,
`batches/${batchId}/`,
true
);
return new Promise((resolve, reject) => {
objectStream.on('data', (obj) => {
objects.push(obj.name);
});
objectStream.on('error', (err) => {
this.logger.error(`Failed to list batch files: ${batchId}`, err);
reject(new Error(`Failed to list batch files: ${err.message}`));
});
objectStream.on('end', () => {
resolve(objects);
});
});
} catch (error) {
this.logger.error(`Failed to list batch files: ${batchId}`, error.stack);
throw new Error(`Batch file listing failed: ${error.message}`);
}
}
/**
* Delete all files in a batch folder
* @param batchId Batch UUID
*/
async deleteBatchFiles(batchId: string): Promise<void> {
try {
const objectKeys = await this.listBatchFiles(batchId);
if (objectKeys.length > 0) {
await this.minioClient.removeObjects(this.bucketName, objectKeys);
this.logger.log(`Deleted ${objectKeys.length} files for batch: ${batchId}`);
}
} catch (error) {
this.logger.error(`Failed to delete batch files: ${batchId}`, error.stack);
throw new Error(`Batch file deletion failed: ${error.message}`);
}
}
/**
* Generate a presigned URL for file download
* @param objectKey Object key
* @param expiry Expiry time in seconds (default: 1 hour)
* @returns Presigned URL
*/
async getPresignedUrl(objectKey: string, expiry: number = 3600): Promise<string> {
try {
return await this.minioClient.presignedGetObject(this.bucketName, objectKey, expiry);
} catch (error) {
this.logger.error(`Failed to generate presigned URL: ${objectKey}`, error.stack);
throw new Error(`Presigned URL generation failed: ${error.message}`);
}
}
/**
* Check if file exists in storage
* @param objectKey Object key to check
* @returns Whether file exists
*/
async fileExists(objectKey: string): Promise<boolean> {
try {
await this.minioClient.statObject(this.bucketName, objectKey);
return true;
} catch (error) {
if (error.code === 'NotFound') {
return false;
}
throw error;
}
}
/**
* Calculate SHA-256 checksum for duplicate detection
* @param buffer File buffer
* @returns SHA-256 checksum
*/
calculateChecksum(buffer: Buffer): string {
return crypto.createHash('sha256').update(buffer).digest('hex');
}
/**
* Get file extension from filename
* @param filename Original filename
* @returns File extension with dot
*/
private getFileExtension(filename: string): string {
const lastDotIndex = filename.lastIndexOf('.');
return lastDotIndex !== -1 ? filename.substring(lastDotIndex) : '';
}
/**
* Validate file MIME type for image uploads
* @param mimeType MIME type to validate
* @returns Whether MIME type is valid
*/
isValidImageMimeType(mimeType: string): boolean {
const validMimeTypes = [
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp',
];
return validMimeTypes.includes(mimeType.toLowerCase());
}
}