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:
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "avatarPath" TEXT;
|
||||||
@@ -39,6 +39,7 @@ model User {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
lastLoginAt DateTime?
|
lastLoginAt DateTime?
|
||||||
|
avatarPath String?
|
||||||
passwordResetTokens PasswordResetToken[]
|
passwordResetTokens PasswordResetToken[]
|
||||||
|
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
|
|||||||
@@ -53,11 +53,12 @@ export class AuthController {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /auth/me
|
* 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')
|
@Get('me')
|
||||||
me(@CurrentUser() user: any) {
|
async me(@CurrentUser() user: any) {
|
||||||
return user;
|
return this.authService.getMe(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -182,6 +182,40 @@ export class AuthService {
|
|||||||
this.logger.log(`Password reset completed for user ${resetToken.userId}`);
|
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.
|
* Change password for the currently logged-in user.
|
||||||
* Verifies current password before allowing change.
|
* Verifies current password before allowing change.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
@@ -8,9 +9,16 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Res,
|
||||||
|
UploadedFile,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { Role } from '@prisma/client';
|
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 { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
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 { UpdateUserDto } from './dto/update-user.dto';
|
||||||
import { UserService } from './user.service';
|
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')
|
@Controller('users')
|
||||||
@UseGuards(RolesGuard)
|
@UseGuards(RolesGuard)
|
||||||
export class UserController {
|
export class UserController {
|
||||||
@@ -194,4 +215,101 @@ export class UserController {
|
|||||||
await this.userService.delete(id);
|
await this.userService.delete(id);
|
||||||
return { message: 'User deleted' };
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user