From 0fba45d2c2c4774108128b2a60aff3f9fba3257b Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 30 Jun 2026 11:57:33 +0200 Subject: [PATCH] feat(quick-260630-gbh-01): avatar storage endpoints + enriched /auth/me - Add avatarPath String? column to User model (migration: add_user_avatar) - POST /users/me/avatar: 2MB limit, image/png/jpeg/webp allowlist, writes to user-files/avatars/{userId}.{ext} - GET /users/me/avatar: streams avatar with Cache-Control: no-store - AuthService.getMe(): returns isLocalUser + hasAvatar without leaking passwordHash/ldapDn - AuthController GET /auth/me: now returns enriched profile via getMe() --- .../migration.sql | 2 + apps/api/prisma/schema.prisma | 1 + apps/api/src/auth/auth.controller.ts | 7 +- apps/api/src/auth/auth.service.ts | 34 +++++ apps/api/src/user/user.controller.ts | 118 ++++++++++++++++++ 5 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 apps/api/prisma/migrations/20260630095533_add_user_avatar/migration.sql diff --git a/apps/api/prisma/migrations/20260630095533_add_user_avatar/migration.sql b/apps/api/prisma/migrations/20260630095533_add_user_avatar/migration.sql new file mode 100644 index 0000000..f9c3b3a --- /dev/null +++ b/apps/api/prisma/migrations/20260630095533_add_user_avatar/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "avatarPath" TEXT; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 902280a..05f9dd4 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -39,6 +39,7 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt lastLoginAt DateTime? + avatarPath String? passwordResetTokens PasswordResetToken[] @@index([tenantId]) diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index a2c8820..7b815e7 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -53,11 +53,12 @@ export class AuthController { /** * GET /auth/me - * Returns the current user from JWT (session check). + * Returns enriched user profile: public fields + isLocalUser + hasAvatar. + * T-gbh-03: passwordHash and ldapDn are never serialised in the response. */ @Get('me') - me(@CurrentUser() user: any) { - return user; + async me(@CurrentUser() user: any) { + return this.authService.getMe(user.id); } /** diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index f815260..6e53649 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -182,6 +182,40 @@ export class AuthService { this.logger.log(`Password reset completed for user ${resetToken.userId}`); } + /** + * Return enriched profile for the currently authenticated user. + * T-gbh-03: Only public fields + isLocalUser/hasAvatar returned — never + * passwordHash or ldapDn. + */ + async getMe(userId: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + username: true, + displayName: true, + role: true, + tenantId: true, + mustChangePassword: true, + passwordHash: true, + ldapDn: true, + avatarPath: true, + }, + }); + + if (!user) { + return null; + } + + const { passwordHash, ldapDn, avatarPath, ...publicFields } = user; + + return { + ...publicFields, + isLocalUser: !!passwordHash && !ldapDn, + hasAvatar: !!avatarPath, + }; + } + /** * Change password for the currently logged-in user. * Verifies current password before allowing change. diff --git a/apps/api/src/user/user.controller.ts b/apps/api/src/user/user.controller.ts index 4c0d0e3..28f53ea 100644 --- a/apps/api/src/user/user.controller.ts +++ b/apps/api/src/user/user.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Delete, @@ -8,9 +9,16 @@ import { Param, Patch, Post, + Res, + UploadedFile, UseGuards, + UseInterceptors, } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; import { Role } from '@prisma/client'; +import * as fs from 'fs'; +import * as path from 'path'; +import { Response } from 'express'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { Roles } from '../auth/decorators/roles.decorator'; import { RolesGuard } from '../auth/guards/roles.guard'; @@ -19,6 +27,19 @@ import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserDto } from './dto/update-user.dto'; import { UserService } from './user.service'; +/** Map accepted MIME types to file extensions (T-gbh-01). */ +const AVATAR_MIME_TO_EXT: Record = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/webp': 'webp', +}; + +/** Resolve the avatars storage directory relative to the monorepo root. + * At runtime __dirname = apps/api/dist/user/ → go up 4 levels. */ +function resolveAvatarsDir(): string { + return path.resolve(__dirname, '..', '..', '..', '..', 'user-files', 'avatars'); +} + @Controller('users') @UseGuards(RolesGuard) export class UserController { @@ -194,4 +215,101 @@ export class UserController { await this.userService.delete(id); return { message: 'User deleted' }; } + + // ─── Self-service avatar endpoints (all authenticated roles) ─────────────── + // No @Roles() → RolesGuard.canActivate() returns true when requiredRoles is + // empty (see guards/roles.guard.ts). Global JwtAuthGuard still enforces auth. + + /** + * POST /users/me/avatar + * Upload or replace the current user's profile picture. + * T-gbh-01: 2 MB size limit + image-only MIME allowlist. + * T-gbh-02: userId derived from @CurrentUser() only — never from request body. + * T-gbh-04: filename = {userId}.{ext} derived from MIME — no path traversal. + */ + @Post('me/avatar') + @UseInterceptors( + FileInterceptor('file', { limits: { fileSize: 2 * 1024 * 1024 } }), + ) + async uploadAvatar( + @UploadedFile() file: any, + @CurrentUser() currentUser: any, + ) { + if (!file || !file.buffer) { + throw new BadRequestException('No file provided'); + } + + const ext = AVATAR_MIME_TO_EXT[file.mimetype as string]; + if (!ext) { + throw new BadRequestException( + 'Invalid file type. Allowed: image/png, image/jpeg, image/webp', + ); + } + + const avatarsDir = resolveAvatarsDir(); + fs.mkdirSync(avatarsDir, { recursive: true }); + + const filename = `${currentUser.id}.${ext}`; + const filePath = path.join(avatarsDir, filename); + + // Remove any previous avatar files for this user (different extension) + for (const existingExt of Object.values(AVATAR_MIME_TO_EXT)) { + const candidate = path.join(avatarsDir, `${currentUser.id}.${existingExt}`); + if (candidate !== filePath && fs.existsSync(candidate)) { + fs.unlinkSync(candidate); + } + } + + fs.writeFileSync(filePath, file.buffer as Buffer); + + // Persist relative path (relative to monorepo root) + const relativePath = path.join('user-files', 'avatars', filename); + await this.prisma.user.update({ + where: { id: currentUser.id }, + data: { avatarPath: relativePath }, + }); + + return { success: true }; + } + + /** + * GET /users/me/avatar + * Stream the current user's avatar image. + * T-gbh-05: Only the authenticated user's own file is served here. + */ + @Get('me/avatar') + async getAvatar( + @CurrentUser() currentUser: any, + @Res() res: Response, + ) { + const user = await this.prisma.user.findUnique({ + where: { id: currentUser.id }, + select: { avatarPath: true }, + }); + + if (!user?.avatarPath) { + throw new NotFoundException('No avatar set'); + } + + // Resolve from monorepo root (same upward-walk pattern as DkvService) + const monorepoRoot = path.resolve(__dirname, '..', '..', '..', '..'); + const absolutePath = path.join(monorepoRoot, user.avatarPath); + + if (!fs.existsSync(absolutePath)) { + throw new NotFoundException('Avatar file not found'); + } + + const ext = path.extname(absolutePath).slice(1).toLowerCase(); + const mimeTypes: Record = { + png: 'image/png', + jpg: 'image/jpeg', + webp: 'image/webp', + }; + const contentType = mimeTypes[ext] ?? 'application/octet-stream'; + + const buffer = fs.readFileSync(absolutePath); + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', 'no-store'); + res.send(buffer); + } }