UI & Feature update - Alpha #9

Merged
Mathis merged 22 commits from dev into prod 2026-01-14 22:40:06 +01:00
7 changed files with 163 additions and 6 deletions
Showing only changes of commit a30113e8e2 - Show all commits

View File

@@ -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"),

View File

@@ -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)

View File

@@ -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)

View File

@@ -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],

View File

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

View File

@@ -16,4 +16,32 @@ export const UserService = {
const { data } = await api.patch<User>("/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<void> {
await api.delete(`/users/${uuid}`);
},
async updateAvatar(file: File): Promise<User> {
const formData = new FormData();
formData.append("file", file);
const { data } = await api.post<User>("/users/me/avatar", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
return data;
},
};

View File

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