From 30e0eacd961b3b82a70ef23dae7a341564ad1f9a Mon Sep 17 00:00:00 2001 From: AmzBG Date: Fri, 7 Aug 2026 14:57:07 +0300 Subject: [PATCH 1/2] feat(storage): migrate media to S3-compatible storage --- apps/api/.env.example | 7 + apps/api/package.json | 1 + apps/api/src/auth/auth.controller.spec.ts | 46 ++++ apps/api/src/auth/auth.controller.ts | 73 +++-- apps/api/src/auth/auth.module.ts | 3 +- apps/api/src/auth/utils/image-content-type.ts | 14 + apps/api/src/common/swagger/schemas.ts | 4 +- .../src/projects/projects.controller.spec.ts | 85 +++++- apps/api/src/projects/projects.controller.ts | 173 ++++-------- apps/api/src/projects/projects.module.ts | 5 +- apps/api/src/projects/projects.service.ts | 21 +- apps/api/src/storage/storage.constants.ts | 7 + apps/api/src/storage/storage.module.ts | 65 +++++ apps/api/src/storage/storage.service.spec.ts | 67 +++++ apps/api/src/storage/storage.service.ts | 150 +++++++++++ apps/api/src/users/users.service.spec.ts | 7 +- .../profile-picture-editor-dialog.tsx | 5 +- docker-compose.yml | 32 +++ package-lock.json | 57 ++++ packages/database/.env.example | 14 + packages/database/package.json | 2 + .../prisma/scripts/migrateLegacyMedia.ts | 250 ++++++++++++++++++ packages/database/prisma/seeders/index.ts | 8 +- .../database/prisma/seeders/projectCatalog.ts | 36 +-- .../prisma/seeders/seedObjectStorage.ts | 241 +++++++++++++++++ .../database/prisma/seeders/seedProjects.ts | 38 ++- packages/database/prisma/seeders/seedUsers.ts | 22 +- 27 files changed, 1209 insertions(+), 224 deletions(-) create mode 100644 apps/api/src/auth/utils/image-content-type.ts create mode 100644 apps/api/src/storage/storage.constants.ts create mode 100644 apps/api/src/storage/storage.module.ts create mode 100644 apps/api/src/storage/storage.service.spec.ts create mode 100644 apps/api/src/storage/storage.service.ts create mode 100644 packages/database/prisma/scripts/migrateLegacyMedia.ts create mode 100644 packages/database/prisma/seeders/seedObjectStorage.ts diff --git a/apps/api/.env.example b/apps/api/.env.example index fee215e..43c430b 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -5,6 +5,13 @@ MAILPIT_URL="http://localhost:8025" APP_URL="http://localhost:3000" API_URL="http://localhost:3001" OPENAI_API_KEY="" +OBJECT_STORAGE_ENDPOINT="http://localhost:9000" +OBJECT_STORAGE_PUBLIC_URL="http://localhost:9000/bootcamp-media" +OBJECT_STORAGE_REGION="us-east-1" +OBJECT_STORAGE_BUCKET="bootcamp-media" +OBJECT_STORAGE_ACCESS_KEY="bootcamp" +OBJECT_STORAGE_SECRET_KEY="bootcamp-secret" +OBJECT_STORAGE_FORCE_PATH_STYLE="true" EMAIL_PROVIDER="ses" AWS_REGION="us-west-2" SES_FROM_EMAIL="verified-sender@example.com" diff --git a/apps/api/package.json b/apps/api/package.json index 3ea2aef..41b2fe6 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -19,6 +19,7 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1091.0", "@aws-sdk/client-sesv2": "^3.1101.0", "@nestjs/bullmq": "^11.0.4", "@nestjs/common": "^11.0.1", diff --git a/apps/api/src/auth/auth.controller.spec.ts b/apps/api/src/auth/auth.controller.spec.ts index 453465b..d7e77d2 100644 --- a/apps/api/src/auth/auth.controller.spec.ts +++ b/apps/api/src/auth/auth.controller.spec.ts @@ -2,6 +2,7 @@ import { Test } from '@nestjs/testing'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { UpdateProfileRequest, UserResponse } from '@repo/contracts'; +import { ObjectStorageService } from '../storage/storage.service'; describe('AuthController', () => { let controller: AuthController; @@ -9,8 +10,10 @@ describe('AuthController', () => { const mockAuthService = { updateProfile: jest.fn(), }; + const objectStorage = { upload: jest.fn(), deleteMany: jest.fn() }; beforeEach(async () => { + jest.clearAllMocks(); const moduleRef = await Test.createTestingModule({ controllers: [AuthController], providers: [ @@ -18,6 +21,7 @@ describe('AuthController', () => { provide: AuthService, useValue: mockAuthService, }, + { provide: ObjectStorageService, useValue: objectStorage }, ], }).compile(); @@ -34,4 +38,46 @@ describe('AuthController', () => { await controller.updateProfile(user, body); expect(mockAuthService.updateProfile).toHaveBeenCalledWith(user.id, body); }); + + it('uploads a validated profile picture to object storage', async () => { + objectStorage.upload + .mockResolvedValueOnce({ + key: 'profile-pictures/cropped.png', + publicUrl: + 'http://localhost:9000/bootcamp-media/profile-pictures/cropped.png', + }) + .mockResolvedValueOnce({ + key: 'profile-pictures/original.png', + publicUrl: + 'http://localhost:9000/bootcamp-media/profile-pictures/original.png', + }); + const image = { + buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + } as Express.Multer.File; + + await expect( + controller.uploadProfilePicture({ file: [image], originalFile: [image] }), + ).resolves.toEqual({ + profilePictureUrl: + 'http://localhost:9000/bootcamp-media/profile-pictures/cropped.png', + profilePictureOriginalUrl: + 'http://localhost:9000/bootcamp-media/profile-pictures/original.png', + }); + expect(objectStorage.upload).toHaveBeenNthCalledWith( + 1, + expect.stringMatching(/^profile-pictures\/[0-9a-f-]+\.png$/), + image.buffer, + 'image/png', + ); + expect(objectStorage.upload).toHaveBeenCalledTimes(2); + }); + + it('rejects invalid image bytes before object storage is called', async () => { + const file = { buffer: Buffer.from('not-an-image') } as Express.Multer.File; + + await expect( + controller.uploadProfilePicture({ file: [file], originalFile: [file] }), + ).rejects.toThrow('The uploaded file is not a valid image'); + expect(objectStorage.upload).not.toHaveBeenCalled(); + }); }); diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 9cb4041..ba1e77e 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -1,6 +1,4 @@ -import { join } from 'path'; import { randomUUID } from 'crypto'; -import { readFileSync, unlinkSync, renameSync } from 'fs'; import { Controller, Post, @@ -25,7 +23,7 @@ import { ApiResponse, ApiTags, } from '@nestjs/swagger'; -import { diskStorage } from 'multer'; +import { memoryStorage } from 'multer'; import type { Response } from 'express'; import { AuthService } from './auth.service'; import { CurrentUser, Public } from './decorators'; @@ -61,6 +59,8 @@ import { type SuccessResponse, } from '@repo/contracts'; import { ZodValidationPipe } from '../common/pipes'; +import { ObjectStorageService } from '../storage/storage.service'; +import { imageContentType } from './utils/image-content-type'; import { emailRequestSchema as emailRequestOpenApiSchema, loginRequestSchema as loginRequestOpenApiSchema, @@ -73,7 +73,6 @@ import { import type { AccountType } from '@repo/db'; const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days -const PROFILE_PICTURES_DIR = join(process.cwd(), 'uploads', 'profile-pictures'); interface PopulatedUser { id: string; @@ -111,19 +110,13 @@ type ProfilePictureFiles = { file?: Express.Multer.File[]; originalFile?: Express.Multer.File[]; }; - -function safeUnlink(path: string) { - try { - unlinkSync(path); - } catch { - return; - } -} - @ApiTags('auth') @Controller('auth') export class AuthController { - constructor(private readonly authService: AuthService) {} + constructor( + private readonly authService: AuthService, + private readonly objectStorage: ObjectStorageService, + ) {} @Public() @Post('magic-link') @@ -336,10 +329,7 @@ export class AuthController { { name: 'originalFile', maxCount: 1 }, ], { - storage: diskStorage({ - destination: PROFILE_PICTURES_DIR, - filename: (_req, _file, callback) => callback(null, randomUUID()), - }), + storage: memoryStorage(), limits: { fileSize: PROFILE_PICTURE_MAX_SIZE_BYTES }, fileFilter: (_req, file, callback) => { if ( @@ -360,43 +350,42 @@ export class AuthController { }, ), ) - uploadProfilePicture( + async uploadProfilePicture( @UploadedFiles() files: ProfilePictureFiles | undefined, - ): ProfilePictureUploadResponse { + ): Promise { const croppedFile = files?.file?.[0]; const originalFile = files?.originalFile?.[0]; - const uploadedFiles = [croppedFile, originalFile].filter( - (file): file is Express.Multer.File => Boolean(file), - ); - if (!croppedFile || !originalFile) { - uploadedFiles.forEach((file) => safeUnlink(file.path)); throw new BadRequestException( 'Both the cropped photo and original photo are required', ); } - const croppedExtension = detectImageExtension( - readFileSync(croppedFile.path), - ); - const originalExtension = detectImageExtension( - readFileSync(originalFile.path), - ); + const croppedExtension = detectImageExtension(croppedFile.buffer); + const originalExtension = detectImageExtension(originalFile.buffer); if (!croppedExtension || !originalExtension) { - uploadedFiles.forEach((file) => safeUnlink(file.path)); throw new BadRequestException('The uploaded file is not a valid image'); } - const croppedFilename = `${croppedFile.filename}${croppedExtension}`; - const originalFilename = `${originalFile.filename}${originalExtension}`; - renameSync(croppedFile.path, join(PROFILE_PICTURES_DIR, croppedFilename)); - renameSync(originalFile.path, join(PROFILE_PICTURES_DIR, originalFilename)); - - const apiUrl = process.env.API_URL ?? 'http://localhost:3001'; - return { - profilePictureUrl: `${apiUrl}/uploads/profile-pictures/${croppedFilename}`, - profilePictureOriginalUrl: `${apiUrl}/uploads/profile-pictures/${originalFilename}`, - }; + const cropped = await this.objectStorage.upload( + `profile-pictures/${randomUUID()}${croppedExtension}`, + croppedFile.buffer, + imageContentType(croppedExtension), + ); + try { + const original = await this.objectStorage.upload( + `profile-pictures/${randomUUID()}${originalExtension}`, + originalFile.buffer, + imageContentType(originalExtension), + ); + return { + profilePictureUrl: cropped.publicUrl, + profilePictureOriginalUrl: original.publicUrl, + }; + } catch (error) { + await this.objectStorage.deleteMany([cropped.key]); + throw error; + } } @Patch('profile') diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 8697425..0aed95d 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -7,9 +7,10 @@ import { RolesGuard } from './guards/roles.guard'; import { SessionService } from './session.service'; import { MailModule } from '../mail/mail.module'; import { RedisClientService } from './redis-client.service'; +import { StorageModule } from '../storage/storage.module'; @Module({ - imports: [MailModule], + imports: [MailModule, StorageModule], providers: [ AuthService, SessionService, diff --git a/apps/api/src/auth/utils/image-content-type.ts b/apps/api/src/auth/utils/image-content-type.ts new file mode 100644 index 0000000..4b594ce --- /dev/null +++ b/apps/api/src/auth/utils/image-content-type.ts @@ -0,0 +1,14 @@ +const CONTENT_TYPES: Record = { + '.jpg': 'image/jpeg', + '.png': 'image/png', + '.webp': 'image/webp', + '.gif': 'image/gif', +}; + +export function imageContentType(extension: string): string { + const contentType = CONTENT_TYPES[extension]; + if (!contentType) { + throw new Error(`Unsupported image extension: ${extension}`); + } + return contentType; +} diff --git a/apps/api/src/common/swagger/schemas.ts b/apps/api/src/common/swagger/schemas.ts index 70b36e8..4c4e8e8 100644 --- a/apps/api/src/common/swagger/schemas.ts +++ b/apps/api/src/common/swagger/schemas.ts @@ -95,9 +95,9 @@ export const updateProfileRequestSchema: ApiBodySchema = withExample( bio: 'I build SaaS apps.', location: 'Beirut, Lebanon', profilePictureUrl: - 'http://localhost:3001/uploads/profile-pictures/example.png', + 'http://localhost:9000/bootcamp-media/profile-pictures/example.png', profilePictureOriginalUrl: - 'http://localhost:3001/uploads/profile-pictures/example-original.png', + 'http://localhost:9000/bootcamp-media/profile-pictures/example-original.png', profilePictureCropZoom: 1.35, profilePictureCropX: 12, profilePictureCropY: -8, diff --git a/apps/api/src/projects/projects.controller.spec.ts b/apps/api/src/projects/projects.controller.spec.ts index 35775a9..095d15f 100644 --- a/apps/api/src/projects/projects.controller.spec.ts +++ b/apps/api/src/projects/projects.controller.spec.ts @@ -3,22 +3,32 @@ import { AccountType, type User } from '@repo/db'; import { ROLES_KEY } from '../auth/decorators/roles.decorator'; import { ProjectsController } from './projects.controller'; import { ProjectsService } from './projects.service'; +import { ObjectStorageService } from '../storage/storage.service'; describe('ProjectsController', () => { let controller: ProjectsController; const projectsService = { importGithubProject: jest.fn(), + uploadLogo: jest.fn(), exploreProjects: jest.fn(), removeProjectMember: jest.fn(), }; + const objectStorage = { + upload: jest.fn(), + deleteMany: jest.fn(), + keyFromPublicUrl: jest.fn(), + }; beforeEach(async () => { jest.clearAllMocks(); const moduleRef = await Test.createTestingModule({ controllers: [ProjectsController], - providers: [{ provide: ProjectsService, useValue: projectsService }], + providers: [ + { provide: ProjectsService, useValue: projectsService }, + { provide: ObjectStorageService, useValue: objectStorage }, + ], }).compile(); controller = moduleRef.get(ProjectsController); @@ -59,6 +69,79 @@ describe('ProjectsController', () => { ); }); + it('stores a validated project logo and removes the replaced object', async () => { + const publicUrl = + 'http://localhost:9000/bootcamp-media/project-media/project/logo.png'; + objectStorage.upload.mockResolvedValue({ + key: 'project-media/project/logo.png', + publicUrl, + }); + objectStorage.keyFromPublicUrl.mockReturnValue( + 'project-media/project/old.png', + ); + projectsService.uploadLogo.mockResolvedValue({ + id: '00000000-0000-4000-8000-000000000003', + repositoryId: '00000000-0000-4000-8000-000000000002', + createdByUserId: '00000000-0000-4000-8000-000000000001', + title: 'Project', + slug: 'project', + logoUrl: publicUrl, + shortDescription: null, + fullDescription: null, + deploymentUrl: null, + status: 'DRAFT', + createdAt: new Date('2026-07-20T10:00:00.000Z'), + updatedAt: new Date('2026-07-20T10:00:00.000Z'), + publishedAt: null, + previousLogoUrl: + 'http://localhost:9000/bootcamp-media/project-media/project/old.png', + }); + const file = { + buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + } as Express.Multer.File; + + await expect( + controller.uploadProjectLogo( + { id: 'user-id' } as User, + 'project-id', + file, + ), + ).resolves.toMatchObject({ logoUrl: publicUrl }); + expect(projectsService.uploadLogo).toHaveBeenCalledWith( + expect.anything(), + 'project-id', + publicUrl, + ); + expect(objectStorage.deleteMany).toHaveBeenCalledWith([ + 'project-media/project/old.png', + ]); + }); + + it('removes a newly uploaded object when persistence fails', async () => { + objectStorage.upload.mockResolvedValue({ + key: 'project-media/project/new.png', + publicUrl: + 'http://localhost:9000/bootcamp-media/project-media/project/new.png', + }); + projectsService.uploadLogo.mockRejectedValue( + new Error('database unavailable'), + ); + const file = { + buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + } as Express.Multer.File; + + await expect( + controller.uploadProjectLogo( + { id: 'user-id' } as User, + 'project-id', + file, + ), + ).rejects.toThrow('database unavailable'); + expect(objectStorage.deleteMany).toHaveBeenCalledWith([ + 'project-media/project/new.png', + ]); + }); + it('delegates project member removal to ProjectsService', async () => { projectsService.removeProjectMember.mockResolvedValue(undefined); const user = { diff --git a/apps/api/src/projects/projects.controller.ts b/apps/api/src/projects/projects.controller.ts index de80bda..34da3e3 100644 --- a/apps/api/src/projects/projects.controller.ts +++ b/apps/api/src/projects/projects.controller.ts @@ -1,7 +1,4 @@ -import { join } from 'path'; import { randomUUID } from 'crypto'; -import { existsSync, mkdirSync } from 'fs'; -import { readFile, unlink, rename } from 'fs/promises'; import { Controller, Get, @@ -26,7 +23,7 @@ import { ApiResponse, ApiTags, } from '@nestjs/swagger'; -import { diskStorage } from 'multer'; +import { memoryStorage } from 'multer'; import { ProjectsService } from './projects.service'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { Roles } from '../auth/decorators/roles.decorator'; @@ -70,6 +67,8 @@ import { updateProjectRequestSchema as updateProjectOpenApiRequestSchema, } from '../common/swagger/schemas'; import { mapProjectMember } from './project-member.mapper'; +import { ObjectStorageService } from '../storage/storage.service'; +import { imageContentType } from '../auth/utils/image-content-type'; import { normalizeMediaUrl } from '../common/utils/normalize-media-url'; const PROJECT_MEDIA_MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5MB @@ -79,20 +78,18 @@ const PROJECT_MEDIA_ALLOWED_MIME_TYPES = [ 'image/webp', 'image/gif', ]; -const PROJECT_MEDIA_DIR = join(process.cwd(), 'uploads', 'project-media'); const ANONYMOUS_CONTRIBUTOR_NAME = 'Community member'; -if (!existsSync(PROJECT_MEDIA_DIR)) { - mkdirSync(PROJECT_MEDIA_DIR, { recursive: true }); -} - @ApiTags('projects') @ApiCookieAuth('session') @Controller('projects') export class ProjectsController { private readonly logger = new Logger(ProjectsController.name); - constructor(private readonly projectsService: ProjectsService) {} + constructor( + private readonly projectsService: ProjectsService, + private readonly objectStorage: ObjectStorageService, + ) {} @Post('import-github') @Roles(AccountType.DEVELOPER) @@ -396,11 +393,7 @@ export class ProjectsController { @ApiResponse({ status: 200, description: 'Logo successfully uploaded.' }) @UseInterceptors( FileInterceptor('file', { - storage: diskStorage({ - destination: PROJECT_MEDIA_DIR, - filename: (_req, _file, callback) => - callback(null, `logo-${randomUUID()}`), - }), + storage: memoryStorage(), limits: { fileSize: PROJECT_MEDIA_MAX_SIZE_BYTES }, fileFilter: (_req, file, callback) => { if (!PROJECT_MEDIA_ALLOWED_MIME_TYPES.includes(file.mimetype)) { @@ -425,60 +418,30 @@ export class ProjectsController { throw new BadRequestException('No file uploaded'); } - let fileBuffer: Buffer; - try { - fileBuffer = await readFile(file.path); - } catch (_readError) { - try { - await unlink(file.path); - } catch (_unlinkError) { - // Ignored - } - throw new BadRequestException('Could not read the uploaded file'); - } - - const extension = detectImageExtension(fileBuffer); + const extension = detectImageExtension(file.buffer); if (!extension) { - try { - await unlink(file.path); - } catch (_unlinkError) { - // Ignored - } throw new BadRequestException('The uploaded file is not a valid image'); } - const finalFilename = `${file.filename}${extension}`; - try { - await rename(file.path, join(PROJECT_MEDIA_DIR, finalFilename)); - } catch (_renameError) { - try { - await unlink(file.path); - } catch (_unlinkError) { - // Ignored - } - throw new BadRequestException('Failed to process the uploaded file'); - } - - const apiUrl = process.env.API_URL ?? 'http://localhost:3001'; - const publicUrl = `${apiUrl}/uploads/project-media/${finalFilename}`; + const stored = await this.objectStorage.upload( + `project-media/${projectId}/logos/${randomUUID()}${extension}`, + file.buffer, + imageContentType(extension), + ); let result: Awaited>; try { result = await this.projectsService.uploadLogo( user, projectId, - publicUrl, + stored.publicUrl, ); } catch (error) { - try { - await unlink(join(PROJECT_MEDIA_DIR, finalFilename)); - } catch (_unlinkError) { - // Ignored - } + await this.deleteObjectSafely(stored.key, 'rolled-back project logo'); throw error; } - const { previousLogoKey, ...project } = result; + const { previousLogoUrl, ...project } = result; const response = projectResponseSchema.parse({ ...project, createdAt: project.createdAt.toISOString(), @@ -488,12 +451,10 @@ export class ProjectsController { // Only remove the old logo after the persisted replacement is confirmed to // satisfy the public response contract. + const previousLogoKey = + this.objectStorage.keyFromPublicUrl(previousLogoUrl); if (previousLogoKey) { - try { - await unlink(join(PROJECT_MEDIA_DIR, previousLogoKey)); - } catch (_unlinkError) { - // Ignored - } + await this.deleteObjectSafely(previousLogoKey, 'replaced project logo'); } return response; @@ -512,26 +473,13 @@ export class ProjectsController { @CurrentUser() user: User, @Param('id') projectId: string, ): Promise { - const { mediaStorageKeys } = await this.projectsService.deleteProject( - user, - projectId, - ); + const { mediaStorageKeys, logoUrl } = + await this.projectsService.deleteProject(user, projectId); - await Promise.all( - mediaStorageKeys.map(async (storageKey) => { - try { - await unlink(join(PROJECT_MEDIA_DIR, storageKey)); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== 'ENOENT') { - const message = - error instanceof Error ? error.message : String(error); - this.logger.warn( - `Failed to delete media file ${storageKey} for removed project ${projectId}: ${message}`, - ); - } - } - }), + const logoKey = this.objectStorage.keyFromPublicUrl(logoUrl); + await this.deleteObjectsSafely( + logoKey ? [...mediaStorageKeys, logoKey] : mediaStorageKeys, + `removed project ${projectId}`, ); return successResponseSchema.parse({ success: true }); @@ -642,10 +590,7 @@ export class ProjectsController { @ApiResponse({ status: 201, description: 'Media successfully uploaded.' }) @UseInterceptors( FileInterceptor('file', { - storage: diskStorage({ - destination: PROJECT_MEDIA_DIR, - filename: (_req, _file, callback) => callback(null, randomUUID()), - }), + storage: memoryStorage(), limits: { fileSize: PROJECT_MEDIA_MAX_SIZE_BYTES }, fileFilter: (_req, file, callback) => { if (!PROJECT_MEDIA_ALLOWED_MIME_TYPES.includes(file.mimetype)) { @@ -672,51 +617,26 @@ export class ProjectsController { throw new BadRequestException('No file uploaded'); } - let fileBuffer: Buffer; - try { - fileBuffer = await readFile(file.path); - } catch (_readError) { - throw new BadRequestException('Could not read the uploaded file'); - } - - const extension = detectImageExtension(fileBuffer); + const extension = detectImageExtension(file.buffer); if (!extension) { - try { - await unlink(file.path); - } catch (_unlinkError) { - // Ignored - } throw new BadRequestException('The uploaded file is not a valid image'); } - const finalFilename = `${file.filename}${extension}`; - try { - await rename(file.path, join(PROJECT_MEDIA_DIR, finalFilename)); - } catch (_renameError) { - try { - await unlink(file.path); - } catch (_unlinkError) { - // Ignored - } - throw new BadRequestException('Failed to process the uploaded file'); - } - - const apiUrl = process.env.API_URL ?? 'http://localhost:3001'; - const publicUrl = `${apiUrl}/uploads/project-media/${finalFilename}`; + const stored = await this.objectStorage.upload( + `project-media/${projectId}/${randomUUID()}${extension}`, + file.buffer, + imageContentType(extension), + ); let media: Awaited>; try { media = await this.projectsService.addMedia(user, projectId, { ...body, - storageKey: finalFilename, - publicUrl, + storageKey: stored.key, + publicUrl: stored.publicUrl, }); } catch (error) { - try { - await unlink(join(PROJECT_MEDIA_DIR, finalFilename)); - } catch (_unlinkError) { - // Ignored - } + await this.deleteObjectSafely(stored.key, 'rolled-back project media'); throw error; } @@ -803,12 +723,23 @@ export class ProjectsController { mediaId, ); - try { - await unlink(join(PROJECT_MEDIA_DIR, result.storageKey)); - } catch (_e) { - // Ignore error if file is already missing from disk - } + await this.deleteObjectSafely(result.storageKey, 'removed project media'); return successResponseSchema.parse({ success: true }); } + + private async deleteObjectSafely(key: string, context: string) { + await this.deleteObjectsSafely([key], context); + } + + private async deleteObjectsSafely(keys: string[], context: string) { + try { + await this.objectStorage.deleteMany(keys); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn( + `Failed to delete object storage data for ${context}: ${message}`, + ); + } + } } diff --git a/apps/api/src/projects/projects.module.ts b/apps/api/src/projects/projects.module.ts index a3893d9..47f60f8 100644 --- a/apps/api/src/projects/projects.module.ts +++ b/apps/api/src/projects/projects.module.ts @@ -2,11 +2,12 @@ import { Module } from '@nestjs/common'; import { ProjectsController } from './projects.controller'; import { ProjectsService } from './projects.service'; import { DatabaseModule } from '../database/database.module'; -import { GithubModule } from '../github/github.module'; // Imported GithubModule +import { GithubModule } from '../github/github.module'; import { ProjectAccessService } from './project-access.service'; +import { StorageModule } from '../storage/storage.module'; @Module({ - imports: [DatabaseModule, GithubModule], // Added GithubModule + imports: [DatabaseModule, GithubModule, StorageModule], controllers: [ProjectsController], providers: [ProjectsService, ProjectAccessService], exports: [ProjectsService, ProjectAccessService], diff --git a/apps/api/src/projects/projects.service.ts b/apps/api/src/projects/projects.service.ts index 7ea0c3f..f3aca0a 100644 --- a/apps/api/src/projects/projects.service.ts +++ b/apps/api/src/projects/projects.service.ts @@ -730,22 +730,13 @@ export class ProjectsService { } const previousLogoUrl = project.logoUrl; - let previousLogoKey: string | null = null; - - if (previousLogoUrl) { - const parts = previousLogoUrl.split('/'); - const oldFilename = parts[parts.length - 1]; - if (oldFilename) { - previousLogoKey = oldFilename; - } - } const updatedProject = await this.prisma.project.update({ where: { id: projectId }, data: { logoUrl }, }); - return { ...updatedProject, previousLogoKey }; + return { ...updatedProject, previousLogoUrl }; } async getProjectBySlug(slug: string) { @@ -1119,15 +1110,7 @@ export class ProjectsService { const mediaStorageKeys = project.media.map((m) => m.storageKey); - if (project.logoUrl) { - const parts = project.logoUrl.split('/'); - const logoKey = parts[parts.length - 1]; - if (logoKey) { - mediaStorageKeys.push(logoKey); - } - } - - return { mediaStorageKeys }; + return { mediaStorageKeys, logoUrl: project.logoUrl }; } } diff --git a/apps/api/src/storage/storage.constants.ts b/apps/api/src/storage/storage.constants.ts new file mode 100644 index 0000000..527c8d5 --- /dev/null +++ b/apps/api/src/storage/storage.constants.ts @@ -0,0 +1,7 @@ +export const OBJECT_STORAGE_CLIENT = Symbol('OBJECT_STORAGE_CLIENT'); + +export const DEFAULT_OBJECT_STORAGE_BUCKET = 'bootcamp-media'; +export const DEFAULT_OBJECT_STORAGE_ENDPOINT = 'http://localhost:9000'; +export const DEFAULT_OBJECT_STORAGE_REGION = 'us-east-1'; +export const DEFAULT_OBJECT_STORAGE_ACCESS_KEY = 'bootcamp'; +export const DEFAULT_OBJECT_STORAGE_SECRET_KEY = 'bootcamp-secret'; diff --git a/apps/api/src/storage/storage.module.ts b/apps/api/src/storage/storage.module.ts new file mode 100644 index 0000000..3877781 --- /dev/null +++ b/apps/api/src/storage/storage.module.ts @@ -0,0 +1,65 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { S3Client, type S3ClientConfig } from '@aws-sdk/client-s3'; +import { + DEFAULT_OBJECT_STORAGE_ACCESS_KEY, + DEFAULT_OBJECT_STORAGE_ENDPOINT, + DEFAULT_OBJECT_STORAGE_REGION, + DEFAULT_OBJECT_STORAGE_SECRET_KEY, + OBJECT_STORAGE_CLIENT, +} from './storage.constants'; +import { ObjectStorageService } from './storage.service'; + +function createClient(config: ConfigService): S3Client { + const isProduction = config.get('NODE_ENV') === 'production'; + const endpoint = config.get('OBJECT_STORAGE_ENDPOINT'); + const accessKeyId = config.get('OBJECT_STORAGE_ACCESS_KEY'); + const secretAccessKey = config.get('OBJECT_STORAGE_SECRET_KEY'); + + if (Boolean(accessKeyId) !== Boolean(secretAccessKey)) { + throw new Error( + 'OBJECT_STORAGE_ACCESS_KEY and OBJECT_STORAGE_SECRET_KEY must be configured together', + ); + } + + const resolvedEndpoint = + endpoint ?? (isProduction ? undefined : DEFAULT_OBJECT_STORAGE_ENDPOINT); + const forcePathStyleValue = config.get( + 'OBJECT_STORAGE_FORCE_PATH_STYLE', + ); + const clientConfig: S3ClientConfig = { + region: + config.get('OBJECT_STORAGE_REGION') ?? + DEFAULT_OBJECT_STORAGE_REGION, + forcePathStyle: forcePathStyleValue + ? forcePathStyleValue === 'true' + : !isProduction, + ...(resolvedEndpoint ? { endpoint: resolvedEndpoint } : {}), + ...(accessKeyId && secretAccessKey + ? { credentials: { accessKeyId, secretAccessKey } } + : isProduction + ? {} + : { + credentials: { + accessKeyId: DEFAULT_OBJECT_STORAGE_ACCESS_KEY, + secretAccessKey: DEFAULT_OBJECT_STORAGE_SECRET_KEY, + }, + }), + }; + + return new S3Client(clientConfig); +} + +@Module({ + imports: [ConfigModule], + providers: [ + { + provide: OBJECT_STORAGE_CLIENT, + inject: [ConfigService], + useFactory: createClient, + }, + ObjectStorageService, + ], + exports: [ObjectStorageService], +}) +export class StorageModule {} diff --git a/apps/api/src/storage/storage.service.spec.ts b/apps/api/src/storage/storage.service.spec.ts new file mode 100644 index 0000000..fd16bbe --- /dev/null +++ b/apps/api/src/storage/storage.service.spec.ts @@ -0,0 +1,67 @@ +import { ServiceUnavailableException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import type { S3Client } from '@aws-sdk/client-s3'; +import { ObjectStorageService } from './storage.service'; + +describe('ObjectStorageService', () => { + const send = jest.fn, [unknown]>(); + const service = new ObjectStorageService( + { send } as unknown as S3Client, + new ConfigService({ + OBJECT_STORAGE_BUCKET: 'test-bucket', + OBJECT_STORAGE_PUBLIC_URL: 'http://localhost:9000/test-bucket', + }), + ); + + beforeEach(() => jest.clearAllMocks()); + + it('uploads an object and returns an encoded public URL', async () => { + send.mockResolvedValue({}); + + await expect( + service.upload( + 'project-media/project id/image.png', + Buffer.from('image'), + 'image/png', + ), + ).resolves.toEqual({ + key: 'project-media/project id/image.png', + publicUrl: + 'http://localhost:9000/test-bucket/project-media/project%20id/image.png', + }); + }); + + it('extracts only keys belonging to the configured bucket', () => { + expect( + service.keyFromPublicUrl( + 'http://localhost:9000/test-bucket/project-media/a%20b.png', + ), + ).toBe('project-media/a b.png'); + expect( + service.keyFromPublicUrl('https://example.com/test-bucket/image.png'), + ).toBeNull(); + }); + + it('deduplicates keys before deletion', async () => { + send.mockResolvedValue({}); + + await service.deleteMany(['one.png', 'one.png', 'two.png']); + + expect(send).toHaveBeenCalledTimes(1); + const command = send.mock.calls[0]?.[0] as { + input: { Delete: { Objects: Array<{ Key: string }> } }; + }; + expect(command.input.Delete.Objects).toEqual([ + { Key: 'one.png' }, + { Key: 'two.png' }, + ]); + }); + + it('maps provider failures to a stable service error', async () => { + send.mockRejectedValue(new Error('connection refused')); + + await expect( + service.upload('image.png', Buffer.from('image'), 'image/png'), + ).rejects.toBeInstanceOf(ServiceUnavailableException); + }); +}); diff --git a/apps/api/src/storage/storage.service.ts b/apps/api/src/storage/storage.service.ts new file mode 100644 index 0000000..d7c8d72 --- /dev/null +++ b/apps/api/src/storage/storage.service.ts @@ -0,0 +1,150 @@ +import { + DeleteObjectsCommand, + PutObjectCommand, + type ObjectIdentifier, + type S3Client, +} from '@aws-sdk/client-s3'; +import { + Inject, + Injectable, + Logger, + ServiceUnavailableException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + DEFAULT_OBJECT_STORAGE_BUCKET, + DEFAULT_OBJECT_STORAGE_ENDPOINT, + OBJECT_STORAGE_CLIENT, +} from './storage.constants'; + +export interface StoredObject { + key: string; + publicUrl: string; +} + +@Injectable() +export class ObjectStorageService { + private readonly logger = new Logger(ObjectStorageService.name); + private readonly bucket: string; + private readonly publicBaseUrl: string; + + constructor( + @Inject(OBJECT_STORAGE_CLIENT) private readonly client: S3Client, + config: ConfigService, + ) { + const isProduction = config.get('NODE_ENV') === 'production'; + const configuredPublicUrl = config.get('OBJECT_STORAGE_PUBLIC_URL'); + const endpoint = config.get('OBJECT_STORAGE_ENDPOINT'); + if (isProduction && !configuredPublicUrl && !endpoint) { + throw new Error( + 'OBJECT_STORAGE_PUBLIC_URL or OBJECT_STORAGE_ENDPOINT is required in production to generate public object URLs', + ); + } + + this.bucket = + config.get('OBJECT_STORAGE_BUCKET') ?? + DEFAULT_OBJECT_STORAGE_BUCKET; + this.publicBaseUrl = configuredPublicUrl + ? configuredPublicUrl.replace(/\/+$/, '') + : `${(endpoint ?? DEFAULT_OBJECT_STORAGE_ENDPOINT).replace(/\/+$/, '')}/${encodeURIComponent(this.bucket)}`; + } + + async upload( + key: string, + body: Buffer, + contentType: string, + ): Promise { + try { + await this.client.send( + new PutObjectCommand({ + Bucket: this.bucket, + Key: key, + Body: body, + ContentLength: body.length, + ContentType: contentType, + CacheControl: 'public, max-age=31536000, immutable', + }), + ); + } catch (error) { + this.logger.error( + `Failed to upload object ${key}`, + error instanceof Error ? error.stack : String(error), + ); + throw new ServiceUnavailableException( + 'Object storage is temporarily unavailable', + ); + } + + return { key, publicUrl: this.publicUrl(key) }; + } + + async delete(key: string): Promise { + await this.deleteMany([key]); + } + + async deleteMany(keys: string[]): Promise { + const uniqueKeys = [...new Set(keys.filter(Boolean))]; + if (uniqueKeys.length === 0) return; + + try { + for (let offset = 0; offset < uniqueKeys.length; offset += 1000) { + const objects: ObjectIdentifier[] = uniqueKeys + .slice(offset, offset + 1000) + .map((Key) => ({ Key })); + const response = await this.client.send( + new DeleteObjectsCommand({ + Bucket: this.bucket, + Delete: { Objects: objects, Quiet: true }, + }), + ); + + if (response.Errors?.length) { + throw new Error( + response.Errors.map( + (item) => `${item.Key ?? 'unknown'}: ${item.Message ?? 'failed'}`, + ).join(', '), + ); + } + } + } catch (error) { + this.logger.error( + `Failed to delete ${uniqueKeys.length} object(s)`, + error instanceof Error ? error.stack : String(error), + ); + throw new ServiceUnavailableException( + 'Object storage is temporarily unavailable', + ); + } + } + + keyFromPublicUrl(value: string | null | undefined): string | null { + if (!value) return null; + + try { + const candidate = new URL(value); + const base = new URL(this.publicBaseUrl); + if (candidate.origin !== base.origin) return null; + + const basePath = base.pathname.replace(/\/+$/, ''); + const objectPrefix = `${basePath}/`; + if (!candidate.pathname.startsWith(objectPrefix)) return null; + + const encodedKey = candidate.pathname.slice(objectPrefix.length); + if (!encodedKey) return null; + return encodedKey + .split('/') + .map((segment) => decodeURIComponent(segment)) + .join('/'); + } catch { + return null; + } + } + + private publicUrl(key: string): string { + const encodedKey = key + .split('/') + .map((segment) => encodeURIComponent(segment)) + .join('/'); + return `${this.publicBaseUrl}/${encodedKey}`; + } +} diff --git a/apps/api/src/users/users.service.spec.ts b/apps/api/src/users/users.service.spec.ts index d7a0a47..0089023 100644 --- a/apps/api/src/users/users.service.spec.ts +++ b/apps/api/src/users/users.service.spec.ts @@ -112,7 +112,12 @@ function project({ publishedAt: updatedAt, updatedAt, members, - media: [{ publicUrl: 'http://localhost:3001/uploads/cover.png' }], + media: [ + { + publicUrl: + 'http://localhost:9000/bootcamp-media/project-media/project/cover.png', + }, + ], technologies: [ { technology: { diff --git a/apps/web/components/profile-picture-editor-dialog.tsx b/apps/web/components/profile-picture-editor-dialog.tsx index ae52338..830d377 100644 --- a/apps/web/components/profile-picture-editor-dialog.tsx +++ b/apps/web/components/profile-picture-editor-dialog.tsx @@ -194,7 +194,10 @@ export function ProfilePictureEditorDialog({ setIsLoadingCurrentImage(true); try { const response = await fetch(currentOriginalImageUrl, { - credentials: 'include', + // Media objects are public through the CDN. Omitting credentials keeps + // this compatible with both legacy /uploads URLs and cross-origin S3/ + // CloudFront URLs without requiring credentialed CORS. + credentials: 'omit', }); if (!response.ok) throw new Error('Unable to load current image'); diff --git a/docker-compose.yml b/docker-compose.yml index 823b0f4..4ccc4e2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,38 @@ services: MP_SMTP_AUTH_ACCEPT_ANY: 1 MP_SMTP_AUTH_ALLOW_INSECURE: 1 + minio: + image: minio/minio:RELEASE.2025-09-07T16-13-09Z + container_name: minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: bootcamp + MINIO_ROOT_PASSWORD: bootcamp-secret + ports: + - '127.0.0.1:9000:9000' + - '127.0.0.1:9001:9001' + volumes: + - minio_data:/data + healthcheck: + test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live'] + interval: 5s + timeout: 3s + retries: 20 + + minio-init: + image: minio/mc:RELEASE.2025-08-13T08-35-41Z + depends_on: + minio: + condition: service_healthy + entrypoint: /bin/sh + command: + - -c + - >- + mc alias set local http://minio:9000 bootcamp bootcamp-secret && + mc mb --ignore-existing local/bootcamp-media && + mc anonymous set download local/bootcamp-media + volumes: postgres_data: redis_data: + minio_data: diff --git a/package-lock.json b/package-lock.json index 5d703d6..21c8269 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "apps/api": { "version": "0.0.1", "dependencies": { + "@aws-sdk/client-s3": "^3.1091.0", "@aws-sdk/client-sesv2": "^3.1101.0", "@nestjs/bullmq": "^11.0.4", "@nestjs/common": "^11.0.1", @@ -501,6 +502,44 @@ } } }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1000.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.26.tgz", + "integrity": "sha512-CGznePoL+1oWCSzqmkvlYMpWQEZohPR3LntjKXXfVH+oh6Kd8d+yHLkjpiAGwPIHAxFe4OAq3aKfRnv/wqWIyA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1105.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1105.0.tgz", + "integrity": "sha512-eiR289CNgH2atIh2zOSDSpXOmDVGCxFEk0S8jMHo599oTCjcnjmlNOVsFCWXx5jMPP83+c47Wl/1R7xgUVBzZw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1000.26", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/credential-provider-node": "^3.972.78", + "@aws-sdk/middleware-sdk-s3": "^3.972.72", + "@aws-sdk/signature-v4-multi-region": "^3.996.43", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-sesv2": { "version": "3.1104.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sesv2/-/client-sesv2-3.1104.0.tgz", @@ -688,6 +727,23 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.72.tgz", + "integrity": "sha512-lSAoVPvQxX1d8TOM6waKDBQrvvZcm4w6pCldFAsRUffEaXq6lYY0pPyew3KlLu6Xqb74DXI42hGvSsbGBLljlw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/signature-v4-multi-region": "^3.996.43", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/nested-clients": { "version": "3.997.41", "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.41.tgz", @@ -19923,6 +19979,7 @@ "name": "@repo/db", "version": "0.0.0", "dependencies": { + "@aws-sdk/client-s3": "^3.1091.0", "@prisma/adapter-pg": "^7.2.0", "@prisma/client": "^7.2.0", "dotenv": "^16.0.3", diff --git a/packages/database/.env.example b/packages/database/.env.example index c01b6e7..730d572 100644 --- a/packages/database/.env.example +++ b/packages/database/.env.example @@ -1 +1,15 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5433/bootcamp_starter" + +# Seed image storage (defaults match the local MinIO compose service) +OBJECT_STORAGE_ENDPOINT="http://localhost:9000" +OBJECT_STORAGE_PUBLIC_URL="http://localhost:9000/bootcamp-media" +OBJECT_STORAGE_REGION="us-east-1" +OBJECT_STORAGE_BUCKET="bootcamp-media" +OBJECT_STORAGE_ACCESS_KEY="bootcamp" +OBJECT_STORAGE_SECRET_KEY="bootcamp-secret" +OBJECT_STORAGE_FORCE_PATH_STYLE="true" + +# Optional during the one-time legacy /uploads migration. Point this at the +# currently running API before replacing its container/task. +LEGACY_MEDIA_BASE_URL="http://localhost:3001" +LEGACY_MEDIA_DOWNLOAD_ATTEMPTS="30" diff --git a/packages/database/package.json b/packages/database/package.json index 0910316..7482f58 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -10,6 +10,7 @@ "db:deploy": "prisma migrate deploy", "db:reset": "prisma migrate reset", "db:seed": "prisma db seed", + "db:migrate-media": "tsx prisma/scripts/migrateLegacyMedia.ts", "db:build": "tsc" }, "devDependencies": { @@ -22,6 +23,7 @@ "prettier": "^3.4.2" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1091.0", "@prisma/adapter-pg": "^7.2.0", "@prisma/client": "^7.2.0", "dotenv": "^16.0.3", diff --git a/packages/database/prisma/scripts/migrateLegacyMedia.ts b/packages/database/prisma/scripts/migrateLegacyMedia.ts new file mode 100644 index 0000000..4b073a9 --- /dev/null +++ b/packages/database/prisma/scripts/migrateLegacyMedia.ts @@ -0,0 +1,250 @@ +import 'dotenv/config'; +import { createHash } from 'crypto'; +import { extname } from 'path'; +import { prisma } from '../../src/client'; +import { SeedObjectStorage } from '../seeders/seedObjectStorage'; + +type MigrationCandidate = { + label: string; + sourceUrl: string; + targetKey: string; + persist: (publicUrl: string) => Promise; +}; + +const LEGACY_UPLOAD_PREFIX = '/uploads/'; + +async function main() { + const apply = process.argv.includes('--apply'); + const sourceBaseUrl = optionalValue(process.env.LEGACY_MEDIA_BASE_URL); + const downloadAttempts = positiveInteger( + process.env.LEGACY_MEDIA_DOWNLOAD_ATTEMPTS, + 30, + ); + const candidates = await collectCandidates(sourceBaseUrl); + + console.log( + `${apply ? 'Applying' : 'Dry run:'} ${candidates.length} legacy media migration(s).`, + ); + + if (!apply) { + for (const candidate of candidates) { + console.log( + ` ${candidate.label}: ${candidate.sourceUrl} -> ${candidate.targetKey}`, + ); + } + console.log('No files or database rows were changed. Re-run with --apply.'); + return; + } + + if (candidates.length === 0) return; + + const objectStorage = new SeedObjectStorage(); + await objectStorage.assertAvailable(); + + const failures: Array<{ label: string; error: unknown }> = []; + let migrated = 0; + + // Keep this deliberately sequential. It avoids saturating the old API task + // and makes a production run easy to follow and safely rerun. + for (const candidate of candidates) { + try { + const publicUrl = await objectStorage.mirrorImage( + candidate.sourceUrl, + candidate.targetKey, + { + attempts: downloadAttempts, + sourceFingerprint: createHash('sha256') + .update(candidate.sourceUrl) + .digest('hex'), + }, + ); + const persisted = await candidate.persist(publicUrl); + if (!persisted) { + console.log( + ` Skipped ${candidate.label}; its database URL changed during the migration.`, + ); + continue; + } + migrated += 1; + console.log(` Migrated ${candidate.label}.`); + } catch (error) { + failures.push({ label: candidate.label, error }); + console.error(` Failed ${candidate.label}: ${errorMessage(error)}`); + } + } + + console.log( + `Migration finished: ${migrated} succeeded, ${failures.length} failed.`, + ); + + if (failures.length > 0) { + throw new Error( + 'Some media could not be migrated. Their database URLs were left unchanged; fix the source and rerun the command.', + ); + } +} + +async function collectCandidates( + sourceBaseUrl: string | undefined, +): Promise { + const [profiles, projects, media] = await Promise.all([ + prisma.developerProfile.findMany({ + select: { + id: true, + profilePictureUrl: true, + profilePictureOriginalUrl: true, + }, + }), + prisma.project.findMany({ + select: { id: true, logoUrl: true }, + }), + prisma.projectMedia.findMany({ + select: { id: true, publicUrl: true }, + }), + ]); + + const candidates: MigrationCandidate[] = []; + + for (const profile of profiles) { + const cropped = legacySource(profile.profilePictureUrl, sourceBaseUrl); + if (cropped) { + const previousUrl = profile.profilePictureUrl!; + candidates.push({ + label: `developer profile ${profile.id} cropped picture`, + sourceUrl: cropped.url, + targetKey: `migrated/profile-pictures/${profile.id}/cropped${cropped.extension}`, + persist: async (profilePictureUrl) => { + const result = await prisma.developerProfile.updateMany({ + where: { id: profile.id, profilePictureUrl: previousUrl }, + data: { profilePictureUrl }, + }); + return result.count === 1; + }, + }); + } + + const original = legacySource( + profile.profilePictureOriginalUrl, + sourceBaseUrl, + ); + if (original) { + const previousUrl = profile.profilePictureOriginalUrl!; + candidates.push({ + label: `developer profile ${profile.id} original picture`, + sourceUrl: original.url, + targetKey: `migrated/profile-pictures/${profile.id}/original${original.extension}`, + persist: async (profilePictureOriginalUrl) => { + const result = await prisma.developerProfile.updateMany({ + where: { + id: profile.id, + profilePictureOriginalUrl: previousUrl, + }, + data: { profilePictureOriginalUrl }, + }); + return result.count === 1; + }, + }); + } + } + + for (const project of projects) { + const logo = legacySource(project.logoUrl, sourceBaseUrl); + if (!logo) continue; + const previousUrl = project.logoUrl!; + + candidates.push({ + label: `project ${project.id} logo`, + sourceUrl: logo.url, + targetKey: `migrated/project-logos/${project.id}/logo${logo.extension}`, + persist: async (logoUrl) => { + const result = await prisma.project.updateMany({ + where: { id: project.id, logoUrl: previousUrl }, + data: { logoUrl }, + }); + return result.count === 1; + }, + }); + } + + for (const item of media) { + const source = legacySource(item.publicUrl, sourceBaseUrl); + if (!source) continue; + const previousUrl = item.publicUrl; + + const storageKey = `migrated/project-media/${item.id}/media${source.extension}`; + candidates.push({ + label: `project media ${item.id}`, + sourceUrl: source.url, + targetKey: storageKey, + persist: async (publicUrl) => { + const result = await prisma.projectMedia.updateMany({ + where: { id: item.id, publicUrl: previousUrl }, + data: { storageKey, publicUrl }, + }); + return result.count === 1; + }, + }); + } + + return candidates; +} + +function legacySource( + value: string | null, + sourceBaseUrl: string | undefined, +): { url: string; extension: string } | null { + const normalized = value?.trim(); + if (!normalized) return null; + + let parsed: URL; + try { + parsed = new URL(normalized, sourceBaseUrl); + } catch { + throw new Error( + `Cannot resolve legacy media URL "${normalized}". Set LEGACY_MEDIA_BASE_URL to the currently running API origin.`, + ); + } + + if (!parsed.pathname.startsWith(LEGACY_UPLOAD_PREFIX)) return null; + + if (sourceBaseUrl) { + const sourceBase = new URL(sourceBaseUrl); + parsed = new URL(`${parsed.pathname}${parsed.search}`, sourceBase); + } + + return { + url: parsed.toString(), + extension: safeImageExtension(parsed.pathname), + }; +} + +function safeImageExtension(pathname: string): string { + const extension = extname(pathname).toLowerCase(); + return ['.jpg', '.jpeg', '.png', '.webp', '.gif'].includes(extension) + ? extension + : ''; +} + +function optionalValue(value: string | undefined): string | undefined { + const normalized = value?.trim(); + return normalized || undefined; +} + +function positiveInteger(value: string | undefined, fallback: number): number { + if (!value) return fallback; + const parsed = Number.parseInt(value, 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +main() + .catch((error: unknown) => { + console.error(errorMessage(error)); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/packages/database/prisma/seeders/index.ts b/packages/database/prisma/seeders/index.ts index edb9807..dd2585e 100644 --- a/packages/database/prisma/seeders/index.ts +++ b/packages/database/prisma/seeders/index.ts @@ -1,13 +1,17 @@ import { prisma } from '../../src/client'; import { seedUsers } from './seedUsers'; import { seedProjects } from './seedProjects'; +import { SeedObjectStorage } from './seedObjectStorage'; async function main() { + const objectStorage = new SeedObjectStorage(); + await objectStorage.assertAvailable(); + // Seed users and profiles first - await seedUsers(prisma); + await seedUsers(prisma, objectStorage); // Seed projects, repositories, and technologies - await seedProjects(prisma); + await seedProjects(prisma, objectStorage); } main() diff --git a/packages/database/prisma/seeders/projectCatalog.ts b/packages/database/prisma/seeders/projectCatalog.ts index 9a92e40..a0c6f93 100644 --- a/packages/database/prisma/seeders/projectCatalog.ts +++ b/packages/database/prisma/seeders/projectCatalog.ts @@ -45,14 +45,14 @@ export interface ProjectCatalogItem { ownerEmail: string; title: string; slug: string; - logoUrl: string; + logoSourceUrl: string; shortDescription: string; fullDescription: string; deploymentUrl: string; publishedDaysAgo: number; techSlugs: string[]; media: Array<{ - publicUrl: string; + sourceUrl: string; caption: string; }>; collaborators?: Array<{ @@ -81,7 +81,7 @@ export const projectCatalog: ProjectCatalogItem[] = [ ownerEmail: 'dev.alex@example.com', title: 'Excalidraw Collaborative Whiteboard', slug: 'excalidraw-collaborative-whiteboard', - logoUrl: 'https://avatars.githubusercontent.com/u/59452120?v=4', + logoSourceUrl: 'https://avatars.githubusercontent.com/u/59452120?v=4', shortDescription: 'A local-first virtual whiteboard for sketching diagrams, wireframes, and ideas with real-time collaboration.', fullDescription: @@ -98,12 +98,12 @@ export const projectCatalog: ProjectCatalogItem[] = [ ], media: [ { - publicUrl: 'https://excalidraw.com/og-image-3.png', + sourceUrl: 'https://excalidraw.com/og-image-3.png', caption: 'Excalidraw’s hand-drawn visual language and collaborative canvas.', }, { - publicUrl: liveScreenshot('https://excalidraw.com'), + sourceUrl: liveScreenshot('https://excalidraw.com'), caption: 'The live editor with drawing tools, shapes, and an infinite canvas.', }, @@ -132,7 +132,7 @@ export const projectCatalog: ProjectCatalogItem[] = [ ownerEmail: 'dev.sarah@example.com', title: 'Cal.diy Scheduling Platform', slug: 'cal-diy-scheduling-platform', - logoUrl: 'https://avatars.githubusercontent.com/u/79145102?v=4', + logoSourceUrl: 'https://avatars.githubusercontent.com/u/79145102?v=4', shortDescription: 'Community-driven scheduling infrastructure for booking links, availability, calendars, and self-hosted workflows.', fullDescription: @@ -153,7 +153,7 @@ export const projectCatalog: ProjectCatalogItem[] = [ ], media: [ { - publicUrl: + sourceUrl: 'https://framerusercontent.com/images/pPSh5HDe1qaySb4R7xBgMHudhU.png', caption: 'The scheduling platform’s booking, availability, and calendar experience.', @@ -183,7 +183,7 @@ export const projectCatalog: ProjectCatalogItem[] = [ ownerEmail: 'dev.sarah@example.com', title: 'shadcn/ui Component Platform', slug: 'shadcn-ui-component-platform', - logoUrl: 'https://avatars.githubusercontent.com/u/139895814?v=4', + logoSourceUrl: 'https://avatars.githubusercontent.com/u/139895814?v=4', shortDescription: 'Accessible, beautifully designed components and a code distribution platform for building your own design system.', fullDescription: @@ -203,13 +203,13 @@ export const projectCatalog: ProjectCatalogItem[] = [ ], media: [ { - publicUrl: + sourceUrl: 'https://ui.shadcn.com/og?title=The%20Foundation%20for%20your%20Design%20System&description=Open%20Source.%20Open%20Code.', caption: 'The shadcn/ui design-system foundation and open-code approach.', }, { - publicUrl: liveScreenshot('https://ui.shadcn.com'), + sourceUrl: liveScreenshot('https://ui.shadcn.com'), caption: 'The live component catalog, documentation, and registry experience.', }, @@ -229,7 +229,7 @@ export const projectCatalog: ProjectCatalogItem[] = [ ownerEmail: 'dev.alex@example.com', title: 'Twenty Open-Source CRM', slug: 'twenty-open-source-crm', - logoUrl: 'https://avatars.githubusercontent.com/u/119600397?v=4', + logoSourceUrl: 'https://avatars.githubusercontent.com/u/119600397?v=4', shortDescription: 'A modern, extensible CRM for managing companies, people, opportunities, workflows, and customer data.', fullDescription: @@ -248,12 +248,12 @@ export const projectCatalog: ProjectCatalogItem[] = [ ], media: [ { - publicUrl: 'https://twenty.com/images/og/default.png', + sourceUrl: 'https://twenty.com/images/og/default.png', caption: 'Twenty’s modern workspace for companies, people, and opportunities.', }, { - publicUrl: liveScreenshot('https://twenty.com'), + sourceUrl: liveScreenshot('https://twenty.com'), caption: 'The live Twenty product site and its open-source CRM positioning.', }, @@ -282,7 +282,7 @@ export const projectCatalog: ProjectCatalogItem[] = [ ownerEmail: 'dev.sarah@example.com', title: 'Formbricks Experience Management', slug: 'formbricks-experience-management', - logoUrl: 'https://avatars.githubusercontent.com/u/105877416?v=4', + logoSourceUrl: 'https://avatars.githubusercontent.com/u/105877416?v=4', shortDescription: 'A privacy-first platform for product surveys, website feedback, link surveys, and experience analysis.', fullDescription: @@ -302,7 +302,7 @@ export const projectCatalog: ProjectCatalogItem[] = [ ], media: [ { - publicUrl: liveScreenshot('https://formbricks.com'), + sourceUrl: liveScreenshot('https://formbricks.com'), caption: 'Formbricks’ privacy-first survey and experience-management platform.', }, @@ -331,7 +331,7 @@ export const projectCatalog: ProjectCatalogItem[] = [ ownerEmail: 'dev.alex@example.com', title: 'NocoDB Low-Code Database', slug: 'nocodb-low-code-database', - logoUrl: 'https://avatars.githubusercontent.com/u/50206778?v=4', + logoSourceUrl: 'https://avatars.githubusercontent.com/u/50206778?v=4', shortDescription: 'A self-hostable Airtable alternative that turns relational data into collaborative spreadsheet-style workflows.', fullDescription: @@ -350,13 +350,13 @@ export const projectCatalog: ProjectCatalogItem[] = [ ], media: [ { - publicUrl: + sourceUrl: 'https://cdn.prod.website-files.com/650a7aeba6c28976499496bb/686278d1c40e23ec5f1ac414_66245a0c0a05baffdf947012613f7c7b_Website%20Thumbnail.png', caption: 'NocoDB’s collaborative, spreadsheet-style interface for relational data.', }, { - publicUrl: liveScreenshot('https://nocodb.com'), + sourceUrl: liveScreenshot('https://nocodb.com'), caption: 'The live NocoDB product site and self-hosted no-code platform.', }, diff --git a/packages/database/prisma/seeders/seedObjectStorage.ts b/packages/database/prisma/seeders/seedObjectStorage.ts new file mode 100644 index 0000000..8323f9d --- /dev/null +++ b/packages/database/prisma/seeders/seedObjectStorage.ts @@ -0,0 +1,241 @@ +import { + HeadBucketCommand, + HeadObjectCommand, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; + +const DEFAULT_BUCKET = 'bootcamp-media'; +const DEFAULT_ENDPOINT = 'http://localhost:9000'; +const DEFAULT_REGION = 'us-east-1'; +const DEFAULT_ACCESS_KEY = 'bootcamp'; +const DEFAULT_SECRET_KEY = 'bootcamp-secret'; +const MAX_SEED_IMAGE_SIZE_BYTES = 10 * 1024 * 1024; + +type MirrorImageOptions = { + attempts?: number; + sourceFingerprint?: string; +}; + +export class SeedObjectStorage { + private readonly client: S3Client; + private readonly bucket: string; + private readonly publicBaseUrl: string; + + constructor() { + const configuredEndpoint = optionalValue( + process.env.OBJECT_STORAGE_ENDPOINT, + ); + const useAwsDefaults = + !configuredEndpoint && Boolean(process.env.OBJECT_STORAGE_BUCKET); + const endpoint = useAwsDefaults ? undefined : DEFAULT_ENDPOINT; + const resolvedEndpoint = configuredEndpoint ?? endpoint; + const region = process.env.OBJECT_STORAGE_REGION ?? DEFAULT_REGION; + const accessKeyId = optionalValue(process.env.OBJECT_STORAGE_ACCESS_KEY); + const secretAccessKey = optionalValue( + process.env.OBJECT_STORAGE_SECRET_KEY, + ); + + if (Boolean(accessKeyId) !== Boolean(secretAccessKey)) { + throw new Error( + 'OBJECT_STORAGE_ACCESS_KEY and OBJECT_STORAGE_SECRET_KEY must be configured together.', + ); + } + + this.bucket = process.env.OBJECT_STORAGE_BUCKET ?? DEFAULT_BUCKET; + const configuredPublicUrl = optionalValue( + process.env.OBJECT_STORAGE_PUBLIC_URL, + ); + if (!configuredPublicUrl && !resolvedEndpoint) { + throw new Error( + 'OBJECT_STORAGE_PUBLIC_URL is required when using AWS S3.', + ); + } + this.publicBaseUrl = configuredPublicUrl + ? configuredPublicUrl.replace(/\/+$/, '') + : `${resolvedEndpoint!.replace(/\/+$/, '')}/${encodeURIComponent(this.bucket)}`; + + const forcePathStyle = optionalValue( + process.env.OBJECT_STORAGE_FORCE_PATH_STYLE, + ); + + this.client = new S3Client({ + ...(resolvedEndpoint ? { endpoint: resolvedEndpoint } : {}), + region, + forcePathStyle: forcePathStyle + ? forcePathStyle.toLowerCase() === 'true' + : Boolean(resolvedEndpoint), + ...(accessKeyId && secretAccessKey + ? { credentials: { accessKeyId, secretAccessKey } } + : resolvedEndpoint + ? { + credentials: { + accessKeyId: DEFAULT_ACCESS_KEY, + secretAccessKey: DEFAULT_SECRET_KEY, + }, + } + : {}), + }); + } + + async assertAvailable(): Promise { + try { + await this.client.send(new HeadBucketCommand({ Bucket: this.bucket })); + } catch (error) { + throw new Error( + `Seed object-storage bucket "${this.bucket}" is unavailable: ${errorMessage(error)}`, + ); + } + } + + async mirrorImage( + sourceUrl: string, + key: string, + options: MirrorImageOptions = {}, + ): Promise { + if (await this.objectMatches(key, options.sourceFingerprint)) { + return this.publicUrl(key); + } + + const response = await fetchWithRetry(sourceUrl, options.attempts ?? 1); + + const contentType = response.headers + .get('content-type') + ?.split(';')[0] + ?.trim(); + if (!contentType?.startsWith('image/')) { + throw new Error( + `Seed image ${sourceUrl} returned unsupported content type "${contentType ?? 'unknown'}".`, + ); + } + + const declaredSize = Number(response.headers.get('content-length')); + if ( + Number.isFinite(declaredSize) && + declaredSize > MAX_SEED_IMAGE_SIZE_BYTES + ) { + throw new Error(`Seed image ${sourceUrl} exceeds the 10 MB limit.`); + } + + const body = Buffer.from(await response.arrayBuffer()); + if (body.length > MAX_SEED_IMAGE_SIZE_BYTES) { + throw new Error(`Seed image ${sourceUrl} exceeds the 10 MB limit.`); + } + + try { + await this.client.send( + new PutObjectCommand({ + Bucket: this.bucket, + Key: key, + Body: body, + ContentLength: body.length, + ContentType: contentType, + CacheControl: 'public, max-age=31536000, immutable', + ...(options.sourceFingerprint + ? { + Metadata: { + 'legacy-source-sha256': options.sourceFingerprint, + }, + } + : {}), + }), + ); + } catch (error) { + throw new Error( + `Failed to upload seed image to ${key}: ${errorMessage(error)}`, + ); + } + + console.log(` Uploaded seed image ${key}.`); + return this.publicUrl(key); + } + + private async objectMatches( + key: string, + sourceFingerprint: string | undefined, + ): Promise { + try { + const response = await this.client.send( + new HeadObjectCommand({ Bucket: this.bucket, Key: key }), + ); + return sourceFingerprint + ? response.Metadata?.['legacy-source-sha256'] === sourceFingerprint + : true; + } catch (error) { + if (isNotFound(error)) return false; + throw new Error( + `Failed to inspect seed object ${key}: ${errorMessage(error)}`, + ); + } + } + + private publicUrl(key: string): string { + const encodedKey = key + .split('/') + .map((segment) => encodeURIComponent(segment)) + .join('/'); + return `${this.publicBaseUrl}/${encodedKey}`; + } +} + +async function fetchWithRetry( + sourceUrl: string, + attempts: number, +): Promise { + const maximumAttempts = Math.max(1, attempts); + let lastError: unknown; + + for (let attempt = 1; attempt <= maximumAttempts; attempt += 1) { + try { + const response = await fetch(sourceUrl, { + headers: { + 'cache-control': 'no-cache', + 'user-agent': 'bootcamp-starter-media-migration/1.0', + }, + signal: AbortSignal.timeout(60_000), + }); + + if (response.ok) return response; + + lastError = new Error(`HTTP ${response.status}`); + const retryable = [404, 502, 503, 504].includes(response.status); + if (!retryable || attempt === maximumAttempts) break; + await response.body?.cancel(); + } catch (error) { + lastError = error; + if (attempt === maximumAttempts) break; + } + + await delay(Math.min(250 * attempt, 2_000)); + } + + throw new Error( + `Failed to download seed image ${sourceUrl} after ${maximumAttempts} attempt(s): ${errorMessage(lastError)}`, + ); +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +function optionalValue(value: string | undefined): string | undefined { + const normalized = value?.trim(); + return normalized || undefined; +} + +function isNotFound(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const candidate = error as { + name?: string; + $metadata?: { httpStatusCode?: number }; + }; + return ( + candidate.$metadata?.httpStatusCode === 404 || + candidate.name === 'NotFound' || + candidate.name === 'NoSuchKey' + ); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/database/prisma/seeders/seedProjects.ts b/packages/database/prisma/seeders/seedProjects.ts index f006799..9949e91 100644 --- a/packages/database/prisma/seeders/seedProjects.ts +++ b/packages/database/prisma/seeders/seedProjects.ts @@ -4,6 +4,7 @@ import { projectCatalog, technologySeeds, } from './projectCatalog'; +import { SeedObjectStorage } from './seedObjectStorage'; const DAY_IN_MS = 24 * 60 * 60 * 1000; @@ -33,7 +34,10 @@ async function generateLocalEmbedding(text: string): Promise { } } -export async function seedProjects(prisma: PrismaClient) { +export async function seedProjects( + prisma: PrismaClient, + objectStorage: SeedObjectStorage, +) { console.log( 'Seeding technologies, repositories, and showcase projects with vectors...', ); @@ -87,6 +91,27 @@ export async function seedProjects(prisma: PrismaClient) { ); } + const mediaKeyPrefix = `seed/${item.slug}/`; + const [logoUrl, storedMedia] = await Promise.all([ + objectStorage.mirrorImage(item.logoSourceUrl, `${mediaKeyPrefix}logo`), + Promise.all( + item.media.map(async (media, index) => { + const storageKey = `${mediaKeyPrefix}${index + 1}`; + return { + storageKey, + publicUrl: await objectStorage.mirrorImage( + media.sourceUrl, + storageKey, + ), + caption: media.caption, + }; + }), + ), + ]); + + // The catalog points to real public repositories. Demo ownership is + // intentionally assigned to the seeded developers for portfolio content; + // live GitHub import and collaborator verification still require OAuth. const repository = await prisma.repository.upsert({ where: { githubRepoId: item.repository.githubRepoId }, update: { @@ -116,7 +141,7 @@ export async function seedProjects(prisma: PrismaClient) { createdByUserId: owner.id, title: item.title, slug: item.slug, - logoUrl: item.logoUrl, + logoUrl, shortDescription: item.shortDescription, fullDescription: item.fullDescription, deploymentUrl: item.deploymentUrl, @@ -132,7 +157,7 @@ export async function seedProjects(prisma: PrismaClient) { createdByUserId: owner.id, title: item.title, slug: item.slug, - logoUrl: item.logoUrl, + logoUrl, shortDescription: item.shortDescription, fullDescription: item.fullDescription, deploymentUrl: item.deploymentUrl, @@ -263,20 +288,19 @@ export async function seedProjects(prisma: PrismaClient) { }); // Sync Media - const mediaKeyPrefix = `seed/${item.slug}/`; await prisma.projectMedia.deleteMany({ where: { projectId: project.id, storageKey: { startsWith: mediaKeyPrefix }, }, }); - if (item.media && item.media.length > 0) { + if (storedMedia.length > 0) { await prisma.projectMedia.createMany({ - data: item.media.map((media, index) => ({ + data: storedMedia.map((media, index) => ({ projectId: project.id, uploadedByUserId: owner.id, mediaType: 'IMAGE' as const, - storageKey: `${mediaKeyPrefix}${index + 1}`, + storageKey: media.storageKey, publicUrl: media.publicUrl, caption: media.caption, sortOrder: index, diff --git a/packages/database/prisma/seeders/seedUsers.ts b/packages/database/prisma/seeders/seedUsers.ts index fe5ac6f..761d2df 100644 --- a/packages/database/prisma/seeders/seedUsers.ts +++ b/packages/database/prisma/seeders/seedUsers.ts @@ -1,6 +1,7 @@ /// import { randomBytes, scryptSync } from 'node:crypto'; import { PrismaClient } from '../../src/generated/prisma/client'; +import { SeedObjectStorage } from './seedObjectStorage'; function hashPassword(password: string): string { const salt = randomBytes(16).toString('hex'); @@ -8,7 +9,10 @@ function hashPassword(password: string): string { return `${salt}:${derivedKey}`; } -export async function seedUsers(prisma: PrismaClient) { +export async function seedUsers( + prisma: PrismaClient, + objectStorage: SeedObjectStorage, +) { const defaultPassword = 'Password123!'; console.log('Seeding users and profiles...'); @@ -35,7 +39,7 @@ export async function seedUsers(prisma: PrismaClient) { headline: 'Senior Product Engineer · React, Next.js & Design Systems', bio: 'Product-focused full-stack engineer who turns complex workflows into fast, accessible experiences. Sarah enjoys building design systems, collaborative tools, and polished SaaS products with TypeScript.', location: 'San Francisco, California', - profilePictureUrl: + profilePictureSourceUrl: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=512&h=512&q=85', }, { @@ -47,12 +51,16 @@ export async function seedUsers(prisma: PrismaClient) { headline: 'Staff Platform Engineer · APIs, Data & Infrastructure', bio: 'Backend and platform engineer specializing in resilient APIs, data-intensive systems, and developer infrastructure. Alex works across PostgreSQL, Redis, containers, queues, observability, and cloud delivery.', location: 'Berlin, Germany', - profilePictureUrl: + profilePictureSourceUrl: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=512&h=512&q=85', }, ]; for (const dev of devs) { + const profilePictureUrl = await objectStorage.mirrorImage( + dev.profilePictureSourceUrl, + `seed/developers/${dev.publicSlug}/profile`, + ); const user = await prisma.user.upsert({ where: { email: dev.email }, update: { @@ -77,8 +85,8 @@ export async function seedUsers(prisma: PrismaClient) { headline: dev.headline, bio: dev.bio, location: dev.location, - profilePictureUrl: dev.profilePictureUrl, - profilePictureOriginalUrl: dev.profilePictureUrl, + profilePictureUrl, + profilePictureOriginalUrl: profilePictureUrl, profilePictureCropZoom: 1, profilePictureCropX: 0, profilePictureCropY: 0, @@ -92,8 +100,8 @@ export async function seedUsers(prisma: PrismaClient) { headline: dev.headline, bio: dev.bio, location: dev.location, - profilePictureUrl: dev.profilePictureUrl, - profilePictureOriginalUrl: dev.profilePictureUrl, + profilePictureUrl, + profilePictureOriginalUrl: profilePictureUrl, profilePictureCropZoom: 1, profilePictureCropX: 0, profilePictureCropY: 0, From 5ea376f737fd7d587878e2d35b281d4057cb4444 Mon Sep 17 00:00:00 2001 From: AmzBG Date: Fri, 7 Aug 2026 15:03:28 +0300 Subject: [PATCH 2/2] fix: README MinIO update --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3cae6a0..a1e8535 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A generic full-stack starter for bootcamp projects. Multi-tenant auth scaffoldin | Database | PostgreSQL 18 via Prisma | | Queue | Redis + BullMQ | | Email (dev) | Mailpit | +| Files (dev) | MinIO (S3-compatible object storage) | | Build | Turborepo | | AI guidance | Single [`AGENTS.md`](AGENTS.md) for Claude / Cursor / Codex / etc. | @@ -29,7 +30,7 @@ cp apps/api/.env.example apps/api/.env cp apps/web/.env.example apps/web/.env cp packages/database/.env.example packages/database/.env -# Start postgres + redis + mailpit +# Start postgres + redis + mailpit + minio npm run services:init # Install @@ -52,6 +53,8 @@ npm run dev - Web: - API: - Mailpit: +- MinIO API: +- MinIO console: (`bootcamp` / `bootcamp-secret`) ## Monorepo layout @@ -94,7 +97,7 @@ Schema lives at [packages/database/prisma/schema.prisma](packages/database/prism ## Troubleshooting -- **Port already in use** — postgres uses :5433, redis :6380, mailpit :8025/:1025. Stop the conflicting process or change ports in `docker-compose.yml`. +- **Port already in use** — postgres uses :5433, redis :6380, mailpit :8025/:1025, and MinIO :9000/:9001. Stop the conflicting process or change ports in `docker-compose.yml`. - **`@repo/contracts` types not found** — run `npx tsc -p packages/contracts` once. - **Prisma client out of date** — `npx turbo run db:generate`. - **Docker volume cruft** — `docker compose down -v && npm run services:init` (wipes local DB data).