AWS S3 Storage System/Phase 2 of 62-3 hours

Core Storage Service

Phase 2: Core Storage Service

Overview

Build the core NestJS StorageModule with S3 client, presigned URL generation, and file management utilities.

Requirements

Functional:

  • •Generate presigned PUT URLs for client uploads
  • •Generate presigned GET URLs for private content
  • •Delete files from S3
  • •Validate file types and sizes

Non-functional:

  • •Presigned URL expiration: 15 minutes (upload), 1 hour (download)
  • •Max file size: 10MB (images), 50MB (documents)
  • •Allowed types: JPEG, PNG, WebP, GIF, PDF

Architecture

src/modules/storage/
ā”œā”€ā”€ storage.module.ts
ā”œā”€ā”€ storage.service.ts
ā”œā”€ā”€ storage.controller.ts
ā”œā”€ā”€ dto/
│   ā”œā”€ā”€ presigned-url-request.dto.ts
│   └── presigned-url-response.dto.ts
ā”œā”€ā”€ enums/
│   └── upload-category.enum.ts
└── constants/
    └── storage.constants.ts

Implementation Steps

1. Install Dependencies

cd pickleballdot-admin-be
pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

2. Create Storage Constants

src/modules/storage/constants/storage.constants.ts:

export const STORAGE_CONFIG = {
  MAX_FILE_SIZE: {
    IMAGE: 10 * 1024 * 1024,      // 10MB
    DOCUMENT: 50 * 1024 * 1024,   // 50MB
  },
  ALLOWED_MIME_TYPES: {
    IMAGE: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'],
    DOCUMENT: ['application/pdf'],
  },
  PRESIGNED_EXPIRY: {
    UPLOAD: 15 * 60,   // 15 minutes
    DOWNLOAD: 60 * 60, // 1 hour
  },
};

3. Create Upload Category Enum

src/modules/storage/enums/upload-category.enum.ts:

export enum UploadCategory {
  PROFILE = 'profiles',
  COURT = 'courts',
  CLUB = 'clubs',
  POST = 'posts',
  TOURNAMENT = 'tournaments',
  MESSAGE = 'messages',
}

4. Create DTOs

src/modules/storage/dto/presigned-url-request.dto.ts:

