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
| Risk | Mitigation |
|---|---|
| Presigned URL leak | Short expiry (15min), audit logs |
| Large file DoS | Content-Length validation before presign |
| S3 SDK errors | Proper error handling, NestJS exceptions (no raw SDK errors) |
| Path traversal | validateFileKey() rejects .. and / prefixes |
| Unauthorized delete | Ownership check via userId in fileKey path |
| Zero-byte uploads | @Min(1) validation on contentLength |
| Filename injection | sanitizeFileName() strips special chars |
| Invalid entityId | @Matches pattern validation (alphanumeric + hyphens) |
Security Hardening (2026-05-08)
| Issue | Fix Applied |
|---|---|
| Path traversal in delete | validateFileKey() rejects .., /, validates category/id/uuid.ext pattern |
| No authorization on delete | Controller passes userId, service checks ownership |
| Zero file size allowed | Added @Min(1) to contentLength DTO |
| Filename not sanitized | Added sanitizeFileName() - strips specials, limits 100 chars |
| entityId not validated | Added @Matches(/^[a-zA-Z0-9-]{1,36}$/) |
| AWS errors leak | Wrapped in try-catch, throw NestJS exceptions |