Skip to content

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.