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

Profile Image System

Phase 3: Profile Image Upload

Overview

Implement profile avatar upload as the first concrete use case. Client uploads directly to S3, then confirms to backend which updates Profile.avatarUrl.

Requirements

Functional:

  • β€’User requests presigned URL for avatar upload
  • β€’User uploads image directly to S3
  • β€’User confirms upload, backend validates and updates profile
  • β€’Old avatar deleted on replacement

Non-functional:

  • β€’Max avatar size: 5MB
  • β€’Allowed formats: JPEG, PNG, WebP
  • β€’Avatar key format: profiles/{userId}/avatar.{ext}

Architecture

Upload Flow:

1. Client: POST /api/v1/users/me/avatar/presign {fileName, contentType, contentLength}
2. Server: Returns {uploadUrl, fileKey, publicUrl}
3. Client: PUT uploadUrl (binary data β†’ S3)
4. Client: POST /api/v1/users/me/avatar/confirm {fileKey}
5. Server: Validates file exists, updates Profile.avatarUrl, deletes old avatar

Implementation Steps

1. Create Avatar Upload DTOs

src/modules/users/dto/avatar-presign.dto.ts:

import { IsString, IsNumber, Max, Matches } from 'class-validator';

export class AvatarPresignDto {
  @IsString()
  fileName: string;

  @IsString()
  @Matches(/^image\/(jpeg|png|webp)$/, { message: 'Only JPEG, PNG, WebP allowed' })
  contentType: string;

  @IsNumber()
  @Max(5 * 1024 * 1024, { message: 'Max file size is 5MB' })
  contentLength: number;
}

src/modules/users/dto/avatar-confirm.dto.ts:

import { IsString, Matches } from 'class-validator';

export class AvatarConfirmDto {
  @IsString()
  @Matches(/^profiles\/[a-f0-9-]+\/[a-f0-9]+\.\w+$/, { message: 'Invalid file key' })
  fileKey: string;
}

2. Add Avatar Methods to Users Service

Add to src/modules/users/users.service.ts:

import { StorageService } from '../storage/storage.service';
import { UploadCategory } from '../storage/enums/upload-category.enum';

@Injectable()
export class UsersService {
  constructor(
    private prisma: PrismaService,
    private storageService: StorageService,
  ) {}

  async getAvatarPresignUrl(userId: string, dto: AvatarPresignDto) {
    return this.storageService.generateUploadUrl(
      {
        category: UploadCategory.PROFILE,
        fileName: dto.fileName,
        contentType: dto.contentType,
        contentLength: dto.contentLength,
        entityId: userId,
      },
      userId,
    );
  }

  async confirmAvatarUpload(userId: string, dto: AvatarConfirmDto) {
    // Validate the key belongs to this user
    if (!dto.fileKey.startsWith(`profiles/${userId}/`)) {
      throw new ForbiddenException('Invalid file key for this user');
    }

    // Get current profile to delete old avatar
    const profile = await this.prisma.profile.findUnique({
      where: { userId },
      select: { avatarUrl: true },
    });

    // Update profile with new avatar URL
    const cdnUrl = this.storageService.getCdnUrl();
    const newAvatarUrl = `${cdnUrl}/${dto.fileKey}`;

    await this.prisma.profile.update({
      where: { userId },
      data: { avatarUrl: newAvatarUrl },
    });

    // Delete old avatar if exists and different
    if (profile?.avatarUrl && profile.avatarUrl !== newAvatarUrl) {
      const oldKey = this.extractKeyFromUrl(profile.avatarUrl);
      if (oldKey) {
        await this.storageService.deleteFile(oldKey).catch(() => {});
      }
    }

    return { avatarUrl: newAvatarUrl };
  }

  async deleteAvatar(userId: string) {
    const profile = await this.prisma.profile.findUnique({
      where: { userId },
      select: { avatarUrl: true },
    });

    if (profile?.avatarUrl) {
      const key = this.extractKeyFromUrl(profile.avatarUrl);
      if (key) {
        await this.storageService.deleteFile(key).catch(() => {});
      }
    }

    await this.prisma.profile.update({
      where: { userId },
      data: { avatarUrl: null },
    });
  }

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

3. Add Avatar Endpoints to Users Controller

Add to src/modules/users/users.controller.ts:

@Post('me/avatar/presign')
@ApiOperation({ summary: 'Get presigned URL for avatar upload' })
async getAvatarPresignUrl(
  @GetUser('sub') userId: string,
  @Body() dto: AvatarPresignDto,
) {
  return this.usersService.getAvatarPresignUrl(userId, dto);
}

@Post('me/avatar/confirm')
@ApiOperation({ summary: 'Confirm avatar upload and update profile' })
async confirmAvatarUpload(
  @GetUser('sub') userId: string,
  @Body() dto: AvatarConfirmDto,
) {
  return this.usersService.confirmAvatarUpload(userId, dto);
}

@Delete('me/avatar')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete avatar' })
async deleteAvatar(@GetUser('sub') userId: string) {
  return this.usersService.deleteAvatar(userId);
}

4. Update Users Module

Add StorageModule import to src/modules/users/users.module.ts:

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

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

5. Add getCdnUrl to StorageService

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

getCdnUrl(): string {
  return this.cdnUrl;
}

Related Code Files

  • β€’Create: src/modules/users/dto/avatar-presign.dto.ts
  • β€’Create: src/modules/users/dto/avatar-confirm.dto.ts
  • β€’Modify: src/modules/users/users.service.ts
  • β€’Modify: src/modules/users/users.controller.ts
  • β€’Modify: src/modules/users/users.module.ts
  • β€’Modify: src/modules/storage/storage.service.ts

Success Criteria

  • β€’ POST /api/v1/users/me/avatar/presign returns valid presigned URL
  • β€’ Direct upload to S3 works with presigned URL
  • β€’ POST /api/v1/users/me/avatar/confirm updates Profile.avatarUrl
  • β€’ Old avatar deleted when new one uploaded
  • β€’ DELETE /api/v1/users/me/avatar removes avatar
  • β€’ Validation rejects wrong file types/sizes

Risk Assessment

RiskMitigation
User uploads to wrong keyValidate key pattern includes userId
Orphan files if confirm not calledLifecycle policy on temp uploads
Race condition on deleteDelete after update succeeds