From a30113e8e26129e40196287939c998fe27088308 Mon Sep 17 00:00:00 2001 From: Mathis HERRIOT <197931332+0x485254@users.noreply.github.com> Date: Wed, 14 Jan 2026 21:42:46 +0100 Subject: [PATCH] feat(users): add avatar and bio support, improve user profile handling Introduce `avatarUrl` and `bio` fields in the user schema. Update repository, service, and controller to handle avatar uploads, processing, and bio updates. Add S3 integration for avatar storage and enhance user data handling for private and public profiles. --- backend/src/database/schemas/users.ts | 2 + .../users/repositories/users.repository.ts | 5 + backend/src/users/users.controller.ts | 19 ++++ backend/src/users/users.module.ts | 10 +- backend/src/users/users.service.ts | 103 +++++++++++++++++- frontend/src/services/user.service.ts | 28 +++++ frontend/src/types/user.ts | 2 +- 7 files changed, 163 insertions(+), 6 deletions(-) diff --git a/backend/src/database/schemas/users.ts b/backend/src/database/schemas/users.ts index fb9c55f..86ce9dc 100644 --- a/backend/src/database/schemas/users.ts +++ b/backend/src/database/schemas/users.ts @@ -30,6 +30,8 @@ export const users = pgTable( username: varchar("username", { length: 32 }).notNull().unique(), passwordHash: varchar("password_hash", { length: 100 }).notNull(), + avatarUrl: varchar("avatar_url", { length: 512 }), + bio: varchar("bio", { length: 255 }), // Sécurité twoFactorSecret: pgpEncrypted("two_factor_secret"), diff --git a/backend/src/users/repositories/users.repository.ts b/backend/src/users/repositories/users.repository.ts index 7a3f538..167f12c 100644 --- a/backend/src/users/repositories/users.repository.ts +++ b/backend/src/users/repositories/users.repository.ts @@ -43,6 +43,8 @@ export class UsersRepository { username: users.username, email: users.email, displayName: users.displayName, + avatarUrl: users.avatarUrl, + bio: users.bio, status: users.status, isTwoFactorEnabled: users.isTwoFactorEnabled, createdAt: users.createdAt, @@ -67,6 +69,7 @@ export class UsersRepository { uuid: users.uuid, username: users.username, displayName: users.displayName, + avatarUrl: users.avatarUrl, status: users.status, createdAt: users.createdAt, }) @@ -81,6 +84,8 @@ export class UsersRepository { uuid: users.uuid, username: users.username, displayName: users.displayName, + avatarUrl: users.avatarUrl, + bio: users.bio, createdAt: users.createdAt, }) .from(users) diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index c036337..e49dfcb 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -13,9 +13,11 @@ import { Post, Query, Req, + UploadedFile, UseGuards, UseInterceptors, } from "@nestjs/common"; +import { FileInterceptor } from "@nestjs/platform-express"; import { AuthService } from "../auth/auth.service"; import { Roles } from "../auth/decorators/roles.decorator"; import { AuthGuard } from "../auth/guards/auth.guard"; @@ -74,6 +76,16 @@ export class UsersController { return this.usersService.update(req.user.sub, updateUserDto); } + @Post("me/avatar") + @UseGuards(AuthGuard) + @UseInterceptors(FileInterceptor("file")) + updateAvatar( + @Req() req: AuthenticatedRequest, + @UploadedFile() file: Express.Multer.File, + ) { + return this.usersService.updateAvatar(req.user.sub, file); + } + @Patch("me/consent") @UseGuards(AuthGuard) updateConsent( @@ -93,6 +105,13 @@ export class UsersController { return this.usersService.remove(req.user.sub); } + @Delete(":uuid") + @UseGuards(AuthGuard, RolesGuard) + @Roles("admin") + removeAdmin(@Param("uuid") uuid: string) { + return this.usersService.remove(uuid); + } + // Double Authentification (2FA) @Post("me/2fa/setup") @UseGuards(AuthGuard) diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts index 21678c9..dd4daa1 100644 --- a/backend/src/users/users.module.ts +++ b/backend/src/users/users.module.ts @@ -2,12 +2,20 @@ import { forwardRef, Module } from "@nestjs/common"; import { AuthModule } from "../auth/auth.module"; import { CryptoModule } from "../crypto/crypto.module"; import { DatabaseModule } from "../database/database.module"; +import { MediaModule } from "../media/media.module"; +import { S3Module } from "../s3/s3.module"; import { UsersRepository } from "./repositories/users.repository"; import { UsersController } from "./users.controller"; import { UsersService } from "./users.service"; @Module({ - imports: [DatabaseModule, CryptoModule, forwardRef(() => AuthModule)], + imports: [ + DatabaseModule, + CryptoModule, + forwardRef(() => AuthModule), + MediaModule, + S3Module, + ], controllers: [UsersController], providers: [UsersService, UsersRepository], exports: [UsersService, UsersRepository], diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 8c7a97e..9bea82f 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -1,6 +1,18 @@ import { CACHE_MANAGER } from "@nestjs/cache-manager"; -import { Inject, Injectable, Logger } from "@nestjs/common"; +import { + BadRequestException, + forwardRef, + Inject, + Injectable, + Logger, +} from "@nestjs/common"; import type { Cache } from "cache-manager"; +import { v4 as uuidv4 } from "uuid"; +import { RbacService } from "../auth/rbac.service"; +import type { IMediaService } from "../common/interfaces/media.interface"; +import type { IStorageService } from "../common/interfaces/storage.interface"; +import { MediaService } from "../media/media.service"; +import { S3Service } from "../s3/s3.service"; import { UpdateUserDto } from "./dto/update-user.dto"; import { UsersRepository } from "./repositories/users.repository"; @@ -11,6 +23,10 @@ export class UsersService { constructor( private readonly usersRepository: UsersRepository, @Inject(CACHE_MANAGER) private cacheManager: Cache, + @Inject(forwardRef(() => RbacService)) + private readonly rbacService: RbacService, + @Inject(MediaService) private readonly mediaService: IMediaService, + @Inject(S3Service) private readonly s3Service: IStorageService, ) {} private async clearUserCache(username?: string) { @@ -33,7 +49,19 @@ export class UsersService { } async findOneWithPrivateData(uuid: string) { - return await this.usersRepository.findOneWithPrivateData(uuid); + const [user, roles] = await Promise.all([ + this.usersRepository.findOneWithPrivateData(uuid), + this.rbacService.getUserRoles(uuid), + ]); + + if (!user) return null; + + return { + ...user, + avatarUrl: user.avatarUrl ? this.getFileUrl(user.avatarUrl) : null, + role: roles.includes("admin") ? "admin" : "user", + roles, + }; } async findAll(limit: number, offset: number) { @@ -42,11 +70,22 @@ export class UsersService { this.usersRepository.countAll(), ]); - return { data, totalCount }; + const processedData = data.map((user) => ({ + ...user, + avatarUrl: user.avatarUrl ? this.getFileUrl(user.avatarUrl) : null, + })); + + return { data: processedData, totalCount }; } async findPublicProfile(username: string) { - return await this.usersRepository.findByUsername(username); + const user = await this.usersRepository.findByUsername(username); + if (!user) return null; + + return { + ...user, + avatarUrl: user.avatarUrl ? this.getFileUrl(user.avatarUrl) : null, + }; } async findOne(uuid: string) { @@ -63,6 +102,49 @@ export class UsersService { return result; } + async updateAvatar(uuid: string, file: Express.Multer.File) { + this.logger.log(`Updating avatar for user ${uuid}`); + + // Validation du format et de la taille + const allowedMimeTypes = ["image/png", "image/jpeg", "image/webp"]; + if (!allowedMimeTypes.includes(file.mimetype)) { + throw new BadRequestException( + "Format d'image non supporté. Formats acceptés: png, jpeg, webp.", + ); + } + + if (file.size > 2 * 1024 * 1024) { + throw new BadRequestException( + "Image trop volumineuse. Limite: 2 Mo.", + ); + } + + // 1. Scan Antivirus + const scanResult = await this.mediaService.scanFile( + file.buffer, + file.originalname, + ); + if (scanResult.isInfected) { + throw new BadRequestException( + `Le fichier est infecté par ${scanResult.virusName}`, + ); + } + + // 2. Traitement (WebP + Redimensionnement 512x512) + const processed = await this.mediaService.processImage(file.buffer, "webp", { + width: 512, + height: 512, + }); + + // 3. Upload vers S3 + const key = `avatars/${uuid}/${Date.now()}-${uuidv4()}.${processed.extension}`; + await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType); + + // 4. Mise à jour de la base de données + const user = await this.update(uuid, { avatarUrl: key }); + return user[0]; + } + async updateConsent( uuid: string, termsVersion: string, @@ -111,4 +193,17 @@ export class UsersService { async remove(uuid: string) { return await this.usersRepository.softDeleteUserAndContents(uuid); } + + private getFileUrl(storageKey: string): string { + const endpoint = this.configService.get("S3_ENDPOINT"); + const port = this.configService.get("S3_PORT"); + const protocol = + this.configService.get("S3_USE_SSL") === true ? "https" : "http"; + const bucket = this.configService.get("S3_BUCKET_NAME"); + + if (endpoint === "localhost" || endpoint === "127.0.0.1") { + return `${protocol}://${endpoint}:${port}/${bucket}/${storageKey}`; + } + return `${protocol}://${endpoint}/${bucket}/${storageKey}`; + } } diff --git a/frontend/src/services/user.service.ts b/frontend/src/services/user.service.ts index 5eb4e7e..a957fef 100644 --- a/frontend/src/services/user.service.ts +++ b/frontend/src/services/user.service.ts @@ -16,4 +16,32 @@ export const UserService = { const { data } = await api.patch("/users/me", update); return data; }, + + async getUsersAdmin( + limit = 10, + offset = 0, + ): Promise<{ data: User[]; totalCount: number }> { + const { data } = await api.get<{ data: User[]; totalCount: number }>( + "/users/admin", + { + params: { limit, offset }, + }, + ); + return data; + }, + + async removeUserAdmin(uuid: string): Promise { + await api.delete(`/users/${uuid}`); + }, + + async updateAvatar(file: File): Promise { + const formData = new FormData(); + formData.append("file", file); + const { data } = await api.post("/users/me/avatar", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + return data; + }, }; diff --git a/frontend/src/types/user.ts b/frontend/src/types/user.ts index 1938f44..9966d78 100644 --- a/frontend/src/types/user.ts +++ b/frontend/src/types/user.ts @@ -4,12 +4,12 @@ export interface User { email: string; displayName?: string; avatarUrl?: string; + bio?: string; role: "user" | "admin"; createdAt: string; } export interface UserProfile extends User { - bio?: string; favoritesCount: number; uploadsCount: number; }