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; }