UI & Feature update - Alpha #9
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user