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/presignreturns valid presigned URL - β’ Direct upload to S3 works with presigned URL
- β’
POST /api/v1/users/me/avatar/confirmupdates Profile.avatarUrl - β’ Old avatar deleted when new one uploaded
- β’
DELETE /api/v1/users/me/avatarremoves avatar - β’ Validation rejects wrong file types/sizes
Risk Assessment
| Risk | Mitigation |
|---|---|
| User uploads to wrong key | Validate key pattern includes userId |
| Orphan files if confirm not called | Lifecycle policy on temp uploads |
| Race condition on delete | Delete after update succeeds |