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()
This commit is contained in:
2026-06-30 11:57:33 +02:00
parent dcba4b9977
commit 0fba45d2c2
5 changed files with 159 additions and 3 deletions
+118
View File
@@ -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<string, string> = {
'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<string, string> = {
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);
}
}