Storage
File storage system with local filesystem implementation and abstraction for cloud migration.
Overview
- Local filesystem storage
- Upload tracking in database
- Unique filename generation
- File validation (type, size)
- Cloud-ready abstraction layer
Storage Architecture
Storage Interface
// app/shared/storage.ts
export abstract class AbstractFileSystem {
abstract write(file: { filename: string; buffer: Buffer }): Promise<string>;
abstract delete(filename: string): Promise<void>;
}
Local Implementation
// adapters/storage/local.ts
import { promises as fs } from 'fs';
import { join } from 'path';
import { createHash } from 'crypto';
export class LocalFileSystem extends AbstractFileSystem {
private rootDir: string;
constructor(rootDir: string = 'storage/uploads') {
super();
this.rootDir = rootDir;
}
async write(file: { filename: string; buffer: Buffer }): Promise<string> {
// Generate unique filename
const uniqueFilename = this.generateUniqueFilename(file.filename);
const filePath = join(this.rootDir, uniqueFilename);
// Ensure directory exists
await fs.mkdir(this.rootDir, { recursive: true });
// Write file
await fs.writeFile(filePath, file.buffer);
return uniqueFilename;
}
async delete(filename: string): Promise<void> {
const filePath = join(this.rootDir, filename);
await fs.unlink(filePath);
}
private generateUniqueFilename(originalName: string): string {
const timestamp = Date.now();
const hash = createHash('md5')
.update(`${originalName}${timestamp}`)
.digest('hex')
.substring(0, 8);
const ext = originalName.split('.').pop();
return `${timestamp}-${hash}.${ext}`;
}
}
Upload Repository
Track uploads in database:
// adapters/storage/upload.repository.ts
export class UploadRepository extends BaseRepository<Upload> {
constructor() {
super(Upload);
}
async create(data: {
filename: string;
originalName: string;
mimetype: string;
size: number;
uploadedBy: string;
}): Promise<Upload> {
return await this.model.create(data);
}
async findByUser(userId: string): Promise<Upload[]> {
return await this.model.findAll({
where: { uploadedBy: userId },
order: [['createdAt', 'DESC']],
});
}
async deleteByFilename(filename: string): Promise<void> {
await this.model.destroy({ where: { filename } });
}
}
Upload Model
// domain/shared/upload.entity.ts
export interface UploadProps {
id: string;
filename: string;
originalName: string;
mimetype: string;
size: number;
uploadedBy: string;
createdAt: Date;
}
export class Upload extends AggregateRoot<UploadProps> {
get filename() { return this.props.filename; }
get originalName() { return this.props.originalName; }
get mimetype() { return this.props.mimetype; }
get size() { return this.props.size; }
get uploadedBy() { return this.props.uploadedBy; }
get url() { return `/uploads/${this.props.filename}`; }
static create(props: Omit<UploadProps, 'id' | 'createdAt'>) {
return new Upload({
...props,
id: generateId(),
createdAt: new Date(),
});
}
}
File Upload Use Cases
Upload File
// app/shared/filesystem.ts
export class FileSystemUseCases {
constructor(
private fileSystem: AbstractFileSystem,
private uploadRepo: UploadRepository,
) {}
async uploadFile(params: {
file: Express.Multer.File;
userId: string;
}): Promise<Upload> {
const { file, userId } = params;
// Validate file
this.validateFile(file);
// Write to storage
const filename = await this.fileSystem.write({
filename: file.originalname,
buffer: file.buffer,
});
// Track in database
const upload = await this.uploadRepo.create({
filename,
originalName: file.originalname,
mimetype: file.mimetype,
size: file.size,
uploadedBy: userId,
});
return upload;
}
async deleteFile(params: {
filename: string;
userId: string;
}): Promise<void> {
const { filename, userId } = params;
// Get upload record
const upload = await this.uploadRepo.findOne({ filename });
if (!upload) {
throw new NotFoundException('File not found');
}
// Check ownership
if (upload.uploadedBy !== userId) {
throw new ForbiddenException('Not authorized to delete this file');
}
// Delete from storage
await this.fileSystem.delete(filename);
// Remove from database
await this.uploadRepo.deleteByFilename(filename);
}
private validateFile(file: Express.Multer.File): void {
// Check file size (10MB max)
const maxSize = 10 * 1024 * 1024; // 10MB
if (file.size > maxSize) {
throw new ValidationException('File too large. Max 10MB');
}
// Check file type
const allowedTypes = [
'image/jpeg',
'image/png',
'image/gif',
'application/pdf',
];
if (!allowedTypes.includes(file.mimetype)) {
throw new ValidationException('Invalid file type');
}
}
}
NestJS Integration
File Upload Controller
// web/shared/upload.controller.ts
import {
Controller,
Post,
UseInterceptors,
UploadedFile,
Delete,
Param,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
@Controller('uploads')
export class UploadController {
constructor(private fileSystemUseCases: FileSystemUseCases) {}
@Post()
@UseInterceptors(FileInterceptor('file'))
async upload(
@UploadedFile() file: Express.Multer.File,
@AuthUser() user: UserPayload,
) {
const upload = await this.fileSystemUseCases.uploadFile({
file,
userId: user.userId,
});
return {
filename: upload.filename,
url: upload.url,
size: upload.size,
};
}
@Delete(':filename')
async delete(
@Param('filename') filename: string,
@AuthUser() user: UserPayload,
) {
await this.fileSystemUseCases.deleteFile({
filename,
userId: user.userId,
});
return { message: 'File deleted successfully' };
}
}
Static File Serving
// web/main.ts
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// Serve static files
app.useStaticAssets(join(__dirname, '..', 'storage', 'uploads'), {
prefix: '/uploads/',
});
await app.listen(3000);
}
File Validation
File Type Validation
const ALLOWED_MIMETYPES = {
images: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
documents: ['application/pdf', 'application/msword'],
videos: ['video/mp4', 'video/quicktime'],
};
function validateFileType(file: Express.Multer.File, category: string) {
const allowed = ALLOWED_MIMETYPES[category];
if (!allowed.includes(file.mimetype)) {
throw new ValidationException(`Invalid file type. Allowed: ${allowed.join(', ')}`);
}
}
File Size Limits
const SIZE_LIMITS = {
profileImage: 2 * 1024 * 1024, // 2MB
serviceImage: 5 * 1024 * 1024, // 5MB
portfolio: 10 * 1024 * 1024, // 10MB
document: 20 * 1024 * 1024, // 20MB
};
function validateFileSize(file: Express.Multer.File, type: string) {
const maxSize = SIZE_LIMITS[type];
if (file.size > maxSize) {
throw new ValidationException(
`File too large. Max ${maxSize / (1024 * 1024)}MB`
);
}
}
Image Processing
For image optimization and resizing:
import sharp from 'sharp';
async function processImage(file: Express.Multer.File) {
// Resize and optimize
const buffer = await sharp(file.buffer)
.resize(1200, 1200, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 85 })
.toBuffer();
return {
...file,
buffer,
size: buffer.length,
};
}
// Generate thumbnail
async function generateThumbnail(file: Express.Multer.File) {
const thumbnail = await sharp(file.buffer)
.resize(300, 300, { fit: 'cover' })
.jpeg({ quality: 80 })
.toBuffer();
return thumbnail;
}
Cloud Storage Migration
AWS S3 Implementation
// adapters/storage/s3.ts
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
export class S3FileSystem extends AbstractFileSystem {
private s3: S3Client;
private bucket: string;
constructor() {
super();
this.s3 = new S3Client({
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
});
this.bucket = process.env.AWS_S3_BUCKET;
}
async write(file: { filename: string; buffer: Buffer }): Promise<string> {
const uniqueFilename = this.generateUniqueFilename(file.filename);
await this.s3.send(new PutObjectCommand({
Bucket: this.bucket,
Key: uniqueFilename,
Body: file.buffer,
}));
return uniqueFilename;
}
async delete(filename: string): Promise<void> {
await this.s3.send(new DeleteObjectCommand({
Bucket: this.bucket,
Key: filename,
}));
}
getUrl(filename: string): string {
return `https://${this.bucket}.s3.amazonaws.com/${filename}`;
}
}
Azure Blob Storage
// adapters/storage/azure.ts
import { BlobServiceClient } from '@azure/storage-blob';
export class AzureBlobFileSystem extends AbstractFileSystem {
private containerClient;
constructor() {
super();
const blobServiceClient = BlobServiceClient.fromConnectionString(
process.env.AZURE_STORAGE_CONNECTION_STRING
);
this.containerClient = blobServiceClient.getContainerClient(
process.env.AZURE_CONTAINER_NAME
);
}
async write(file: { filename: string; buffer: Buffer }): Promise<string> {
const uniqueFilename = this.generateUniqueFilename(file.filename);
const blockBlobClient = this.containerClient.getBlockBlobClient(uniqueFilename);
await blockBlobClient.upload(file.buffer, file.buffer.length);
return uniqueFilename;
}
async delete(filename: string): Promise<void> {
const blockBlobClient = this.containerClient.getBlockBlobClient(filename);
await blockBlobClient.delete();
}
}
Storage Provider Factory
// app/shared/storage.ts
export function createFileSystem(): AbstractFileSystem {
const provider = process.env.STORAGE_PROVIDER || 'local';
switch (provider) {
case 's3':
return new S3FileSystem();
case 'azure':
return new AzureBlobFileSystem();
case 'local':
default:
return new LocalFileSystem();
}
}
Environment Configuration
# Storage Provider (local, s3, azure)
STORAGE_PROVIDER=local
# Local Storage
STORAGE_ROOT_DIR=storage/uploads
# AWS S3
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key
AWS_S3_BUCKET=your-bucket-name
# Azure Blob
AZURE_STORAGE_CONNECTION_STRING=your-connection-string
AZURE_CONTAINER_NAME=your-container-name
# File Upload Limits
MAX_FILE_SIZE=10485760 # 10MB in bytes
Best Practices
✅ Do
- Validate files – Check type and size before uploading
- Generate unique names – Prevent filename collisions
- Track uploads – Store metadata in database
- Use abstraction – Easy migration to cloud storage
- Set size limits – Prevent abuse
- Optimize images – Resize and compress before storage
- Check permissions – Verify user can upload/delete
❌ Don't
- Don't trust user input – Validate everything
- Don't use original filenames – Security risk
- Don't skip virus scanning – For production systems
- Don't forget cleanup – Remove orphaned files
- Don't store large files – Use cloud storage
- Don't expose storage paths – Use URLs instead
Testing
Mock File System
// tests/mocks/filesystem.mock.ts
export class MockFileSystem extends AbstractFileSystem {
private files = new Map<string, Buffer>();
async write(file: { filename: string; buffer: Buffer }): Promise<string> {
const filename = `${Date.now()}-${file.filename}`;
this.files.set(filename, file.buffer);
return filename;
}
async delete(filename: string): Promise<void> {
this.files.delete(filename);
}
has(filename: string): boolean {
return this.files.has(filename);
}
clear() {
this.files.clear();
}
}
File Upload Tests
describe('FileSystemUseCases', () => {
let useCases: FileSystemUseCases;
let fileSystem: MockFileSystem;
let uploadRepo: UploadRepository;
beforeEach(() => {
fileSystem = new MockFileSystem();
uploadRepo = new UploadRepository();
useCases = new FileSystemUseCases(fileSystem, uploadRepo);
});
it('should upload file', async () => {
const file = {
originalname: 'test.jpg',
buffer: Buffer.from('test'),
mimetype: 'image/jpeg',
size: 1024,
} as Express.Multer.File;
const upload = await useCases.uploadFile({
file,
userId: 'user-123',
});
expect(upload.originalName).toBe('test.jpg');
expect(upload.mimetype).toBe('image/jpeg');
expect(fileSystem.has(upload.filename)).toBe(true);
});
it('should reject large files', async () => {
const file = {
originalname: 'large.jpg',
buffer: Buffer.alloc(20 * 1024 * 1024), // 20MB
mimetype: 'image/jpeg',
size: 20 * 1024 * 1024,
} as Express.Multer.File;
await expect(
useCases.uploadFile({ file, userId: 'user-123' })
).rejects.toThrow('File too large');
});
});
Summary
The storage system provides:
- Flexible storage with local and cloud options
- File validation for security
- Upload tracking in database
- Unique filenames to prevent collisions
- Easy migration to cloud providers
- Image processing capabilities
- Access control for file operations
This abstraction layer allows easy switching between local development storage and cloud production storage without code changes.