Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand All @@ -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
Expand All @@ -52,6 +53,8 @@ npm run dev
- Web: <http://localhost:3000>
- API: <http://localhost:3001>
- Mailpit: <http://localhost:8025>
- MinIO API: <http://localhost:9000>
- MinIO console: <http://localhost:9001> (`bootcamp` / `bootcamp-secret`)

## Monorepo layout

Expand Down Expand Up @@ -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).
7 changes: 7 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
46 changes: 46 additions & 0 deletions apps/api/src/auth/auth.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,26 @@ 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;

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: [
{
provide: AuthService,
useValue: mockAuthService,
},
{ provide: ObjectStorageService, useValue: objectStorage },
],
}).compile();

Expand All @@ -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();
});
});
73 changes: 31 additions & 42 deletions apps/api/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { join } from 'path';
import { randomUUID } from 'crypto';
import { readFileSync, unlinkSync, renameSync } from 'fs';
import {
Controller,
Post,
Expand All @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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 (
Expand All @@ -360,43 +350,42 @@ export class AuthController {
},
),
)
uploadProfilePicture(
async uploadProfilePicture(
@UploadedFiles() files: ProfilePictureFiles | undefined,
): ProfilePictureUploadResponse {
): Promise<ProfilePictureUploadResponse> {
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')
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions apps/api/src/auth/utils/image-content-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
const CONTENT_TYPES: Record<string, string> = {
'.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;
}
4 changes: 2 additions & 2 deletions apps/api/src/common/swagger/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading