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.
This commit is contained in:
@@ -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