diff --git a/src/module/gamification/persistence/gamification-dao.service.ts b/src/module/gamification/persistence/gamification-dao.service.ts index f83f348..1d97119 100644 --- a/src/module/gamification/persistence/gamification-dao.service.ts +++ b/src/module/gamification/persistence/gamification-dao.service.ts @@ -111,6 +111,20 @@ export class GamificationDao { .exec(); } + async updateBadgeImageUrl( + projectId: string, + badgeId: string, + imageUrl: string, + ): Promise { + return this.gamificationModel + .findOneAndUpdate( + { projectId, 'badges._id': badgeId }, + { $set: { 'badges.$.imageUrl': imageUrl } }, + { new: true }, + ) + .exec(); + } + /** * Moves a badge rule through the fading lifecycle, keeping the window * fields consistent with the status so `effectiveBadgeStatus` never has diff --git a/src/module/project/project-service.spec.ts b/src/module/project/project-service.spec.ts index a289daf..f97f5ac 100644 --- a/src/module/project/project-service.spec.ts +++ b/src/module/project/project-service.spec.ts @@ -14,6 +14,8 @@ import { LeaderboardService } from '../leaderboard/leaderboard.service'; import { BadgeRule } from '../gamification/entities/gamification.entity'; import { getModelToken } from '@nestjs/mongoose'; import { CheckInTemplate } from '../checkin/persistence/checkin.schema'; +import { StorageService } from '../storage/storage.service'; +import { GamificationDao } from '../gamification/persistence/gamification-dao.service'; const mockCheckInModel = { find: jest.fn().mockReturnThis(), @@ -37,6 +39,18 @@ const mockLeaderboardService = { getLeaderboardFor: jest.fn(), }; +const mockStorageService = { + uploadFile: jest.fn(), + optimizeImage: jest.fn(), + getFile: jest.fn(), +}; + +const mockGamificationDao = { + getBadgesByProject: jest.fn(), + updateBadge: jest.fn(), + updateBadgeImageUrl: jest.fn(), +}; + describe('ProjectService', () => { let service: ProjectService; @@ -47,6 +61,8 @@ describe('ProjectService', () => { { provide: ProjectDao, useValue: mockProjectDao }, { provide: UserService, useValue: mockUserService }, { provide: LeaderboardService, useValue: mockLeaderboardService }, + { provide: StorageService, useValue: mockStorageService }, + { provide: GamificationDao, useValue: mockGamificationDao }, { provide: getModelToken(CheckInTemplate.collectionName()), useValue: mockCheckInModel, @@ -225,4 +241,69 @@ describe('ProjectService', () => { expect(mockProjectDao.findOne).toHaveBeenCalledWith('p1'); }); }); + + describe('migrateImages', () => { + it('should migrate external project cover and badge images', async () => { + const project = ProjectBuilder.build(); + project.image = 'https://example.com/cover.png'; + mockProjectDao.findOne.mockResolvedValue(project); + + const badgesDoc = { + badges: [ + { + _id: 'b1', + name: 'Pioneer', + imageUrl: 'https://example.com/badge1.png', + }, + { + _id: 'b2', + name: 'Local', + imageUrl: 'badges/internal.jpg', // already migrated + }, + ], + }; + mockGamificationDao.getBadgesByProject.mockResolvedValue(badgesDoc); + + jest.spyOn(service, 'fetchImageBuffer').mockResolvedValue({ + buffer: Buffer.from('fake-image-bytes'), + mimetype: 'image/png', + }); + + mockStorageService.optimizeImage.mockImplementation(async (f) => f); + mockStorageService.uploadFile + .mockResolvedValueOnce('projects/p1/cover.jpg') + .mockResolvedValueOnce('badges/b1.jpg'); + + const result = await service.migrateImages('p1'); + + expect(result.projectImageMigrated).toBe(true); + expect(result.badgesMigratedCount).toBe(1); + expect(result.failures).toHaveLength(0); + expect(mockProjectDao.update).toHaveBeenCalledWith('p1', { + image: 'projects/p1/cover.jpg', + }); + expect(mockGamificationDao.updateBadgeImageUrl).toHaveBeenCalledWith( + 'p1', + 'b1', + 'badges/b1.jpg', + ); + }); + + it('should record failures gracefully when fetch fails', async () => { + const project = ProjectBuilder.build(); + project.image = 'https://broken.com/cover.png'; + mockProjectDao.findOne.mockResolvedValue(project); + mockGamificationDao.getBadgesByProject.mockResolvedValue({ badges: [] }); + + jest + .spyOn(service, 'fetchImageBuffer') + .mockRejectedValue(new Error('Network error')); + + const result = await service.migrateImages('p1'); + + expect(result.projectImageMigrated).toBe(false); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]).toContain('Project cover: Network error'); + }); + }); }); diff --git a/src/module/project/project.controller.spec.ts b/src/module/project/project.controller.spec.ts index 3ed4960..fd8a760 100644 --- a/src/module/project/project.controller.spec.ts +++ b/src/module/project/project.controller.spec.ts @@ -17,6 +17,7 @@ describe('ProjectController', () => { findOnePublic: jest.fn(), update: jest.fn(), toggleAvailable: jest.fn(), + migrateImages: jest.fn(), }; const mockUserService = {}; @@ -81,4 +82,15 @@ describe('ProjectController', () => { await controller.toggleAvailable('1'); expect(service.toggleAvailable).toHaveBeenCalledWith('1'); }); + + it('should call migrateImages', async () => { + mockProjectService.migrateImages = jest.fn().mockResolvedValue({ + projectId: '1', + projectImageMigrated: true, + badgesMigratedCount: 0, + failures: [], + }); + await controller.migrateImages('1'); + expect(mockProjectService.migrateImages).toHaveBeenCalledWith('1'); + }); }); diff --git a/src/module/project/project.controller.ts b/src/module/project/project.controller.ts index 4f6f714..1c781af 100644 --- a/src/module/project/project.controller.ts +++ b/src/module/project/project.controller.ts @@ -68,4 +68,11 @@ export class ProjectController { toggleAvailable(@Param('id') id: string) { return this.projectService.toggleAvailable(id); } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.Admin) + @Post('/:id/migrate-images') + migrateImages(@Param('id') id: string) { + return this.projectService.migrateImages(id); + } } diff --git a/src/module/project/project.module.ts b/src/module/project/project.module.ts index ef3eee1..39a06aa 100644 --- a/src/module/project/project.module.ts +++ b/src/module/project/project.module.ts @@ -12,6 +12,7 @@ import { CheckInSchema, CheckInTemplate, } from '../checkin/persistence/checkin.schema'; +import { StorageModule } from '../storage/storage.module'; @Module({ imports: [ @@ -22,6 +23,7 @@ import { ]), AuthModule, GamificationModule, + StorageModule, ], controllers: [ProjectController], providers: [ProjectService, ProjectDao], diff --git a/src/module/project/project.service.ts b/src/module/project/project.service.ts index cca7ba3..ed38014 100644 --- a/src/module/project/project.service.ts +++ b/src/module/project/project.service.ts @@ -29,6 +29,10 @@ import { } from '../checkin/persistence/checkin.schema'; import { CheckinMapper } from '../checkin/persistence/CheckinMapper'; import { BasicBadgeEngine } from '../gamification/entities/engine/gamification/basic-badge-engine'; +import { StorageService } from '../storage/storage.service'; +import { GamificationDao } from '../gamification/persistence/gamification-dao.service'; +import http from 'http'; +import https from 'https'; export interface UserStatus { isSubscribed: boolean; @@ -37,6 +41,13 @@ export interface UserStatus { leaderboard: Leaderboard; } +export interface ImageMigrationResult { + projectId: string; + projectImageMigrated: boolean; + badgesMigratedCount: number; + failures: string[]; +} + @Injectable() export class ProjectService { constructor( @@ -45,6 +56,8 @@ export class ProjectService { private readonly leaderboardService: LeaderboardService, @InjectModel(CheckInTemplate.collectionName()) private readonly checkInModel: Model, + private readonly storageService: StorageService, + private readonly gamificationDao: GamificationDao, ) {} async findAll(): Promise<(ProjectTemplate & { _id: string })[]> { @@ -202,4 +215,173 @@ export class ProjectService { findOnePublic(id: string) { return this.projectDao.findOne(id); } + + /** + * Downloads an image from an external HTTP/HTTPS URL with timeout and size cap (10MB). + */ + async fetchImageBuffer( + url: string, + timeoutMs = 8000, + ): Promise<{ buffer: Buffer; mimetype: string }> { + return new Promise((resolve, reject) => { + const client = url.startsWith('https') ? https : http; + const req = client.get( + url, + { + timeout: timeoutMs, + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Rayuela/1.0', + }, + }, + (res) => { + if ( + res.statusCode && + (res.statusCode === 301 || res.statusCode === 302) && + res.headers.location + ) { + return this.fetchImageBuffer(res.headers.location, timeoutMs) + .then(resolve) + .catch(reject); + } + + if ( + !res.statusCode || + res.statusCode < 200 || + res.statusCode >= 300 + ) { + return reject( + new Error(`Failed to fetch image: HTTP status ${res.statusCode}`), + ); + } + + const contentType = res.headers['content-type'] || 'image/jpeg'; + const chunks: Buffer[] = []; + let totalLength = 0; + const maxBytes = 10 * 1024 * 1024; // 10MB limit + + res.on('data', (chunk: Buffer) => { + totalLength += chunk.length; + if (totalLength > maxBytes) { + req.destroy(new Error('Image exceeds 10MB size limit')); + return; + } + chunks.push(chunk); + }); + + res.on('end', () => { + resolve({ + buffer: Buffer.concat(chunks), + mimetype: contentType, + }); + }); + }, + ); + + req.on('timeout', () => { + req.destroy(new Error(`Timeout fetching image after ${timeoutMs}ms`)); + }); + + req.on('error', (err) => { + reject(err); + }); + }); + } + + /** + * Migrates external project cover image and badge images into Garage S3. + */ + async migrateImages(projectId: string): Promise { + const project = await this.projectDao.findOne(projectId); + if (!project) { + throw new NotFoundException('Project not found'); + } + + const failures: string[] = []; + let projectImageMigrated = false; + let badgesMigratedCount = 0; + + // 1. Migrate Project Cover Image + if ( + project.image && + (project.image.startsWith('http://') || + project.image.startsWith('https://')) + ) { + try { + const { buffer, mimetype } = await this.fetchImageBuffer(project.image); + const ext = mimetype.includes('png') + ? 'png' + : mimetype.includes('webp') + ? 'webp' + : 'jpg'; + const optimized = await this.storageService.optimizeImage( + { buffer, mimetype, originalname: `project-cover.${ext}` }, + { maxDimension: 1600, quality: 80 }, + ); + const key = await this.storageService.uploadFile( + optimized, + `projects/${projectId}`, + ); + await this.projectDao.update(projectId, { image: key }); + projectImageMigrated = true; + } catch (err: any) { + failures.push(`Project cover: ${err?.message || err}`); + } + } + + // 2. Migrate Badge Images + const gamificationDoc = + await this.gamificationDao.getBadgesByProject(projectId); + if ( + gamificationDoc && + gamificationDoc.badges && + gamificationDoc.badges.length > 0 + ) { + for (const badge of gamificationDoc.badges) { + if ( + badge.imageUrl && + (badge.imageUrl.startsWith('http://') || + badge.imageUrl.startsWith('https://')) + ) { + try { + const { buffer, mimetype } = await this.fetchImageBuffer( + badge.imageUrl, + ); + const ext = mimetype.includes('png') + ? 'png' + : mimetype.includes('webp') + ? 'webp' + : 'jpg'; + const optimized = await this.storageService.optimizeImage( + { + buffer, + mimetype, + originalname: `${badge.name || 'badge'}.${ext}`, + }, + { maxDimension: 512, quality: 85 }, + ); + const key = await this.storageService.uploadFile( + optimized, + 'badges', + ); + await this.gamificationDao.updateBadgeImageUrl( + projectId, + String(badge._id), + key, + ); + badgesMigratedCount++; + } catch (err: any) { + failures.push(`Badge "${badge.name}": ${err?.message || err}`); + } + } + } + } + + return { + projectId, + projectImageMigrated, + badgesMigratedCount, + failures, + }; + } } diff --git a/src/module/storage/storage.controller.spec.ts b/src/module/storage/storage.controller.spec.ts index bd5d725..bac6aeb 100644 --- a/src/module/storage/storage.controller.spec.ts +++ b/src/module/storage/storage.controller.spec.ts @@ -10,6 +10,8 @@ describe('StorageController', () => { const mockStorageService = { getFile: jest.fn(), + optimizeImage: jest.fn(), + uploadFile: jest.fn(), }; beforeEach(async () => { @@ -71,4 +73,46 @@ describe('StorageController', () => { ); }); }); + + describe('uploadFile', () => { + it('should optimize image and upload to specified folder', async () => { + const mockFile = { + originalname: 'badge.png', + buffer: Buffer.from('image-data'), + mimetype: 'image/png', + size: 100, + } as Express.Multer.File; + + const optimized = { + ...mockFile, + buffer: Buffer.from('optimized-data'), + }; + + mockStorageService.optimizeImage = jest.fn().mockResolvedValue(optimized); + mockStorageService.uploadFile = jest + .fn() + .mockResolvedValue('badges/unique-id.jpg'); + + const result = await controller.uploadFile(mockFile, 'badges'); + + expect(mockStorageService.optimizeImage).toHaveBeenCalledWith(mockFile, { + maxDimension: 512, + quality: 85, + }); + expect(mockStorageService.uploadFile).toHaveBeenCalledWith( + optimized, + 'badges', + ); + expect(result).toEqual({ + key: 'badges/unique-id.jpg', + url: '/storage/file?key=badges%2Funique-id.jpg', + }); + }); + + it('should throw BadRequestException if no file is provided', async () => { + await expect( + controller.uploadFile(null as any, 'badges'), + ).rejects.toThrow(); + }); + }); }); diff --git a/src/module/storage/storage.controller.ts b/src/module/storage/storage.controller.ts index a7c5289..82be94a 100644 --- a/src/module/storage/storage.controller.ts +++ b/src/module/storage/storage.controller.ts @@ -1,7 +1,31 @@ -import { Controller, Get, Query, Res, NotFoundException } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Query, + Res, + NotFoundException, + UseGuards, + UseInterceptors, + UploadedFile, + BadRequestException, +} from '@nestjs/common'; import { StorageService } from './storage.service'; import { Response } from 'express'; import { Stream } from 'stream'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { JwtAuthGuard } from '../auth/auth.guard'; +import { RolesGuard } from '../auth/roles.guard'; +import { Roles } from '../auth/role.decorator'; +import { UserRole } from '../auth/users/user.schema'; + +const ALLOWED_ADMIN_IMAGE_MIMES = new Set([ + 'image/jpeg', + 'image/png', + 'image/webp', +]); +const MAX_ADMIN_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB +const ALLOWED_STORAGE_FOLDERS = new Set(['badges', 'projects']); @Controller('storage') export class StorageController { @@ -26,4 +50,56 @@ export class StorageController { throw new NotFoundException('File not found'); } } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.Admin) + @Post('upload') + @UseInterceptors( + FileInterceptor('file', { + limits: { fileSize: MAX_ADMIN_IMAGE_SIZE_BYTES }, + fileFilter: (_req, file, cb) => { + if (ALLOWED_ADMIN_IMAGE_MIMES.has(file.mimetype)) { + cb(null, true); + return; + } + cb( + new BadRequestException( + `Unsupported file type: ${file.mimetype}. Allowed types: image/jpeg, image/png, image/webp`, + ), + false, + ); + }, + }), + ) + async uploadFile( + @UploadedFile() file: Express.Multer.File, + @Query('folder') folder: string = 'badges', + ) { + if (!file) { + throw new BadRequestException('File is required'); + } + + const targetFolder = ALLOWED_STORAGE_FOLDERS.has(folder) + ? folder + : 'badges'; + const isBadge = targetFolder === 'badges'; + + // Optimize image with Sharp: + // Badges: max 512px, quality 85. + // Projects: max 1600px, quality 80. + const optimizedFile = await this.storageService.optimizeImage(file, { + maxDimension: isBadge ? 512 : 1600, + quality: isBadge ? 85 : 80, + }); + + const key = await this.storageService.uploadFile( + optimizedFile, + targetFolder, + ); + + return { + key, + url: `/storage/file?key=${encodeURIComponent(key)}`, + }; + } } diff --git a/src/module/storage/storage.service.spec.ts b/src/module/storage/storage.service.spec.ts index 5948656..037662c 100644 --- a/src/module/storage/storage.service.spec.ts +++ b/src/module/storage/storage.service.spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { StorageService } from './storage.service'; import { ConfigService } from '@nestjs/config'; import { S3Client } from '@aws-sdk/client-s3'; +import sharp from 'sharp'; jest.mock('@aws-sdk/client-s3'); @@ -60,4 +61,50 @@ describe('StorageService', () => { expect(result.contentType).toBe('image/png'); expect(S3Client.prototype.send).toHaveBeenCalled(); }); + + describe('optimizeImage', () => { + it('should return original buffer if file has no buffer', async () => { + const mockFile = {} as any; + const res = await service.optimizeImage(mockFile); + expect(res.buffer).toBeUndefined(); + }); + + it('should fail-open and return buffer on unexpected sharp failure', async () => { + const invalidFile = { + buffer: Buffer.from('not-a-real-image'), + originalname: 'corrupted.png', + mimetype: 'image/png', + size: 16, + }; + + const res = await service.optimizeImage(invalidFile); + expect(res.buffer).toEqual(invalidFile.buffer); + expect(res.originalname).toBe('corrupted.png'); + }); + + it('should optimize transparent PNG as WebP preserving transparency', async () => { + const transparentPngBuffer = await sharp({ + create: { + width: 10, + height: 10, + channels: 4, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }, + }) + .png() + .toBuffer(); + + const res = await service.optimizeImage({ + buffer: transparentPngBuffer, + originalname: 'badge-icon.png', + mimetype: 'image/png', + }); + + expect(res.mimetype).toBe('image/webp'); + expect(res.originalname).toBe('badge-icon.webp'); + const meta = await sharp(res.buffer).metadata(); + expect(meta.format).toBe('webp'); + expect(meta.hasAlpha).toBe(true); + }); + }); }); diff --git a/src/module/storage/storage.service.ts b/src/module/storage/storage.service.ts index c12d53c..22710b4 100644 --- a/src/module/storage/storage.service.ts +++ b/src/module/storage/storage.service.ts @@ -7,6 +7,13 @@ import { } from '@aws-sdk/client-s3'; import { v4 as uuidv4 } from 'uuid'; +import sharp from 'sharp'; + +export interface OptimizeImageOptions { + maxDimension?: number; + quality?: number; +} + @Injectable() export class StorageService { private readonly s3Client: S3Client; @@ -37,8 +44,99 @@ export class StorageService { } } + /** + * Optimizes an image buffer/file using sharp: auto-orient, resize within maxDimension, + * re-encode to JPEG quality. + * + * Zero-friction fail-open guarantee: falls back to original buffer on error. + */ + async optimizeImage( + file: { + buffer: Buffer; + originalname?: string; + mimetype?: string; + size?: number; + }, + options: OptimizeImageOptions = {}, + ): Promise<{ + buffer: Buffer; + originalname: string; + mimetype: string; + size: number; + }> { + const { maxDimension = 1600, quality = 80 } = options; + + if (!file || !file.buffer) { + return { + buffer: file?.buffer, + originalname: file?.originalname || 'image.jpg', + mimetype: file?.mimetype || 'image/jpeg', + size: file?.size || 0, + }; + } + + try { + const metadata = await sharp(file.buffer).metadata(); + const hasAlpha = !!metadata.hasAlpha; + const isPngOrWebp = + file.mimetype === 'image/png' || + file.mimetype === 'image/webp' || + metadata.format === 'png' || + metadata.format === 'webp'; + + const pipeline = sharp(file.buffer).rotate().resize({ + width: maxDimension, + height: maxDimension, + fit: 'inside', + withoutEnlargement: true, + }); + + let optimizedBuffer: Buffer; + let mimetype: string; + let extension: string; + + if (hasAlpha || isPngOrWebp) { + // Retain full alpha transparency using WebP + optimizedBuffer = await pipeline + .webp({ quality, alphaQuality: 100, effort: 4 }) + .toBuffer(); + mimetype = 'image/webp'; + extension = 'webp'; + } else { + optimizedBuffer = await pipeline + .jpeg({ quality, mozjpeg: true }) + .toBuffer(); + mimetype = 'image/jpeg'; + extension = 'jpg'; + } + + const originalBase = file.originalname + ? file.originalname.replace(/\.[^/.]+$/, '') + : 'image'; + + return { + buffer: optimizedBuffer, + size: optimizedBuffer.length, + mimetype, + originalname: `${originalBase}.${extension}`, + }; + } catch (error) { + this.logger.warn( + `Failed to optimize image ${file.originalname || 'unknown'}: ${error?.message || error}. Falling back to original buffer.`, + ); + return { + buffer: file.buffer, + size: file.size || file.buffer.length, + mimetype: file.mimetype || 'image/jpeg', + originalname: file.originalname || 'image.jpg', + }; + } + } + async uploadFile(file: any, folder: string): Promise { - const fileExtension = file.originalname.split('.').pop(); + const fileExtension = file.originalname + ? file.originalname.split('.').pop() + : 'jpg'; const fileName = `${folder}/${uuidv4()}.${fileExtension}`; const command = new PutObjectCommand({