import {
  IsEnum,
  IsString,
  IsNumber,
  Max,
  Min,
  IsOptional,
  Matches,
  MaxLength,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { UploadCategory } from '../enums/upload-category.enum';

export class PresignedUrlRequestDto {
  @ApiProperty({ enum: UploadCategory })
  @IsEnum(UploadCategory)
  category!: UploadCategory;

  @ApiProperty({ example: 'avatar.jpg' })
  @IsString()
  @MaxLength(100)
  @Matches(/^[a-zA-Z0-9._-]+$/, {
    message: 'fileName must contain only alphanumeric, dots, underscores, hyphens',
  })
  fileName!: string;

  @ApiProperty({ example: 'image/jpeg' })
  @IsString()
  contentType!: string;

  @ApiProperty({ example: 1024000, description: 'File size in bytes (1 byte - 50MB)' })
  @IsNumber()
  @Min(1)  // Prevent zero-byte uploads
  @Max(50 * 1024 * 1024)
  contentLength!: number;

  @ApiPropertyOptional({ description: 'Entity ID (courtId, clubId, etc.)' })
  @IsString()
  @IsOptional()
  @Matches(/^[a-zA-Z0-9-]{1,36}$/, {
    message: 'entityId must be alphanumeric with hyphens, max 36 chars',
  })
  entityId?: string;
}

src/modules/storage/dto/presigned-url-response.dto.ts:

export class PresignedUrlResponseDto {
  uploadUrl: string;
  fileKey: string;
  publicUrl: string;
  expiresAt: Date;
}

5. Create Storage Service

src/modules/storage/storage.service.ts:

import {
  Injectable,
  BadRequestException,
  ForbiddenException,
  InternalServerErrorException,
  Logger,
  NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
  S3Client,
  PutObjectCommand,
  GetObjectCommand,
  DeleteObjectCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { randomUUID } from 'crypto';
import { UploadCategory } from './enums/upload-category.enum';
import { STORAGE_CONFIG } from './constants/storage.constants';
import { PresignedUrlRequestDto } from './dto/presigned-url-request.dto';
import { PresignedUrlResponseDto } from './dto/presigned-url-response.dto';

@Injectable()
export class StorageService {
  private readonly logger = new Logger(StorageService.name);
  private readonly s3Client: S3Client;
  private readonly bucket: string;
  private readonly publicUrl: string;

  constructor(private config: ConfigService) {
    const region =
      config.get('S3_REGION') || config.get('AWS_REGION', 'ap-southeast-1');
    this.s3Client = new S3Client({
      region,
      credentials: {
        accessKeyId:
          config.get('S3_ACCESS_KEY_ID') ||
          config.getOrThrow('AWS_ACCESS_KEY_ID'),
        secretAccessKey:
          config.get('S3_SECRET_ACCESS_KEY') ||
          config.getOrThrow('AWS_SECRET_ACCESS_KEY'),
      },
    });
    this.bucket = config.get('S3_BUCKET') || config.getOrThrow('AWS_S3_BUCKET');
    this.publicUrl =
      config.get('STORAGE_PUBLIC_URL') ||
      `https://${this.bucket}.s3.${region}.amazonaws.com`;
  }

  async generateUploadUrl(
    dto: PresignedUrlRequestDto,
    userId: string,
  ): Promise<PresignedUrlResponseDto> {
    this.validateFile(dto.contentType, dto.contentLength);

    const sanitizedFileName = this.sanitizeFileName(dto.fileName);
    const fileKey = this.generateFileKey(
      dto.category,
      dto.entityId || userId,
      sanitizedFileName,
    );

    const command = new PutObjectCommand({
      Bucket: this.bucket,
      Key: fileKey,
      ContentType: dto.contentType,
      Metadata: {
        'uploaded-by': userId,
        'original-name': sanitizedFileName,
      },
    });

    try {
      const uploadUrl = await getSignedUrl(this.s3Client, command, {
        expiresIn: STORAGE_CONFIG.PRESIGNED_EXPIRY.UPLOAD,
      });

      return {
        uploadUrl,
        fileKey,
        publicUrl: `${this.publicUrl}/${fileKey}`,
        expiresAt: new Date(
          Date.now() + STORAGE_CONFIG.PRESIGNED_EXPIRY.UPLOAD * 1000,
        ),
      };
    } catch (error) {
      this.logger.error('Failed to generate upload URL', error);
      throw new InternalServerErrorException('Failed to generate upload URL');
    }
  }

  async generateDownloadUrl(fileKey: string): Promise<string> {
    const command = new GetObjectCommand({
      Bucket: this.bucket,
      Key: fileKey,
    });

    return getSignedUrl(this.s3Client, command, {
      expiresIn: STORAGE_CONFIG.PRESIGNED_EXPIRY.DOWNLOAD,
    });
  }

  async deleteFile(fileKey: string, userId?: string): Promise<void> {
    this.validateFileKey(fileKey, userId);

    try {
      await this.s3Client.send(
        new DeleteObjectCommand({
          Bucket: this.bucket,
          Key: fileKey,
        }),
      );
      this.logger.log(`Deleted file: ${fileKey}`);
    } catch (error: unknown) {
      this.logger.error(`Failed to delete file: ${fileKey}`, error);
      if (error instanceof Error && error.name === 'NoSuchKey') {
        throw new NotFoundException('File not found');
      }
      throw new InternalServerErrorException('Failed to delete file');
    }
  }

  async deleteFiles(fileKeys: string[]): Promise<void> {
    await Promise.all(fileKeys.map((key) => this.deleteFile(key)));
  }

  getPublicUrl(): string {
    return this.publicUrl;
  }

  extractKeyFromUrl(url: string): string | null {
    try {
      const urlObj = new URL(url);
      return urlObj.pathname.slice(1);
    } catch {
      return null;
    }
  }

  private generateFileKey(
    category: UploadCategory,
    entityId: string,
    fileName: string,
  ): string {
    const ext = fileName.split('.').pop()?.toLowerCase() || 'bin';
    const uniqueId = randomUUID().slice(0, 8);
    return `${category}/${entityId}/${uniqueId}.${ext}`;
  }

  private validateFile(contentType: string, contentLength: number): void {
    const isImage =
      STORAGE_CONFIG.ALLOWED_MIME_TYPES.IMAGE.includes(contentType);
    const isDocument =
      STORAGE_CONFIG.ALLOWED_MIME_TYPES.DOCUMENT.includes(contentType);

    if (!isImage && !isDocument) {
      throw new BadRequestException(`File type ${contentType} not allowed`);
    }

    const maxSize = isImage
      ? STORAGE_CONFIG.MAX_FILE_SIZE.IMAGE
      : STORAGE_CONFIG.MAX_FILE_SIZE.DOCUMENT;

    if (contentLength > maxSize) {
      throw new BadRequestException(
        `File size exceeds limit (${maxSize / 1024 / 1024}MB)`,
      );
    }
  }

  // Security: Validate fileKey to prevent path traversal and unauthorized access
  private validateFileKey(fileKey: string, userId?: string): void {
    if (fileKey.includes('..') || fileKey.startsWith('/')) {
      throw new BadRequestException('Invalid file key');
    }

    const validPattern =
      /^(profiles|courts|clubs|posts|tournaments|messages)\/[a-zA-Z0-9-]+\/[a-zA-Z0-9-]+\.\w+$/;
    if (!validPattern.test(fileKey)) {
      throw new BadRequestException('Invalid file key format');
    }

    // Ownership check: user can only delete their own files
    if (userId) {
      const isProfileOwner = fileKey.startsWith(`profiles/${userId}/`);
      const isEntityOwner = fileKey.includes(`/${userId}/`);
      if (!isProfileOwner && !isEntityOwner) {
        throw new ForbiddenException('Cannot delete files you do not own');
      }
    }
  }

  // Security: Sanitize filename to prevent special character injection
  private sanitizeFileName(fileName: string): string {
    return fileName
      .replace(/[\/\\]/g, '')
      .replace(/[^a-zA-Z0-9._-]/g, '_')
      .slice(0, 100);
  }
}

6. Create Storage Controller

src/modules/storage/storage.controller.ts:

import {
  Controller,
  Post,
  Delete,
  Body,
  Param,
  HttpCode,
  HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { StorageService } from './storage.service';
import { GetUser } from '../../common/decorators/get-user.decorator';
import { PresignedUrlRequestDto } from './dto/presigned-url-request.dto';
import { PresignedUrlResponseDto } from './dto/presigned-url-response.dto';

@ApiTags('storage')
@ApiBearerAuth()
@Controller('storage')
export class StorageController {
  constructor(private storageService: StorageService) {}

  @Post('presigned-url')
  @ApiOperation({ summary: 'Get presigned URL for file upload' })
  async getPresignedUrl(
    @Body() dto: PresignedUrlRequestDto,
    @GetUser('sub') userId: string,
  ): Promise<PresignedUrlResponseDto> {
    return this.storageService.generateUploadUrl(dto, userId);
  }

  @Delete(':fileKey')
  @HttpCode(HttpStatus.NO_CONTENT)
  @ApiOperation({ summary: 'Delete a file from storage' })
  async deleteFile(
    @Param('fileKey') fileKey: string,
    @GetUser('sub') userId: string,  // Pass userId for ownership check
  ): Promise<void> {
    return this.storageService.deleteFile(decodeURIComponent(fileKey), userId);
  }
}

7. Create Storage Module

src/modules/storage/storage.module.ts:

import { Module } from '@nestjs/common';
import { StorageService } from './storage.service';
import { StorageController } from './storage.controller';

@Module({
  controllers: [StorageController],
  providers: [StorageService],
  exports: [StorageService],
})
export class StorageModule {}

8. Register Module

Add to app.module.ts:

import { StorageModule } from './modules/storage/storage.module';

@Module({
  imports: [
    // ...existing imports
    StorageModule,
  ],
})

Related Code Files

  • •Create: src/modules/storage/storage.module.ts
  • •Create: src/modules/storage/storage.service.ts
  • •Create: src/modules/storage/storage.controller.ts
  • •Create: src/modules/storage/dto/presigned-url-request.dto.ts
  • •Create: src/modules/storage/dto/presigned-url-response.dto.ts
  • •Create: src/modules/storage/enums/upload-category.enum.ts
  • •Create: src/modules/storage/constants/storage.constants.ts
  • •Modify: src/app.module.ts

Success Criteria

  • • StorageModule created and registered
  • • Presigned URL generation works (test with curl)
  • • File type validation rejects invalid types
  • • File size validation enforces limits
  • • Delete endpoint removes files from S3
  • • Swagger docs show storage endpoints

Risk Assessment

RiskMitigation
Presigned URL leakShort expiry (15min), audit logs
Large file DoSContent-Length validation before presign
S3 SDK errorsProper error handling, NestJS exceptions (no raw SDK errors)
Path traversalvalidateFileKey() rejects .. and / prefixes
Unauthorized deleteOwnership check via userId in fileKey path
Zero-byte uploads@Min(1) validation on contentLength
Filename injectionsanitizeFileName() strips special chars
Invalid entityId@Matches pattern validation (alphanumeric + hyphens)

Security Hardening (2026-05-08)

IssueFix Applied
Path traversal in deletevalidateFileKey() rejects .., /, validates category/id/uuid.ext pattern
No authorization on deleteController passes userId, service checks ownership
Zero file size allowedAdded @Min(1) to contentLength DTO
Filename not sanitizedAdded sanitizeFileName() - strips specials, limits 100 chars
entityId not validatedAdded @Matches(/^[a-zA-Z0-9-]{1,36}$/)
AWS errors leakWrapped in try-catch, throw NestJS exceptions