Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa673d0f80
|
||
|
|
8df6d15b19
|
||
|
|
0144421f03
|
||
|
|
df9a6c6f36
|
||
|
|
15426a9e18
|
||
|
|
a28844e9b7
|
||
|
|
ae916931f6
|
||
|
|
e4dc5dd10b
|
||
|
|
878c35cbcd
|
||
|
|
8cf0036248
|
||
|
|
c389024f59
|
||
|
|
bbdbe58af5
|
||
|
|
5951e41eb5
|
||
|
|
7442236e8d
|
||
|
|
3ef7292287
|
||
|
|
f1a571196d
|
||
|
|
f4cd20a010
|
||
|
|
988eacc281
|
||
|
|
329a150ff8
|
||
|
|
4372f75025
|
||
|
|
4fa163b542
|
||
|
|
7f0749808e
|
||
|
|
bcbc93d6a3
|
||
|
|
89587d6abc
|
||
|
|
3347d693ce
|
||
|
|
5048b4813c
|
||
|
|
906f615428
|
||
|
|
fc4efd1e24
|
||
|
|
6bc6a8f68c
|
||
|
|
e69156407e
|
||
|
|
7dce7ec286
|
||
|
|
029bbe9bb9
|
||
|
|
c3f57db1e5
|
||
|
|
939448d15c
|
||
|
|
4e61b0de9a
|
||
|
|
73556894f8
|
||
|
|
96a9d6e7a7
|
||
|
|
058830bb60
|
||
|
|
02d612e026
|
||
|
|
498f85d24e
|
||
|
|
10cc5a6d8d
|
||
|
|
7503707ef1
|
||
|
|
8778508ced
|
||
|
|
b968d1e6f8
|
||
|
|
0382b21a65
|
||
|
|
764c4c07c8
|
||
|
|
68b5071f6d
|
||
|
|
f5c90b0ae4
|
||
|
|
c8820a71b6
|
||
|
|
9b714716f6
|
||
|
|
3a5550d6eb
|
||
|
|
07cdb741b3
|
||
|
|
02796e4e1f
|
||
|
|
951b38db67
|
||
|
|
a90aba2748
|
||
|
|
3f0b1e5119
|
||
|
|
aff8acebf8
|
||
|
|
a721b4041c
|
||
|
|
f4a1a2f4df
|
||
|
|
0548c418c7
|
||
|
|
dd0a9e620b
|
||
|
|
7e7b19fe9f
|
||
|
|
57bc51290b
|
||
|
|
d613a89e63
|
||
|
|
67a10ad7d8
|
||
|
|
82e98f4fce
|
||
|
|
70a4249e41
|
||
|
|
de7d41f4a1
|
||
|
|
2da1142866
|
||
|
|
4e8e441d98
|
||
|
|
0e83de70e3
|
||
|
|
8169ef719a
|
||
|
|
7637499a97
|
||
|
|
c03ad8c221
|
||
|
|
8483927823
|
||
|
|
e7b79013fd
|
||
|
|
b6b37ebc6b
|
||
|
|
d647a585c8
|
||
|
|
6a2abf115f
|
||
|
|
ded2d3220d
|
@@ -4,11 +4,8 @@ name: CI/CD Pipeline
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
|
||||||
- '**'
|
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validate:
|
validate:
|
||||||
@@ -83,7 +80,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Déployer avec Docker Compose
|
- name: Déployer avec Docker Compose
|
||||||
run: |
|
run: |
|
||||||
docker compose -f docker-compose.prod.yml up -d --build
|
docker compose -f docker-compose.prod.yml up -d --build --remove-orphans
|
||||||
env:
|
env:
|
||||||
BACKEND_PORT: ${{ secrets.BACKEND_PORT }}
|
BACKEND_PORT: ${{ secrets.BACKEND_PORT }}
|
||||||
FRONTEND_PORT: ${{ secrets.FRONTEND_PORT }}
|
FRONTEND_PORT: ${{ secrets.FRONTEND_PORT }}
|
||||||
|
|||||||
756
backend.plantuml
Normal file
756
backend.plantuml
Normal file
@@ -0,0 +1,756 @@
|
|||||||
|
@startuml
|
||||||
|
|
||||||
|
!theme plain
|
||||||
|
top to bottom direction
|
||||||
|
skinparam linetype ortho
|
||||||
|
|
||||||
|
class AdminController {
|
||||||
|
constructor(adminService: AdminService):
|
||||||
|
getStats(): Promise<{users: number, contents: numbe…
|
||||||
|
}
|
||||||
|
class AdminModule
|
||||||
|
class AdminService {
|
||||||
|
constructor(usersRepository: UsersRepository, contentsRepository: ContentsRepository, categoriesRepository: CategoriesRepository):
|
||||||
|
getStats(): Promise<{users: number, contents: numbe…
|
||||||
|
}
|
||||||
|
class AllExceptionsFilter {
|
||||||
|
logger: Logger
|
||||||
|
catch(exception: unknown, host: ArgumentsHost): void
|
||||||
|
}
|
||||||
|
class ApiKeysController {
|
||||||
|
constructor(apiKeysService: ApiKeysService):
|
||||||
|
create(req: AuthenticatedRequest, createApiKeyDto: CreateApiKeyDto): Promise<{name: string, key: string, exp…
|
||||||
|
findAll(req: AuthenticatedRequest): Promise<any>
|
||||||
|
revoke(req: AuthenticatedRequest, id: string): Promise<any>
|
||||||
|
}
|
||||||
|
class ApiKeysModule
|
||||||
|
class ApiKeysRepository {
|
||||||
|
constructor(databaseService: DatabaseService):
|
||||||
|
create(data: {userId: string; name: string; prefix: string; keyHash: string; expiresAt?: Date}): Promise<any>
|
||||||
|
findAll(userId: string): Promise<any>
|
||||||
|
revoke(userId: string, keyId: string): Promise<any>
|
||||||
|
findActiveByKeyHash(keyHash: string): Promise<any>
|
||||||
|
updateLastUsed(id: string): Promise<any>
|
||||||
|
}
|
||||||
|
class ApiKeysService {
|
||||||
|
constructor(apiKeysRepository: ApiKeysRepository, hashingService: HashingService):
|
||||||
|
logger: Logger
|
||||||
|
create(userId: string, name: string, expiresAt?: Date): Promise<{name: string, key: string, exp…
|
||||||
|
findAll(userId: string): Promise<any>
|
||||||
|
revoke(userId: string, keyId: string): Promise<any>
|
||||||
|
validateKey(key: string): Promise<any>
|
||||||
|
}
|
||||||
|
class AppController {
|
||||||
|
constructor(appService: AppService):
|
||||||
|
getHello(): string
|
||||||
|
}
|
||||||
|
class AppModule {
|
||||||
|
configure(consumer: MiddlewareConsumer): void
|
||||||
|
}
|
||||||
|
class AppService {
|
||||||
|
getHello(): string
|
||||||
|
}
|
||||||
|
class AuditLogInDb
|
||||||
|
class AuthController {
|
||||||
|
constructor(authService: AuthService, bootstrapService: BootstrapService, configService: ConfigService):
|
||||||
|
register(registerDto: RegisterDto): Promise<{message: string, userId: any}>
|
||||||
|
login(loginDto: LoginDto, userAgent: string, req: Request, res: Response): Promise<Response<any, Record<string, an…
|
||||||
|
verifyTwoFactor(verify2faDto: Verify2faDto, userAgent: string, req: Request, res: Response): Promise<Response<any, Record<string, an…
|
||||||
|
refresh(req: Request, res: Response): Promise<Response<any, Record<string, an…
|
||||||
|
logout(req: Request, res: Response): Promise<Response<any, Record<string, an…
|
||||||
|
bootstrapAdmin(token: string, username: string): Promise<{message: string}>
|
||||||
|
}
|
||||||
|
class AuthGuard {
|
||||||
|
constructor(jwtService: JwtService, configService: ConfigService):
|
||||||
|
canActivate(context: ExecutionContext): Promise<boolean>
|
||||||
|
}
|
||||||
|
class AuthModule
|
||||||
|
class AuthService {
|
||||||
|
constructor(usersService: UsersService, hashingService: HashingService, jwtService: JwtService, sessionsService: SessionsService, configService: ConfigService):
|
||||||
|
logger: Logger
|
||||||
|
generateTwoFactorSecret(userId: string): Promise<{secret: string, qrCodeDataUrl:…
|
||||||
|
enableTwoFactor(userId: string, token: string): Promise<{message: string}>
|
||||||
|
disableTwoFactor(userId: string, token: string): Promise<{message: string}>
|
||||||
|
register(dto: RegisterDto): Promise<{message: string, userId: any}>
|
||||||
|
login(dto: LoginDto, userAgent?: string, ip?: string): Promise<{message: string, requires2FA: …
|
||||||
|
verifyTwoFactorLogin(userId: string, token: string, userAgent?: string, ip?: string): Promise<{message: string, access_token:…
|
||||||
|
refresh(refreshToken: string): Promise<{access_token: string, refresh_…
|
||||||
|
logout(): Promise<{message: string}>
|
||||||
|
}
|
||||||
|
class AuthenticatedRequest {
|
||||||
|
user: {sub: string, username: string}
|
||||||
|
}
|
||||||
|
class BootstrapService {
|
||||||
|
constructor(rbacService: RbacService, usersService: UsersService, configService: ConfigService):
|
||||||
|
logger: Logger
|
||||||
|
bootstrapToken: string | null
|
||||||
|
onApplicationBootstrap(): Promise<void>
|
||||||
|
generateBootstrapToken(): void
|
||||||
|
consumeToken(token: string, username: string): Promise<{message: string}>
|
||||||
|
}
|
||||||
|
class CategoriesController {
|
||||||
|
constructor(categoriesService: CategoriesService):
|
||||||
|
findAll(): Promise<any>
|
||||||
|
findOne(id: string): Promise<any>
|
||||||
|
create(createCategoryDto: CreateCategoryDto): Promise<any>
|
||||||
|
update(id: string, updateCategoryDto: UpdateCategoryDto): Promise<any>
|
||||||
|
remove(id: string): Promise<any>
|
||||||
|
}
|
||||||
|
class CategoriesModule
|
||||||
|
class CategoriesRepository {
|
||||||
|
constructor(databaseService: DatabaseService):
|
||||||
|
findAll(): Promise<any>
|
||||||
|
countAll(): Promise<number>
|
||||||
|
findOne(id: string): Promise<any>
|
||||||
|
create(data: CreateCategoryDto & {slug: string}): Promise<any>
|
||||||
|
update(id: string, data: UpdateCategoryDto & {slug?: string; updatedAt: Date}): Promise<any>
|
||||||
|
remove(id: string): Promise<any>
|
||||||
|
}
|
||||||
|
class CategoriesService {
|
||||||
|
constructor(categoriesRepository: CategoriesRepository, cacheManager: Cache):
|
||||||
|
logger: Logger
|
||||||
|
clearCategoriesCache(): Promise<void>
|
||||||
|
findAll(): Promise<any>
|
||||||
|
findOne(id: string): Promise<any>
|
||||||
|
create(data: CreateCategoryDto): Promise<any>
|
||||||
|
update(id: string, data: UpdateCategoryDto): Promise<any>
|
||||||
|
remove(id: string): Promise<any>
|
||||||
|
}
|
||||||
|
class CategoryInDb
|
||||||
|
class ClamScanner {
|
||||||
|
scanStream(stream: Readable): Promise<{isInfected: boolean, viruses: …
|
||||||
|
}
|
||||||
|
class CommonModule
|
||||||
|
class ContentInDb
|
||||||
|
class ContentType {
|
||||||
|
MEME:
|
||||||
|
GIF:
|
||||||
|
}
|
||||||
|
class ContentsController {
|
||||||
|
constructor(contentsService: ContentsService):
|
||||||
|
create(req: AuthenticatedRequest, createContentDto: CreateContentDto): Promise<any>
|
||||||
|
getUploadUrl(req: AuthenticatedRequest, fileName: string): Promise<{url: string, key: string}>
|
||||||
|
upload(req: AuthenticatedRequest, file: Express.Multer.File, uploadContentDto: UploadContentDto): Promise<any>
|
||||||
|
explore(req: AuthenticatedRequest, limit: number, offset: number, sort?: "trend" | "recent", tag?: string, category?: string, author?: string): Promise<{data: any, totalCount: any}>
|
||||||
|
trends(req: AuthenticatedRequest, limit: number, offset: number): Promise<{data: any, totalCount: any}>
|
||||||
|
recent(req: AuthenticatedRequest, limit: number, offset: number): Promise<{data: any, totalCount: any}>
|
||||||
|
findOne(idOrSlug: string, req: AuthenticatedRequest, res: Response): Promise<Response<any, Record<string, an…
|
||||||
|
incrementViews(id: string): Promise<void>
|
||||||
|
incrementUsage(id: string): Promise<void>
|
||||||
|
update(id: string, req: AuthenticatedRequest, updateContentDto: any): Promise<any>
|
||||||
|
remove(id: string, req: AuthenticatedRequest): Promise<any>
|
||||||
|
removeAdmin(id: string): Promise<any>
|
||||||
|
updateAdmin(id: string, updateContentDto: any): Promise<any>
|
||||||
|
}
|
||||||
|
class ContentsModule
|
||||||
|
class ContentsRepository {
|
||||||
|
constructor(databaseService: DatabaseService):
|
||||||
|
findAll(options: FindAllOptions): Promise<any>
|
||||||
|
create(data: NewContentInDb & {userId: string}, tagNames?: string[]): Promise<any>
|
||||||
|
findOne(idOrSlug: string, userId?: string): Promise<any>
|
||||||
|
count(options: {tag?: string; category?: string; author?: string; query?: string; favoritesOnly?: boolean; userId?: string}): Promise<number>
|
||||||
|
incrementViews(id: string): Promise<void>
|
||||||
|
incrementUsage(id: string): Promise<void>
|
||||||
|
softDelete(id: string, userId: string): Promise<any>
|
||||||
|
softDeleteAdmin(id: string): Promise<any>
|
||||||
|
update(id: string, data: Partial<typeof contents.$inferInsert>): Promise<any>
|
||||||
|
findBySlug(slug: string): Promise<any>
|
||||||
|
purgeSoftDeleted(before: Date): Promise<any>
|
||||||
|
}
|
||||||
|
class ContentsService {
|
||||||
|
constructor(contentsRepository: ContentsRepository, s3Service: IStorageService, mediaService: IMediaService, configService: ConfigService, cacheManager: Cache):
|
||||||
|
logger: Logger
|
||||||
|
clearContentsCache(): Promise<void>
|
||||||
|
getUploadUrl(userId: string, fileName: string): Promise<{url: string, key: string}>
|
||||||
|
uploadAndProcess(userId: string, file: Express.Multer.File, data: UploadContentDto): Promise<any>
|
||||||
|
findAll(options: {limit: number; offset: number; sortBy?: "trend" | "recent"; tag?: string; category?: string; author?: string; query?: string; favoritesOnly?: boolean; userId?: string}): Promise<{data: any, totalCount: any}>
|
||||||
|
create(userId: string, data: CreateContentDto): Promise<any>
|
||||||
|
incrementViews(id: string): Promise<void>
|
||||||
|
incrementUsage(id: string): Promise<void>
|
||||||
|
remove(id: string, userId: string): Promise<any>
|
||||||
|
removeAdmin(id: string): Promise<any>
|
||||||
|
updateAdmin(id: string, data: any): Promise<any>
|
||||||
|
update(id: string, userId: string, data: any): Promise<any>
|
||||||
|
findOne(idOrSlug: string, userId?: string): Promise<any>
|
||||||
|
generateBotHtml(content: {title: string; storageKey: string}): string
|
||||||
|
generateSlug(text: string): string
|
||||||
|
ensureUniqueSlug(title: string): Promise<string>
|
||||||
|
}
|
||||||
|
class CrawlerDetectionMiddleware {
|
||||||
|
logger: Logger
|
||||||
|
SUSPICIOUS_PATTERNS: RegExp[]
|
||||||
|
BOT_USER_AGENTS: RegExp[]
|
||||||
|
use(req: Request, res: Response, next: NextFunction): void
|
||||||
|
}
|
||||||
|
class CreateApiKeyDto {
|
||||||
|
name: string
|
||||||
|
expiresAt: string
|
||||||
|
}
|
||||||
|
class CreateCategoryDto {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
iconUrl: string
|
||||||
|
}
|
||||||
|
class CreateContentDto {
|
||||||
|
type: "meme" | "gif"
|
||||||
|
title: string
|
||||||
|
storageKey: string
|
||||||
|
mimeType: string
|
||||||
|
fileSize: number
|
||||||
|
categoryId: string
|
||||||
|
tags: string[]
|
||||||
|
}
|
||||||
|
class CreateReportDto {
|
||||||
|
contentId: string
|
||||||
|
tagId: string
|
||||||
|
reason: "inappropriate" | "spam" | "copyright" …
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
class CryptoModule
|
||||||
|
class CryptoService {
|
||||||
|
constructor(hashingService: HashingService, jwtService: JwtService, encryptionService: EncryptionService, postQuantumService: PostQuantumService):
|
||||||
|
hashEmail(email: string): Promise<string>
|
||||||
|
hashIp(ip: string): Promise<string>
|
||||||
|
getPgpEncryptionKey(): string
|
||||||
|
hashPassword(password: string): Promise<string>
|
||||||
|
verifyPassword(password: string, hash: string): Promise<boolean>
|
||||||
|
generateJwt(payload: jose.JWTPayload, expiresIn?: string): Promise<string>
|
||||||
|
verifyJwt(token: string): Promise<T>
|
||||||
|
encryptContent(content: string): Promise<string>
|
||||||
|
decryptContent(jwe: string): Promise<string>
|
||||||
|
signContent(content: string): Promise<string>
|
||||||
|
verifyContentSignature(jws: string): Promise<string>
|
||||||
|
generatePostQuantumKeyPair(): {publicKey: Uint8Array<ArrayBufferLike>…
|
||||||
|
encapsulate(publicKey: Uint8Array): {cipherText: Uint8Array, sharedSecret: …
|
||||||
|
decapsulate(cipherText: Uint8Array, secretKey: Uint8Array): Uint8Array<ArrayBufferLike>
|
||||||
|
}
|
||||||
|
class DatabaseModule
|
||||||
|
class DatabaseService {
|
||||||
|
constructor(configService: ConfigService):
|
||||||
|
logger: Logger
|
||||||
|
pool: Pool
|
||||||
|
db: ReturnType<typeof drizzle>
|
||||||
|
onModuleInit(): Promise<void>
|
||||||
|
onModuleDestroy(): Promise<void>
|
||||||
|
getDatabaseConnectionString(): string
|
||||||
|
}
|
||||||
|
class EncryptionService {
|
||||||
|
constructor(configService: ConfigService):
|
||||||
|
logger: Logger
|
||||||
|
jwtSecret: Uint8Array
|
||||||
|
encryptionKey: Uint8Array
|
||||||
|
encryptContent(content: string): Promise<string>
|
||||||
|
decryptContent(jwe: string): Promise<string>
|
||||||
|
signContent(content: string): Promise<string>
|
||||||
|
verifyContentSignature(jws: string): Promise<string>
|
||||||
|
getPgpEncryptionKey(): string
|
||||||
|
}
|
||||||
|
class Env
|
||||||
|
class FavoriteInDb
|
||||||
|
class FavoritesController {
|
||||||
|
constructor(favoritesService: FavoritesService):
|
||||||
|
add(req: AuthenticatedRequest, contentId: string): Promise<any>
|
||||||
|
remove(req: AuthenticatedRequest, contentId: string): Promise<any>
|
||||||
|
list(req: AuthenticatedRequest, limit: number, offset: number): Promise<any>
|
||||||
|
}
|
||||||
|
class FavoritesModule
|
||||||
|
class FavoritesRepository {
|
||||||
|
constructor(databaseService: DatabaseService):
|
||||||
|
findContentById(contentId: string): Promise<any>
|
||||||
|
add(userId: string, contentId: string): Promise<any>
|
||||||
|
remove(userId: string, contentId: string): Promise<any>
|
||||||
|
findByUserId(userId: string, limit: number, offset: number): Promise<any>
|
||||||
|
}
|
||||||
|
class FavoritesService {
|
||||||
|
constructor(favoritesRepository: FavoritesRepository):
|
||||||
|
logger: Logger
|
||||||
|
addFavorite(userId: string, contentId: string): Promise<any>
|
||||||
|
removeFavorite(userId: string, contentId: string): Promise<any>
|
||||||
|
getUserFavorites(userId: string, limit: number, offset: number): Promise<any>
|
||||||
|
}
|
||||||
|
class FindAllOptions {
|
||||||
|
limit: number
|
||||||
|
offset: number
|
||||||
|
sortBy: "trend" | "recent"
|
||||||
|
tag: string
|
||||||
|
category: string
|
||||||
|
author: string
|
||||||
|
query: string
|
||||||
|
favoritesOnly: boolean
|
||||||
|
userId: string
|
||||||
|
}
|
||||||
|
class HTTPLoggerMiddleware {
|
||||||
|
logger: Logger
|
||||||
|
use(request: Request, response: Response, next: NextFunction): void
|
||||||
|
}
|
||||||
|
class HashingService {
|
||||||
|
hashEmail(email: string): Promise<string>
|
||||||
|
hashIp(ip: string): Promise<string>
|
||||||
|
hashSha256(text: string): Promise<string>
|
||||||
|
hashPassword(password: string): Promise<string>
|
||||||
|
verifyPassword(password: string, hash: string): Promise<boolean>
|
||||||
|
}
|
||||||
|
class HealthController {
|
||||||
|
constructor(databaseService: DatabaseService, cacheManager: Cache):
|
||||||
|
check(): Promise<any>
|
||||||
|
}
|
||||||
|
class IMailService {
|
||||||
|
sendEmailValidation(email: string, token: string): Promise<void>
|
||||||
|
sendPasswordReset(email: string, token: string): Promise<void>
|
||||||
|
}
|
||||||
|
class IMediaProcessorStrategy {
|
||||||
|
canHandle(mimeType: string): boolean
|
||||||
|
process(buffer: Buffer, options?: Record<string, unknown>): Promise<MediaProcessingResult>
|
||||||
|
}
|
||||||
|
class IMediaService {
|
||||||
|
scanFile(buffer: Buffer, filename: string): Promise<ScanResult>
|
||||||
|
processImage(buffer: Buffer, format?: "webp" | "avif", resize?: {width?: number; height?: number}): Promise<MediaProcessingResult>
|
||||||
|
processVideo(buffer: Buffer, format?: "webm" | "av1"): Promise<MediaProcessingResult>
|
||||||
|
}
|
||||||
|
class IStorageService {
|
||||||
|
uploadFile(fileName: string, file: Buffer, mimeType: string, metaData?: Record<string, string>, bucketName?: string): Promise<string>
|
||||||
|
getFile(fileName: string, bucketName?: string): Promise<Readable>
|
||||||
|
getFileUrl(fileName: string, expiry?: number, bucketName?: string): Promise<string>
|
||||||
|
getUploadUrl(fileName: string, expiry?: number, bucketName?: string): Promise<string>
|
||||||
|
deleteFile(fileName: string, bucketName?: string): Promise<void>
|
||||||
|
getFileInfo(fileName: string, bucketName?: string): Promise<unknown>
|
||||||
|
moveFile(sourceFileName: string, destinationFileName: string, sourceBucketName?: string, destinationBucketName?: string): Promise<string>
|
||||||
|
getPublicUrl(storageKey: string): string
|
||||||
|
}
|
||||||
|
class ImageProcessorStrategy {
|
||||||
|
logger: Logger
|
||||||
|
canHandle(mimeType: string): boolean
|
||||||
|
process(buffer: Buffer, options?: {format: "webp" | "avif"; resize?: {width?: number; height?: number}}): Promise<MediaProcessingResult>
|
||||||
|
}
|
||||||
|
class JwtService {
|
||||||
|
constructor(configService: ConfigService):
|
||||||
|
logger: Logger
|
||||||
|
jwtSecret: Uint8Array
|
||||||
|
generateJwt(payload: jose.JWTPayload, expiresIn?: string): Promise<string>
|
||||||
|
verifyJwt(token: string): Promise<T>
|
||||||
|
}
|
||||||
|
class LoginDto {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
class MailModule
|
||||||
|
class MailService {
|
||||||
|
constructor(mailerService: MailerService, configService: ConfigService):
|
||||||
|
logger: Logger
|
||||||
|
domain: string
|
||||||
|
sendEmailValidation(email: string, token: string): Promise<void>
|
||||||
|
sendPasswordReset(email: string, token: string): Promise<void>
|
||||||
|
}
|
||||||
|
class MediaController {
|
||||||
|
constructor(s3Service: S3Service):
|
||||||
|
logger: Logger
|
||||||
|
getFile(path: string, res: Response): Promise<void>
|
||||||
|
}
|
||||||
|
class MediaModule
|
||||||
|
class MediaProcessingResult {
|
||||||
|
buffer: Buffer
|
||||||
|
mimeType: string
|
||||||
|
extension: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
class MediaProcessingResult {
|
||||||
|
buffer: Buffer
|
||||||
|
mimeType: string
|
||||||
|
extension: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
class MediaService {
|
||||||
|
constructor(configService: ConfigService, imageProcessor: ImageProcessorStrategy, videoProcessor: VideoProcessorStrategy):
|
||||||
|
logger: Logger
|
||||||
|
clamscan: ClamScanner | null
|
||||||
|
isClamAvInitialized: boolean
|
||||||
|
initClamScan(): Promise<void>
|
||||||
|
scanFile(buffer: Buffer, filename: string): Promise<ScanResult>
|
||||||
|
processImage(buffer: Buffer, format?: "webp" | "avif", resize?: {width?: number; height?: number}): Promise<MediaProcessingResult>
|
||||||
|
processVideo(buffer: Buffer, format?: "webm" | "av1"): Promise<MediaProcessingResult>
|
||||||
|
}
|
||||||
|
class NewAuditLogInDb
|
||||||
|
class NewCategoryInDb
|
||||||
|
class NewContentInDb
|
||||||
|
class NewFavoriteInDb
|
||||||
|
class NewReportInDb
|
||||||
|
class NewTagInDb
|
||||||
|
class NewUserInDb
|
||||||
|
class OptionalAuthGuard {
|
||||||
|
constructor(jwtService: JwtService, configService: ConfigService):
|
||||||
|
canActivate(context: ExecutionContext): Promise<boolean>
|
||||||
|
}
|
||||||
|
class PostQuantumService {
|
||||||
|
generatePostQuantumKeyPair(): {publicKey: Uint8Array<ArrayBufferLike>…
|
||||||
|
encapsulate(publicKey: Uint8Array): {cipherText: Uint8Array, sharedSecret: …
|
||||||
|
decapsulate(cipherText: Uint8Array, secretKey: Uint8Array): Uint8Array<ArrayBufferLike>
|
||||||
|
}
|
||||||
|
class PurgeService {
|
||||||
|
constructor(sessionsRepository: SessionsRepository, reportsRepository: ReportsRepository, usersRepository: UsersRepository, contentsRepository: ContentsRepository):
|
||||||
|
logger: Logger
|
||||||
|
purgeExpiredData(): Promise<void>
|
||||||
|
}
|
||||||
|
class RbacRepository {
|
||||||
|
constructor(databaseService: DatabaseService):
|
||||||
|
findRolesByUserId(userId: string): Promise<any>
|
||||||
|
findPermissionsByUserId(userId: string): Promise<any[]>
|
||||||
|
countRoles(): Promise<number>
|
||||||
|
countAdmins(): Promise<number>
|
||||||
|
createRole(name: string, slug: string, description?: string): Promise<any>
|
||||||
|
assignRole(userId: string, roleSlug: string): Promise<any>
|
||||||
|
}
|
||||||
|
class RbacService {
|
||||||
|
constructor(rbacRepository: RbacRepository):
|
||||||
|
logger: Logger
|
||||||
|
onApplicationBootstrap(): Promise<void>
|
||||||
|
seedRoles(): Promise<void>
|
||||||
|
getUserRoles(userId: string): Promise<any>
|
||||||
|
getUserPermissions(userId: string): Promise<any[]>
|
||||||
|
countAdmins(): Promise<number>
|
||||||
|
assignRoleToUser(userId: string, roleSlug: string): Promise<any>
|
||||||
|
}
|
||||||
|
class RefreshDto {
|
||||||
|
refresh_token: string
|
||||||
|
}
|
||||||
|
class RegisterDto {
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
class ReportInDb
|
||||||
|
class ReportReason {
|
||||||
|
INAPPROPRIATE:
|
||||||
|
SPAM:
|
||||||
|
COPYRIGHT:
|
||||||
|
OTHER:
|
||||||
|
}
|
||||||
|
class ReportStatus {
|
||||||
|
PENDING:
|
||||||
|
REVIEWED:
|
||||||
|
RESOLVED:
|
||||||
|
DISMISSED:
|
||||||
|
}
|
||||||
|
class ReportsController {
|
||||||
|
constructor(reportsService: ReportsService):
|
||||||
|
create(req: AuthenticatedRequest, createReportDto: CreateReportDto): Promise<any>
|
||||||
|
findAll(limit: number, offset: number): Promise<any>
|
||||||
|
updateStatus(id: string, updateReportStatusDto: UpdateReportStatusDto): Promise<any>
|
||||||
|
}
|
||||||
|
class ReportsModule
|
||||||
|
class ReportsRepository {
|
||||||
|
constructor(databaseService: DatabaseService):
|
||||||
|
create(data: {reporterId: string; contentId?: string; tagId?: string; reason: "inappropriate" | "spam" | "copyright" | "other"; description?: string}): Promise<any>
|
||||||
|
findAll(limit: number, offset: number): Promise<any>
|
||||||
|
updateStatus(id: string, status: "pending" | "reviewed" | "resolved" | "dismissed"): Promise<any>
|
||||||
|
purgeObsolete(now: Date): Promise<any>
|
||||||
|
}
|
||||||
|
class ReportsService {
|
||||||
|
constructor(reportsRepository: ReportsRepository):
|
||||||
|
logger: Logger
|
||||||
|
create(reporterId: string, data: CreateReportDto): Promise<any>
|
||||||
|
findAll(limit: number, offset: number): Promise<any>
|
||||||
|
updateStatus(id: string, status: "pending" | "reviewed" | "resolved" | "dismissed"): Promise<any>
|
||||||
|
}
|
||||||
|
class RequestWithUser {
|
||||||
|
user: {sub?: string, username?: string, id?: …
|
||||||
|
}
|
||||||
|
class RolesGuard {
|
||||||
|
constructor(reflector: Reflector, rbacService: RbacService):
|
||||||
|
canActivate(context: ExecutionContext): Promise<boolean>
|
||||||
|
}
|
||||||
|
class S3Module
|
||||||
|
class S3Service {
|
||||||
|
constructor(configService: ConfigService):
|
||||||
|
logger: Logger
|
||||||
|
minioClient: Minio.Client
|
||||||
|
bucketName: string
|
||||||
|
onModuleInit(): Promise<void>
|
||||||
|
ensureBucketExists(bucketName: string): Promise<void>
|
||||||
|
uploadFile(fileName: string, file: Buffer, mimeType: string, metaData?: Minio.ItemBucketMetadata, bucketName?: string): Promise<string>
|
||||||
|
getFile(fileName: string, bucketName?: string): Promise<stream.Readable>
|
||||||
|
getFileUrl(fileName: string, expiry?: number, bucketName?: string): Promise<string>
|
||||||
|
getUploadUrl(fileName: string, expiry?: number, bucketName?: string): Promise<string>
|
||||||
|
deleteFile(fileName: string, bucketName?: string): Promise<void>
|
||||||
|
getFileInfo(fileName: string, bucketName?: string): Promise<BucketItemStat>
|
||||||
|
moveFile(sourceFileName: string, destinationFileName: string, sourceBucketName?: string, destinationBucketName?: string): Promise<string>
|
||||||
|
getPublicUrl(storageKey: string): string
|
||||||
|
}
|
||||||
|
class ScanResult {
|
||||||
|
isInfected: boolean
|
||||||
|
virusName: string
|
||||||
|
}
|
||||||
|
class ScanResult {
|
||||||
|
isInfected: boolean
|
||||||
|
virusName: string
|
||||||
|
}
|
||||||
|
class SessionData {
|
||||||
|
accessToken: string
|
||||||
|
refreshToken: string
|
||||||
|
userId: string
|
||||||
|
}
|
||||||
|
class SessionsModule
|
||||||
|
class SessionsRepository {
|
||||||
|
constructor(databaseService: DatabaseService):
|
||||||
|
create(data: {userId: string; refreshToken: string; userAgent?: string; ipHash?: string | null; expiresAt: Date}): Promise<any>
|
||||||
|
findValidByRefreshToken(refreshToken: string): Promise<any>
|
||||||
|
update(sessionId: string, data: Record<string, unknown>): Promise<any>
|
||||||
|
revoke(sessionId: string): Promise<void>
|
||||||
|
revokeAllByUserId(userId: string): Promise<void>
|
||||||
|
purgeExpired(now: Date): Promise<any>
|
||||||
|
}
|
||||||
|
class SessionsService {
|
||||||
|
constructor(sessionsRepository: SessionsRepository, hashingService: HashingService, jwtService: JwtService):
|
||||||
|
createSession(userId: string, userAgent?: string, ip?: string): Promise<any>
|
||||||
|
refreshSession(oldRefreshToken: string): Promise<any>
|
||||||
|
revokeSession(sessionId: string): Promise<void>
|
||||||
|
revokeAllUserSessions(userId: string): Promise<void>
|
||||||
|
}
|
||||||
|
class TagInDb
|
||||||
|
class TagsController {
|
||||||
|
constructor(tagsService: TagsService):
|
||||||
|
findAll(limit: number, offset: number, query?: string, sort?: "popular" | "recent"): Promise<any>
|
||||||
|
}
|
||||||
|
class TagsModule
|
||||||
|
class TagsRepository {
|
||||||
|
constructor(databaseService: DatabaseService):
|
||||||
|
findAll(options: {limit: number; offset: number; query?: string; sortBy?: "popular" | "recent"}): Promise<any>
|
||||||
|
}
|
||||||
|
class TagsService {
|
||||||
|
constructor(tagsRepository: TagsRepository):
|
||||||
|
logger: Logger
|
||||||
|
findAll(options: {limit: number; offset: number; query?: string; sortBy?: "popular" | "recent"}): Promise<any>
|
||||||
|
}
|
||||||
|
class UpdateCategoryDto
|
||||||
|
class UpdateConsentDto {
|
||||||
|
termsVersion: string
|
||||||
|
privacyVersion: string
|
||||||
|
}
|
||||||
|
class UpdateReportStatusDto {
|
||||||
|
status: "pending" | "reviewed" | "resolved" | "…
|
||||||
|
}
|
||||||
|
class UpdateUserDto {
|
||||||
|
displayName: string
|
||||||
|
bio: string
|
||||||
|
avatarUrl: string
|
||||||
|
status: "active" | "verification" | "suspended"…
|
||||||
|
role: string
|
||||||
|
}
|
||||||
|
class UploadContentDto {
|
||||||
|
type: "meme" | "gif"
|
||||||
|
title: string
|
||||||
|
categoryId: string
|
||||||
|
tags: string[]
|
||||||
|
}
|
||||||
|
class UserInDb
|
||||||
|
class UsersController {
|
||||||
|
constructor(usersService: UsersService, authService: AuthService):
|
||||||
|
findAll(limit: number, offset: number): Promise<{data: any, totalCount: any}>
|
||||||
|
findPublicProfile(username: string): Promise<any>
|
||||||
|
findMe(req: AuthenticatedRequest): Promise<any>
|
||||||
|
exportMe(req: AuthenticatedRequest): Promise<null | {profile: any, contents:…
|
||||||
|
updateMe(req: AuthenticatedRequest, updateUserDto: UpdateUserDto): Promise<any>
|
||||||
|
updateAvatar(req: AuthenticatedRequest, file: Express.Multer.File): Promise<any>
|
||||||
|
updateConsent(req: AuthenticatedRequest, consentDto: UpdateConsentDto): Promise<any>
|
||||||
|
removeMe(req: AuthenticatedRequest): Promise<any>
|
||||||
|
removeAdmin(uuid: string): Promise<any>
|
||||||
|
updateAdmin(uuid: string, updateUserDto: UpdateUserDto): Promise<any>
|
||||||
|
setup2fa(req: AuthenticatedRequest): Promise<{secret: string, qrCodeDataUrl:…
|
||||||
|
enable2fa(req: AuthenticatedRequest, token: string): Promise<{message: string}>
|
||||||
|
disable2fa(req: AuthenticatedRequest, token: string): Promise<{message: string}>
|
||||||
|
}
|
||||||
|
class UsersModule
|
||||||
|
class UsersRepository {
|
||||||
|
constructor(databaseService: DatabaseService):
|
||||||
|
create(data: {username: string; email: string; passwordHash: string; emailHash: string}): Promise<any>
|
||||||
|
findByEmailHash(emailHash: string): Promise<any>
|
||||||
|
findOneWithPrivateData(uuid: string): Promise<any>
|
||||||
|
countAll(): Promise<number>
|
||||||
|
findAll(limit: number, offset: number): Promise<any>
|
||||||
|
findByUsername(username: string): Promise<any>
|
||||||
|
findOne(uuid: string): Promise<any>
|
||||||
|
update(uuid: string, data: Partial<typeof users.$inferInsert>): Promise<any>
|
||||||
|
getTwoFactorSecret(uuid: string): Promise<any>
|
||||||
|
getUserContents(uuid: string): Promise<any>
|
||||||
|
getUserFavorites(uuid: string): Promise<any>
|
||||||
|
softDeleteUserAndContents(uuid: string): Promise<any>
|
||||||
|
purgeDeleted(before: Date): Promise<any>
|
||||||
|
}
|
||||||
|
class UsersService {
|
||||||
|
constructor(usersRepository: UsersRepository, cacheManager: Cache, rbacService: RbacService, mediaService: IMediaService, s3Service: IStorageService):
|
||||||
|
logger: Logger
|
||||||
|
clearUserCache(username?: string): Promise<void>
|
||||||
|
create(data: {username: string; email: string; passwordHash: string; emailHash: string}): Promise<any>
|
||||||
|
findByEmailHash(emailHash: string): Promise<any>
|
||||||
|
findOneWithPrivateData(uuid: string): Promise<any>
|
||||||
|
findAll(limit: number, offset: number): Promise<{data: any, totalCount: any}>
|
||||||
|
findPublicProfile(username: string): Promise<any>
|
||||||
|
findOne(uuid: string): Promise<any>
|
||||||
|
update(uuid: string, data: UpdateUserDto): Promise<any>
|
||||||
|
updateAvatar(uuid: string, file: Express.Multer.File): Promise<any>
|
||||||
|
updateConsent(uuid: string, termsVersion: string, privacyVersion: string): Promise<any>
|
||||||
|
setTwoFactorSecret(uuid: string, secret: string): Promise<any>
|
||||||
|
toggleTwoFactor(uuid: string, enabled: boolean): Promise<any>
|
||||||
|
getTwoFactorSecret(uuid: string): Promise<string | null>
|
||||||
|
exportUserData(uuid: string): Promise<null | {profile: any, contents:…
|
||||||
|
remove(uuid: string): Promise<any>
|
||||||
|
}
|
||||||
|
class Verify2faDto {
|
||||||
|
userId: string
|
||||||
|
token: string
|
||||||
|
}
|
||||||
|
class VideoProcessorStrategy {
|
||||||
|
logger: Logger
|
||||||
|
canHandle(mimeType: string): boolean
|
||||||
|
process(buffer: Buffer, options?: {format: "webm" | "av1"}): Promise<MediaProcessingResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
AdminController -[#595959,dashed]-> AdminService
|
||||||
|
AdminService -[#595959,dashed]-> CategoriesRepository
|
||||||
|
AdminService -[#595959,dashed]-> ContentsRepository
|
||||||
|
AdminService -[#595959,dashed]-> UsersRepository
|
||||||
|
AllExceptionsFilter -[#595959,dashed]-> RequestWithUser
|
||||||
|
ApiKeysController -[#595959,dashed]-> ApiKeysService
|
||||||
|
ApiKeysController -[#595959,dashed]-> AuthenticatedRequest
|
||||||
|
ApiKeysController -[#595959,dashed]-> CreateApiKeyDto
|
||||||
|
ApiKeysRepository -[#595959,dashed]-> DatabaseService
|
||||||
|
ApiKeysService -[#595959,dashed]-> ApiKeysRepository
|
||||||
|
ApiKeysService -[#595959,dashed]-> ApiKeysService
|
||||||
|
ApiKeysService -[#595959,dashed]-> HashingService
|
||||||
|
AppController -[#595959,dashed]-> AppService
|
||||||
|
AppModule -[#595959,dashed]-> CrawlerDetectionMiddleware
|
||||||
|
AppModule -[#595959,dashed]-> HTTPLoggerMiddleware
|
||||||
|
AuthController -[#595959,dashed]-> AuthService
|
||||||
|
AuthController -[#595959,dashed]-> BootstrapService
|
||||||
|
AuthController -[#595959,dashed]-> LoginDto
|
||||||
|
AuthController -[#595959,dashed]-> RegisterDto
|
||||||
|
AuthController -[#595959,dashed]-> SessionData
|
||||||
|
AuthController -[#595959,dashed]-> Verify2faDto
|
||||||
|
AuthGuard -[#595959,dashed]-> JwtService
|
||||||
|
AuthGuard -[#595959,dashed]-> SessionData
|
||||||
|
AuthService -[#595959,dashed]-> AuthService
|
||||||
|
AuthService -[#595959,dashed]-> HashingService
|
||||||
|
AuthService -[#595959,dashed]-> JwtService
|
||||||
|
AuthService -[#595959,dashed]-> LoginDto
|
||||||
|
AuthService -[#595959,dashed]-> RegisterDto
|
||||||
|
AuthService -[#595959,dashed]-> SessionsService
|
||||||
|
AuthService -[#595959,dashed]-> UsersService
|
||||||
|
BootstrapService -[#595959,dashed]-> BootstrapService
|
||||||
|
BootstrapService -[#595959,dashed]-> RbacService
|
||||||
|
BootstrapService -[#595959,dashed]-> UsersService
|
||||||
|
CategoriesController -[#595959,dashed]-> AuthGuard
|
||||||
|
CategoriesController -[#595959,dashed]-> CategoriesService
|
||||||
|
CategoriesController -[#595959,dashed]-> CreateCategoryDto
|
||||||
|
CategoriesController -[#595959,dashed]-> RolesGuard
|
||||||
|
CategoriesController -[#595959,dashed]-> UpdateCategoryDto
|
||||||
|
CategoriesRepository -[#595959,dashed]-> CreateCategoryDto
|
||||||
|
CategoriesRepository -[#595959,dashed]-> DatabaseService
|
||||||
|
CategoriesRepository -[#595959,dashed]-> UpdateCategoryDto
|
||||||
|
CategoriesService -[#595959,dashed]-> CategoriesRepository
|
||||||
|
CategoriesService -[#595959,dashed]-> CategoriesService
|
||||||
|
CategoriesService -[#595959,dashed]-> CreateCategoryDto
|
||||||
|
CategoriesService -[#595959,dashed]-> UpdateCategoryDto
|
||||||
|
ContentsController -[#595959,dashed]-> AuthGuard
|
||||||
|
ContentsController -[#595959,dashed]-> AuthenticatedRequest
|
||||||
|
ContentsController -[#595959,dashed]-> ContentsService
|
||||||
|
ContentsController -[#595959,dashed]-> CreateContentDto
|
||||||
|
ContentsController -[#595959,dashed]-> OptionalAuthGuard
|
||||||
|
ContentsController -[#595959,dashed]-> RolesGuard
|
||||||
|
ContentsController -[#595959,dashed]-> UploadContentDto
|
||||||
|
ContentsRepository -[#595959,dashed]-> DatabaseService
|
||||||
|
ContentsRepository -[#595959,dashed]-> FindAllOptions
|
||||||
|
ContentsRepository -[#595959,dashed]-> NewContentInDb
|
||||||
|
ContentsService -[#595959,dashed]-> ContentsRepository
|
||||||
|
ContentsService -[#595959,dashed]-> ContentsService
|
||||||
|
ContentsService -[#595959,dashed]-> CreateContentDto
|
||||||
|
ContentsService -[#595959,dashed]-> IMediaService
|
||||||
|
ContentsService -[#595959,dashed]-> IStorageService
|
||||||
|
ContentsService -[#595959,dashed]-> MediaProcessingResult
|
||||||
|
ContentsService -[#595959,dashed]-> MediaService
|
||||||
|
ContentsService -[#595959,dashed]-> S3Service
|
||||||
|
ContentsService -[#595959,dashed]-> UploadContentDto
|
||||||
|
CryptoService -[#595959,dashed]-> EncryptionService
|
||||||
|
CryptoService -[#595959,dashed]-> HashingService
|
||||||
|
CryptoService -[#595959,dashed]-> JwtService
|
||||||
|
CryptoService -[#595959,dashed]-> PostQuantumService
|
||||||
|
DatabaseService -[#595959,dashed]-> DatabaseService
|
||||||
|
EncryptionService -[#595959,dashed]-> EncryptionService
|
||||||
|
FavoritesController -[#595959,dashed]-> AuthenticatedRequest
|
||||||
|
FavoritesController -[#595959,dashed]-> FavoritesService
|
||||||
|
FavoritesRepository -[#595959,dashed]-> DatabaseService
|
||||||
|
FavoritesService -[#595959,dashed]-> FavoritesRepository
|
||||||
|
FavoritesService -[#595959,dashed]-> FavoritesService
|
||||||
|
HealthController -[#595959,dashed]-> DatabaseService
|
||||||
|
IMediaProcessorStrategy -[#595959,dashed]-> MediaProcessingResult
|
||||||
|
IMediaService -[#595959,dashed]-> MediaProcessingResult
|
||||||
|
IMediaService -[#595959,dashed]-> ScanResult
|
||||||
|
ImageProcessorStrategy -[#008200,dashed]-^ IMediaProcessorStrategy
|
||||||
|
ImageProcessorStrategy -[#595959,dashed]-> ImageProcessorStrategy
|
||||||
|
ImageProcessorStrategy -[#595959,dashed]-> MediaProcessingResult
|
||||||
|
JwtService -[#595959,dashed]-> JwtService
|
||||||
|
MailService -[#008200,dashed]-^ IMailService
|
||||||
|
MailService -[#595959,dashed]-> MailService
|
||||||
|
MediaController -[#595959,dashed]-> MediaController
|
||||||
|
MediaController -[#595959,dashed]-> S3Service
|
||||||
|
MediaService -[#595959,dashed]-> ClamScanner
|
||||||
|
MediaService -[#008200,dashed]-^ IMediaService
|
||||||
|
MediaService -[#595959,dashed]-> ImageProcessorStrategy
|
||||||
|
MediaService -[#595959,dashed]-> MediaProcessingResult
|
||||||
|
MediaService -[#595959,dashed]-> MediaService
|
||||||
|
MediaService -[#595959,dashed]-> ScanResult
|
||||||
|
MediaService -[#595959,dashed]-> VideoProcessorStrategy
|
||||||
|
OptionalAuthGuard -[#595959,dashed]-> JwtService
|
||||||
|
OptionalAuthGuard -[#595959,dashed]-> SessionData
|
||||||
|
PurgeService -[#595959,dashed]-> ContentsRepository
|
||||||
|
PurgeService -[#595959,dashed]-> PurgeService
|
||||||
|
PurgeService -[#595959,dashed]-> ReportsRepository
|
||||||
|
PurgeService -[#595959,dashed]-> SessionsRepository
|
||||||
|
PurgeService -[#595959,dashed]-> UsersRepository
|
||||||
|
RbacRepository -[#595959,dashed]-> DatabaseService
|
||||||
|
RbacService -[#595959,dashed]-> RbacRepository
|
||||||
|
RbacService -[#595959,dashed]-> RbacService
|
||||||
|
ReportsController -[#595959,dashed]-> AuthGuard
|
||||||
|
ReportsController -[#595959,dashed]-> AuthenticatedRequest
|
||||||
|
ReportsController -[#595959,dashed]-> CreateReportDto
|
||||||
|
ReportsController -[#595959,dashed]-> ReportsService
|
||||||
|
ReportsController -[#595959,dashed]-> RolesGuard
|
||||||
|
ReportsController -[#595959,dashed]-> UpdateReportStatusDto
|
||||||
|
ReportsRepository -[#595959,dashed]-> DatabaseService
|
||||||
|
ReportsService -[#595959,dashed]-> CreateReportDto
|
||||||
|
ReportsService -[#595959,dashed]-> ReportsRepository
|
||||||
|
ReportsService -[#595959,dashed]-> ReportsService
|
||||||
|
RolesGuard -[#595959,dashed]-> RbacService
|
||||||
|
S3Service -[#008200,dashed]-^ IStorageService
|
||||||
|
S3Service -[#595959,dashed]-> S3Service
|
||||||
|
SessionsRepository -[#595959,dashed]-> DatabaseService
|
||||||
|
SessionsService -[#595959,dashed]-> HashingService
|
||||||
|
SessionsService -[#595959,dashed]-> JwtService
|
||||||
|
SessionsService -[#595959,dashed]-> SessionsRepository
|
||||||
|
TagsController -[#595959,dashed]-> TagsService
|
||||||
|
TagsRepository -[#595959,dashed]-> DatabaseService
|
||||||
|
TagsService -[#595959,dashed]-> TagsRepository
|
||||||
|
TagsService -[#595959,dashed]-> TagsService
|
||||||
|
UsersController -[#595959,dashed]-> AuthGuard
|
||||||
|
UsersController -[#595959,dashed]-> AuthService
|
||||||
|
UsersController -[#595959,dashed]-> AuthenticatedRequest
|
||||||
|
UsersController -[#595959,dashed]-> RolesGuard
|
||||||
|
UsersController -[#595959,dashed]-> UpdateConsentDto
|
||||||
|
UsersController -[#595959,dashed]-> UpdateUserDto
|
||||||
|
UsersController -[#595959,dashed]-> UsersService
|
||||||
|
UsersRepository -[#595959,dashed]-> DatabaseService
|
||||||
|
UsersService -[#595959,dashed]-> IMediaService
|
||||||
|
UsersService -[#595959,dashed]-> IStorageService
|
||||||
|
UsersService -[#595959,dashed]-> MediaService
|
||||||
|
UsersService -[#595959,dashed]-> RbacService
|
||||||
|
UsersService -[#595959,dashed]-> S3Service
|
||||||
|
UsersService -[#595959,dashed]-> UpdateUserDto
|
||||||
|
UsersService -[#595959,dashed]-> UsersRepository
|
||||||
|
UsersService -[#595959,dashed]-> UsersService
|
||||||
|
VideoProcessorStrategy -[#008200,dashed]-^ IMediaProcessorStrategy
|
||||||
|
VideoProcessorStrategy -[#595959,dashed]-> MediaProcessingResult
|
||||||
|
VideoProcessorStrategy -[#595959,dashed]-> VideoProcessorStrategy
|
||||||
|
@enduml
|
||||||
1
backend/.migrations/0007_melodic_synch.sql
Normal file
1
backend/.migrations/0007_melodic_synch.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TYPE "public"."content_type" ADD VALUE 'video';
|
||||||
1653
backend/.migrations/meta/0007_snapshot.json
Normal file
1653
backend/.migrations/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,13 @@
|
|||||||
"when": 1768423315172,
|
"when": 1768423315172,
|
||||||
"tag": "0006_friendly_adam_warlock",
|
"tag": "0006_friendly_adam_warlock",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1769605995410,
|
||||||
|
"tag": "0007_melodic_synch",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,8 @@ ENV PNPM_HOME="/pnpm"
|
|||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
RUN apk add --no-cache ffmpeg
|
||||||
|
|
||||||
FROM base AS build
|
FROM base AS build
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||||
|
|||||||
@@ -24,7 +24,8 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
"noUnknownAtRules": "off"
|
"noUnknownAtRules": "off",
|
||||||
|
"noExplicitAny": "off"
|
||||||
},
|
},
|
||||||
"style": {
|
"style": {
|
||||||
"useImportType": "off"
|
"useImportType": "off"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@memegoat/backend",
|
"name": "@memegoat/backend",
|
||||||
"version": "1.0.4",
|
"version": "1.5.6",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"lint": "biome check",
|
"lint": "biome check",
|
||||||
"lint:write": "biome check --write",
|
"lint:write": "biome check --write --unsafe",
|
||||||
"format": "biome format --write",
|
"format": "biome format --write",
|
||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"node_modules/(?!(.pnpm/)?(jose|@noble|uuid)/)"
|
"node_modules/(?!(.pnpm/)?(jose|@noble|uuid))"
|
||||||
],
|
],
|
||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)sx?$": "ts-jest"
|
"^.+\\.(t|j)sx?$": "ts-jest"
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ describe("ApiKeysRepository", () => {
|
|||||||
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
|
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
|
||||||
Object.defineProperty(obj, "then", {
|
Object.defineProperty(obj, "then", {
|
||||||
value: function (onFulfilled: (arg0: unknown) => void) {
|
value: function (onFulfilled: (arg0: unknown) => void) {
|
||||||
const result = (this as any).execute();
|
const result = (this as Record<string, unknown>).execute();
|
||||||
return Promise.resolve(result).then(onFulfilled);
|
return Promise.resolve(result).then(onFulfilled);
|
||||||
},
|
},
|
||||||
configurable: true,
|
configurable: true,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { ConfigService } from "@nestjs/config";
|
|||||||
import { Test, TestingModule } from "@nestjs/testing";
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import { AuthController } from "./auth.controller";
|
import { AuthController } from "./auth.controller";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
|
import { BootstrapService } from "./bootstrap.service";
|
||||||
|
|
||||||
jest.mock("iron-session", () => ({
|
jest.mock("iron-session", () => ({
|
||||||
getIronSession: jest.fn().mockResolvedValue({
|
getIronSession: jest.fn().mockResolvedValue({
|
||||||
@@ -44,6 +45,10 @@ describe("AuthController", () => {
|
|||||||
refresh: jest.fn(),
|
refresh: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockBootstrapService = {
|
||||||
|
consumeToken: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
const mockConfigService = {
|
const mockConfigService = {
|
||||||
get: jest
|
get: jest
|
||||||
.fn()
|
.fn()
|
||||||
@@ -55,6 +60,7 @@ describe("AuthController", () => {
|
|||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: AuthService, useValue: mockAuthService },
|
{ provide: AuthService, useValue: mockAuthService },
|
||||||
|
{ provide: BootstrapService, useValue: mockBootstrapService },
|
||||||
{ provide: ConfigService, useValue: mockConfigService },
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
@@ -75,7 +81,6 @@ describe("AuthController", () => {
|
|||||||
password: "password",
|
password: "password",
|
||||||
username: "test",
|
username: "test",
|
||||||
};
|
};
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: Necessary to avoid defining full DTO in test
|
|
||||||
await controller.register(dto as any);
|
await controller.register(dto as any);
|
||||||
expect(authService.register).toHaveBeenCalledWith(dto);
|
expect(authService.register).toHaveBeenCalledWith(dto);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import { Body, Controller, Headers, Post, Req, Res } from "@nestjs/common";
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Headers,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
} from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Throttle } from "@nestjs/throttler";
|
import { Throttle } from "@nestjs/throttler";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { getIronSession } from "iron-session";
|
import { getIronSession } from "iron-session";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
|
import { BootstrapService } from "./bootstrap.service";
|
||||||
import { LoginDto } from "./dto/login.dto";
|
import { LoginDto } from "./dto/login.dto";
|
||||||
import { RegisterDto } from "./dto/register.dto";
|
import { RegisterDto } from "./dto/register.dto";
|
||||||
import { Verify2faDto } from "./dto/verify-2fa.dto";
|
import { Verify2faDto } from "./dto/verify-2fa.dto";
|
||||||
@@ -13,6 +23,7 @@ import { getSessionOptions, SessionData } from "./session.config";
|
|||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
|
private readonly bootstrapService: BootstrapService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -120,4 +131,12 @@ export class AuthController {
|
|||||||
session.destroy();
|
session.destroy();
|
||||||
return res.json({ message: "User logged out" });
|
return res.json({ message: "User logged out" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("bootstrap-admin")
|
||||||
|
async bootstrapAdmin(
|
||||||
|
@Query("token") token: string,
|
||||||
|
@Query("username") username: string,
|
||||||
|
) {
|
||||||
|
return this.bootstrapService.consumeToken(token, username);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { SessionsModule } from "../sessions/sessions.module";
|
|||||||
import { UsersModule } from "../users/users.module";
|
import { UsersModule } from "../users/users.module";
|
||||||
import { AuthController } from "./auth.controller";
|
import { AuthController } from "./auth.controller";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
|
import { BootstrapService } from "./bootstrap.service";
|
||||||
import { AuthGuard } from "./guards/auth.guard";
|
import { AuthGuard } from "./guards/auth.guard";
|
||||||
import { OptionalAuthGuard } from "./guards/optional-auth.guard";
|
import { OptionalAuthGuard } from "./guards/optional-auth.guard";
|
||||||
import { RolesGuard } from "./guards/roles.guard";
|
import { RolesGuard } from "./guards/roles.guard";
|
||||||
@@ -15,6 +16,7 @@ import { RbacRepository } from "./repositories/rbac.repository";
|
|||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
RbacService,
|
RbacService,
|
||||||
|
BootstrapService,
|
||||||
RbacRepository,
|
RbacRepository,
|
||||||
AuthGuard,
|
AuthGuard,
|
||||||
OptionalAuthGuard,
|
OptionalAuthGuard,
|
||||||
|
|||||||
114
backend/src/auth/bootstrap.service.spec.ts
Normal file
114
backend/src/auth/bootstrap.service.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { UnauthorizedException } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { UsersService } from "../users/users.service";
|
||||||
|
import { BootstrapService } from "./bootstrap.service";
|
||||||
|
import { RbacService } from "./rbac.service";
|
||||||
|
|
||||||
|
describe("BootstrapService", () => {
|
||||||
|
let service: BootstrapService;
|
||||||
|
let rbacService: RbacService;
|
||||||
|
let _usersService: UsersService;
|
||||||
|
|
||||||
|
const mockRbacService = {
|
||||||
|
countAdmins: jest.fn(),
|
||||||
|
assignRoleToUser: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockUsersService = {
|
||||||
|
findPublicProfile: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockConfigService = {
|
||||||
|
get: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
BootstrapService,
|
||||||
|
{ provide: RbacService, useValue: mockRbacService },
|
||||||
|
{ provide: UsersService, useValue: mockUsersService },
|
||||||
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<BootstrapService>(BootstrapService);
|
||||||
|
rbacService = module.get<RbacService>(RbacService);
|
||||||
|
_usersService = module.get<UsersService>(UsersService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("onApplicationBootstrap", () => {
|
||||||
|
it("should generate a token if no admin exists", async () => {
|
||||||
|
mockRbacService.countAdmins.mockResolvedValue(0);
|
||||||
|
const generateTokenSpy = jest.spyOn(
|
||||||
|
service as any,
|
||||||
|
"generateBootstrapToken",
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
|
expect(rbacService.countAdmins).toHaveBeenCalled();
|
||||||
|
expect(generateTokenSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not generate a token if admin exists", async () => {
|
||||||
|
mockRbacService.countAdmins.mockResolvedValue(1);
|
||||||
|
const generateTokenSpy = jest.spyOn(
|
||||||
|
service as any,
|
||||||
|
"generateBootstrapToken",
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
|
expect(rbacService.countAdmins).toHaveBeenCalled();
|
||||||
|
expect(generateTokenSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("consumeToken", () => {
|
||||||
|
it("should throw UnauthorizedException if token is invalid", async () => {
|
||||||
|
mockRbacService.countAdmins.mockResolvedValue(0);
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
|
await expect(service.consumeToken("wrong-token", "user1")).rejects.toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw UnauthorizedException if user not found", async () => {
|
||||||
|
mockRbacService.countAdmins.mockResolvedValue(0);
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
const token = (service as any).bootstrapToken;
|
||||||
|
|
||||||
|
mockUsersService.findPublicProfile.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.consumeToken(token, "user1")).rejects.toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should assign admin role and invalidate token on success", async () => {
|
||||||
|
mockRbacService.countAdmins.mockResolvedValue(0);
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
const token = (service as any).bootstrapToken;
|
||||||
|
|
||||||
|
const mockUser = { uuid: "user-uuid", username: "user1" };
|
||||||
|
mockUsersService.findPublicProfile.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
const result = await service.consumeToken(token, "user1");
|
||||||
|
|
||||||
|
expect(rbacService.assignRoleToUser).toHaveBeenCalledWith(
|
||||||
|
"user-uuid",
|
||||||
|
"admin",
|
||||||
|
);
|
||||||
|
expect((service as any).bootstrapToken).toBeNull();
|
||||||
|
expect(result.message).toContain("user1 is now an administrator");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
67
backend/src/auth/bootstrap.service.ts
Normal file
67
backend/src/auth/bootstrap.service.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import * as crypto from "node:crypto";
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
OnApplicationBootstrap,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { UsersService } from "../users/users.service";
|
||||||
|
import { RbacService } from "./rbac.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BootstrapService implements OnApplicationBootstrap {
|
||||||
|
private readonly logger = new Logger(BootstrapService.name);
|
||||||
|
private bootstrapToken: string | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly rbacService: RbacService,
|
||||||
|
private readonly usersService: UsersService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onApplicationBootstrap() {
|
||||||
|
const adminCount = await this.rbacService.countAdmins();
|
||||||
|
if (adminCount === 0) {
|
||||||
|
this.generateBootstrapToken();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateBootstrapToken() {
|
||||||
|
this.bootstrapToken = crypto.randomBytes(32).toString("hex");
|
||||||
|
const domain = this.configService.get("DOMAIN_NAME") || "localhost";
|
||||||
|
const protocol = domain.includes("localhost") ? "http" : "https";
|
||||||
|
const url = `${protocol}://${domain}/auth/bootstrap-admin`;
|
||||||
|
|
||||||
|
this.logger.warn("SECURITY ALERT: No administrator found in database.");
|
||||||
|
this.logger.warn(
|
||||||
|
"To create the first administrator, use the following endpoint:",
|
||||||
|
);
|
||||||
|
this.logger.warn(
|
||||||
|
`Endpoint: GET ${url}?token=${this.bootstrapToken}&username=votre_nom_utilisateur`,
|
||||||
|
);
|
||||||
|
this.logger.warn(
|
||||||
|
'Exemple: curl -X GET "http://localhost/auth/bootstrap-admin?token=...&username=..."',
|
||||||
|
);
|
||||||
|
this.logger.warn("This token is one-time use only.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async consumeToken(token: string, username: string) {
|
||||||
|
if (!this.bootstrapToken || token !== this.bootstrapToken) {
|
||||||
|
throw new UnauthorizedException("Invalid or expired bootstrap token");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.usersService.findPublicProfile(username);
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException(`User ${username} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.rbacService.assignRoleToUser(user.uuid, "admin");
|
||||||
|
this.bootstrapToken = null; // One-time use
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`User ${username} has been promoted to administrator via bootstrap token.`,
|
||||||
|
);
|
||||||
|
return { message: `User ${username} is now an administrator` };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ describe("RbacService", () => {
|
|||||||
const mockRbacRepository = {
|
const mockRbacRepository = {
|
||||||
findRolesByUserId: jest.fn(),
|
findRolesByUserId: jest.fn(),
|
||||||
findPermissionsByUserId: jest.fn(),
|
findPermissionsByUserId: jest.fn(),
|
||||||
|
countRoles: jest.fn(),
|
||||||
|
createRole: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -58,4 +60,35 @@ describe("RbacService", () => {
|
|||||||
expect(repository.findPermissionsByUserId).toHaveBeenCalledWith(userId);
|
expect(repository.findPermissionsByUserId).toHaveBeenCalledWith(userId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("seedRoles", () => {
|
||||||
|
it("should be called on application bootstrap", async () => {
|
||||||
|
const seedRolesSpy = jest.spyOn(service, "seedRoles");
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
expect(seedRolesSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should seed roles if none exist", async () => {
|
||||||
|
mockRbacRepository.countRoles.mockResolvedValue(0);
|
||||||
|
|
||||||
|
await service.seedRoles();
|
||||||
|
|
||||||
|
expect(repository.countRoles).toHaveBeenCalled();
|
||||||
|
expect(repository.createRole).toHaveBeenCalledTimes(3);
|
||||||
|
expect(repository.createRole).toHaveBeenCalledWith(
|
||||||
|
"Administrator",
|
||||||
|
"admin",
|
||||||
|
"Full system access",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not seed roles if some already exist", async () => {
|
||||||
|
mockRbacRepository.countRoles.mockResolvedValue(3);
|
||||||
|
|
||||||
|
await service.seedRoles();
|
||||||
|
|
||||||
|
expect(repository.countRoles).toHaveBeenCalled();
|
||||||
|
expect(repository.createRole).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,53 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable, Logger, OnApplicationBootstrap } from "@nestjs/common";
|
||||||
import { RbacRepository } from "./repositories/rbac.repository";
|
import { RbacRepository } from "./repositories/rbac.repository";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RbacService {
|
export class RbacService implements OnApplicationBootstrap {
|
||||||
|
private readonly logger = new Logger(RbacService.name);
|
||||||
|
|
||||||
constructor(private readonly rbacRepository: RbacRepository) {}
|
constructor(private readonly rbacRepository: RbacRepository) {}
|
||||||
|
|
||||||
|
async onApplicationBootstrap() {
|
||||||
|
this.logger.log("RbacService initialized, checking roles...");
|
||||||
|
await this.seedRoles();
|
||||||
|
}
|
||||||
|
|
||||||
|
async seedRoles() {
|
||||||
|
try {
|
||||||
|
const count = await this.rbacRepository.countRoles();
|
||||||
|
if (count === 0) {
|
||||||
|
this.logger.log("No roles found, seeding default roles...");
|
||||||
|
const defaultRoles = [
|
||||||
|
{
|
||||||
|
name: "Administrator",
|
||||||
|
slug: "admin",
|
||||||
|
description: "Full system access",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Moderator",
|
||||||
|
slug: "moderator",
|
||||||
|
description: "Access to moderation tools",
|
||||||
|
},
|
||||||
|
{ name: "User", slug: "user", description: "Standard user access" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const role of defaultRoles) {
|
||||||
|
await this.rbacRepository.createRole(
|
||||||
|
role.name,
|
||||||
|
role.slug,
|
||||||
|
role.description,
|
||||||
|
);
|
||||||
|
this.logger.log(`Created role: ${role.slug}`);
|
||||||
|
}
|
||||||
|
this.logger.log("Default roles seeded successfully.");
|
||||||
|
} else {
|
||||||
|
this.logger.log(`${count} roles already exist, skipping seeding.`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Error during roles seeding:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getUserRoles(userId: string) {
|
async getUserRoles(userId: string) {
|
||||||
return this.rbacRepository.findRolesByUserId(userId);
|
return this.rbacRepository.findRolesByUserId(userId);
|
||||||
}
|
}
|
||||||
@@ -12,4 +55,12 @@ export class RbacService {
|
|||||||
async getUserPermissions(userId: string) {
|
async getUserPermissions(userId: string) {
|
||||||
return this.rbacRepository.findPermissionsByUserId(userId);
|
return this.rbacRepository.findPermissionsByUserId(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async countAdmins() {
|
||||||
|
return this.rbacRepository.countAdmins();
|
||||||
|
}
|
||||||
|
|
||||||
|
async assignRoleToUser(userId: string, roleSlug: string) {
|
||||||
|
return this.rbacRepository.assignRole(userId, roleSlug);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,4 +39,52 @@ export class RbacRepository {
|
|||||||
|
|
||||||
return Array.from(new Set(result.map((p) => p.slug)));
|
return Array.from(new Set(result.map((p) => p.slug)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async countRoles(): Promise<number> {
|
||||||
|
const result = await this.databaseService.db
|
||||||
|
.select({ count: roles.id })
|
||||||
|
.from(roles);
|
||||||
|
return result.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async countAdmins(): Promise<number> {
|
||||||
|
const result = await this.databaseService.db
|
||||||
|
.select({ count: usersToRoles.userId })
|
||||||
|
.from(usersToRoles)
|
||||||
|
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||||
|
.where(eq(roles.slug, "admin"));
|
||||||
|
return result.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRole(name: string, slug: string, description?: string) {
|
||||||
|
return this.databaseService.db
|
||||||
|
.insert(roles)
|
||||||
|
.values({
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
|
|
||||||
|
async assignRole(userId: string, roleSlug: string) {
|
||||||
|
const role = await this.databaseService.db
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(eq(roles.slug, roleSlug))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!role[0]) {
|
||||||
|
throw new Error(`Role with slug ${roleSlug} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.databaseService.db
|
||||||
|
.insert(usersToRoles)
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
roleId: role[0].id,
|
||||||
|
})
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ describe("CategoriesRepository", () => {
|
|||||||
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
|
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
|
||||||
Object.defineProperty(obj, "then", {
|
Object.defineProperty(obj, "then", {
|
||||||
value: function (onFulfilled: (arg0: unknown) => void) {
|
value: function (onFulfilled: (arg0: unknown) => void) {
|
||||||
const result = (this as any).execute();
|
const result = (this as Record<string, unknown>).execute();
|
||||||
return Promise.resolve(result).then(onFulfilled);
|
return Promise.resolve(result).then(onFulfilled);
|
||||||
},
|
},
|
||||||
configurable: true,
|
configurable: true,
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export const envSchema = z.object({
|
|||||||
// Media Limits
|
// Media Limits
|
||||||
MAX_IMAGE_SIZE_KB: z.coerce.number().default(512),
|
MAX_IMAGE_SIZE_KB: z.coerce.number().default(512),
|
||||||
MAX_GIF_SIZE_KB: z.coerce.number().default(1024),
|
MAX_GIF_SIZE_KB: z.coerce.number().default(1024),
|
||||||
|
MAX_VIDEO_SIZE_KB: z.coerce.number().default(10240),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Env = z.infer<typeof envSchema>;
|
export type Env = z.infer<typeof envSchema>;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
ParseBoolPipe,
|
ParseBoolPipe,
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Req,
|
Req,
|
||||||
@@ -173,6 +174,16 @@ export class ContentsController {
|
|||||||
return this.contentsService.incrementUsage(id);
|
return this.contentsService.incrementUsage(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch(":id")
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
update(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Body() updateContentDto: any,
|
||||||
|
) {
|
||||||
|
return this.contentsService.update(id, req.user.sub, updateContentDto);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(":id")
|
@Delete(":id")
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
remove(@Param("id") id: string, @Req() req: AuthenticatedRequest) {
|
remove(@Param("id") id: string, @Req() req: AuthenticatedRequest) {
|
||||||
@@ -185,4 +196,11 @@ export class ContentsController {
|
|||||||
removeAdmin(@Param("id") id: string) {
|
removeAdmin(@Param("id") id: string) {
|
||||||
return this.contentsService.removeAdmin(id);
|
return this.contentsService.removeAdmin(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch(":id/admin")
|
||||||
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
|
@Roles("admin")
|
||||||
|
updateAdmin(@Param("id") id: string, @Body() updateContentDto: any) {
|
||||||
|
return this.contentsService.updateAdmin(id, updateContentDto);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,22 +55,31 @@ export class ContentsService {
|
|||||||
"image/webp",
|
"image/webp",
|
||||||
"image/gif",
|
"image/gif",
|
||||||
"video/webm",
|
"video/webm",
|
||||||
|
"video/mp4",
|
||||||
|
"video/quicktime",
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!allowedMimeTypes.includes(file.mimetype)) {
|
if (!allowedMimeTypes.includes(file.mimetype)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Format de fichier non supporté. Formats acceptés: png, jpeg, jpg, webp, webm, gif.",
|
"Format de fichier non supporté. Formats acceptés: png, jpeg, jpg, webp, webm, mp4, mov, gif.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isGif = file.mimetype === "image/gif";
|
const isGif = file.mimetype === "image/gif";
|
||||||
const maxSizeKb = isGif
|
const isVideo = file.mimetype.startsWith("video/");
|
||||||
? this.configService.get<number>("MAX_GIF_SIZE_KB", 1024)
|
let maxSizeKb: number;
|
||||||
: this.configService.get<number>("MAX_IMAGE_SIZE_KB", 512);
|
|
||||||
|
if (isGif) {
|
||||||
|
maxSizeKb = this.configService.get<number>("MAX_GIF_SIZE_KB", 1024);
|
||||||
|
} else if (isVideo) {
|
||||||
|
maxSizeKb = this.configService.get<number>("MAX_VIDEO_SIZE_KB", 10240);
|
||||||
|
} else {
|
||||||
|
maxSizeKb = this.configService.get<number>("MAX_IMAGE_SIZE_KB", 512);
|
||||||
|
}
|
||||||
|
|
||||||
if (file.size > maxSizeKb * 1024) {
|
if (file.size > maxSizeKb * 1024) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Fichier trop volumineux. Limite pour ${isGif ? "GIF" : "image"}: ${maxSizeKb} Ko.`,
|
`Fichier trop volumineux. Limite pour ${isGif ? "GIF" : isVideo ? "vidéo" : "image"}: ${maxSizeKb} Ko.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,11 +96,14 @@ export class ContentsService {
|
|||||||
|
|
||||||
// 2. Transcodage
|
// 2. Transcodage
|
||||||
let processed: MediaProcessingResult;
|
let processed: MediaProcessingResult;
|
||||||
if (file.mimetype.startsWith("image/")) {
|
if (file.mimetype.startsWith("image/") && file.mimetype !== "image/gif") {
|
||||||
// Image ou GIF -> WebP (format moderne, bien supporté)
|
// Image -> WebP (format moderne, bien supporté)
|
||||||
processed = await this.mediaService.processImage(file.buffer, "webp");
|
processed = await this.mediaService.processImage(file.buffer, "webp");
|
||||||
} else if (file.mimetype.startsWith("video/")) {
|
} else if (
|
||||||
// Vidéo -> WebM
|
file.mimetype.startsWith("video/") ||
|
||||||
|
file.mimetype === "image/gif"
|
||||||
|
) {
|
||||||
|
// Vidéo ou GIF -> WebM
|
||||||
processed = await this.mediaService.processVideo(file.buffer, "webm");
|
processed = await this.mediaService.processVideo(file.buffer, "webm");
|
||||||
} else {
|
} else {
|
||||||
throw new BadRequestException("Format de fichier non supporté");
|
throw new BadRequestException("Format de fichier non supporté");
|
||||||
@@ -184,6 +196,35 @@ export class ContentsService {
|
|||||||
return deleted;
|
return deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateAdmin(id: string, data: any) {
|
||||||
|
this.logger.log(`Updating content ${id} by admin`);
|
||||||
|
const updated = await this.contentsRepository.update(id, data);
|
||||||
|
|
||||||
|
if (updated) {
|
||||||
|
await this.clearContentsCache();
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, userId: string, data: any) {
|
||||||
|
this.logger.log(`Updating content ${id} for user ${userId}`);
|
||||||
|
|
||||||
|
// Vérifier que le contenu appartient à l'utilisateur
|
||||||
|
const existing = await this.contentsRepository.findOne(id, userId);
|
||||||
|
if (!existing || existing.userId !== userId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Contenu non trouvé ou vous n'avez pas la permission de le modifier.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.contentsRepository.update(id, data);
|
||||||
|
|
||||||
|
if (updated) {
|
||||||
|
await this.clearContentsCache();
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
async findOne(idOrSlug: string, userId?: string) {
|
async findOne(idOrSlug: string, userId?: string) {
|
||||||
const content = await this.contentsRepository.findOne(idOrSlug, userId);
|
const content = await this.contentsRepository.findOne(idOrSlug, userId);
|
||||||
if (!content) return null;
|
if (!content) return null;
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ import {
|
|||||||
export enum ContentType {
|
export enum ContentType {
|
||||||
MEME = "meme",
|
MEME = "meme",
|
||||||
GIF = "gif",
|
GIF = "gif",
|
||||||
|
VIDEO = "video",
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CreateContentDto {
|
export class CreateContentDto {
|
||||||
@IsEnum(ContentType)
|
@IsEnum(ContentType)
|
||||||
type!: "meme" | "gif";
|
type!: "meme" | "gif" | "video";
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { ContentType } from "./create-content.dto";
|
|||||||
|
|
||||||
export class UploadContentDto {
|
export class UploadContentDto {
|
||||||
@IsEnum(ContentType)
|
@IsEnum(ContentType)
|
||||||
type!: "meme" | "gif";
|
type!: "meme" | "gif" | "video";
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
|||||||
@@ -404,6 +404,15 @@ export class ContentsRepository {
|
|||||||
return deleted;
|
return deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async update(id: string, data: Partial<typeof contents.$inferInsert>) {
|
||||||
|
const [updated] = await this.databaseService.db
|
||||||
|
.update(contents)
|
||||||
|
.set({ ...data, updatedAt: new Date() })
|
||||||
|
.where(eq(contents.id, id))
|
||||||
|
.returning();
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
async findBySlug(slug: string) {
|
async findBySlug(slug: string) {
|
||||||
const [result] = await this.databaseService.db
|
const [result] = await this.databaseService.db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { categories } from "./categories";
|
|||||||
import { tags } from "./tags";
|
import { tags } from "./tags";
|
||||||
import { users } from "./users";
|
import { users } from "./users";
|
||||||
|
|
||||||
export const contentType = pgEnum("content_type", ["meme", "gif"]);
|
export const contentType = pgEnum("content_type", ["meme", "gif", "video"]);
|
||||||
|
|
||||||
export const contents = pgTable(
|
export const contents = pgTable(
|
||||||
"contents",
|
"contents",
|
||||||
|
|||||||
@@ -21,10 +21,9 @@ describe("FavoritesRepository", () => {
|
|||||||
|
|
||||||
const wrapWithThen = (obj: unknown) => {
|
const wrapWithThen = (obj: unknown) => {
|
||||||
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
|
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: Necessary to mock Drizzle's awaitable query builder
|
|
||||||
Object.defineProperty(obj, "then", {
|
Object.defineProperty(obj, "then", {
|
||||||
value: function (onFulfilled: (arg0: unknown) => void) {
|
value: function (onFulfilled: (arg0: unknown) => void) {
|
||||||
const result = (this as any).execute();
|
const result = (this as Record<string, unknown>).execute();
|
||||||
return Promise.resolve(result).then(onFulfilled);
|
return Promise.resolve(result).then(onFulfilled);
|
||||||
},
|
},
|
||||||
configurable: true,
|
configurable: true,
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ describe("MediaController", () => {
|
|||||||
expect(stream.pipe).toHaveBeenCalledWith(res);
|
expect(stream.pipe).toHaveBeenCalledWith(res);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException if path is missing", async () => {
|
||||||
|
const res = {} as unknown as Response;
|
||||||
|
await expect(controller.getFile("", res)).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
it("should throw NotFoundException if file is not found", async () => {
|
it("should throw NotFoundException if file is not found", async () => {
|
||||||
mockS3Service.getFileInfo.mockRejectedValue(new Error("Not found"));
|
mockS3Service.getFileInfo.mockRejectedValue(new Error("Not found"));
|
||||||
const res = {} as unknown as Response;
|
const res = {} as unknown as Response;
|
||||||
|
|||||||
@@ -1,27 +1,47 @@
|
|||||||
import { Controller, Get, NotFoundException, Param, Res } from "@nestjs/common";
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
Query,
|
||||||
|
Res,
|
||||||
|
} from "@nestjs/common";
|
||||||
import type { Response } from "express";
|
import type { Response } from "express";
|
||||||
import type { BucketItemStat } from "minio";
|
import type { BucketItemStat } from "minio";
|
||||||
import { S3Service } from "../s3/s3.service";
|
import { S3Service } from "../s3/s3.service";
|
||||||
|
|
||||||
@Controller("media")
|
@Controller("media")
|
||||||
export class MediaController {
|
export class MediaController {
|
||||||
|
private readonly logger = new Logger(MediaController.name);
|
||||||
|
|
||||||
constructor(private readonly s3Service: S3Service) {}
|
constructor(private readonly s3Service: S3Service) {}
|
||||||
|
|
||||||
@Get("*key")
|
@Get()
|
||||||
async getFile(@Param("key") key: string, @Res() res: Response) {
|
async getFile(@Query("path") path: string, @Res() res: Response) {
|
||||||
try {
|
if (!path) {
|
||||||
const stats = (await this.s3Service.getFileInfo(key)) as BucketItemStat;
|
this.logger.warn("Tentative d'accès à un média sans paramètre 'path'");
|
||||||
const stream = await this.s3Service.getFile(key);
|
throw new NotFoundException("Paramètre 'path' manquant");
|
||||||
|
}
|
||||||
|
|
||||||
const contentType =
|
try {
|
||||||
stats.metaData?.["content-type"] || "application/octet-stream";
|
this.logger.log(`Récupération du fichier : ${path}`);
|
||||||
|
const stats = (await this.s3Service.getFileInfo(path)) as BucketItemStat;
|
||||||
|
const stream = await this.s3Service.getFile(path);
|
||||||
|
|
||||||
|
const contentType: string =
|
||||||
|
stats.metaData?.["content-type"] ||
|
||||||
|
stats.metaData?.["Content-Type"] ||
|
||||||
|
"application/octet-stream";
|
||||||
|
|
||||||
res.setHeader("Content-Type", contentType);
|
res.setHeader("Content-Type", contentType);
|
||||||
res.setHeader("Content-Length", stats.size);
|
res.setHeader("Content-Length", stats.size);
|
||||||
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
||||||
|
|
||||||
stream.pipe(res);
|
stream.pipe(res);
|
||||||
} catch (_error) {
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Erreur lors de la récupération du fichier ${path} : ${error.message}`,
|
||||||
|
);
|
||||||
throw new NotFoundException("Fichier non trouvé");
|
throw new NotFoundException("Fichier non trouvé");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ describe("MediaService", () => {
|
|||||||
toFormat: jest.fn().mockReturnThis(),
|
toFormat: jest.fn().mockReturnThis(),
|
||||||
videoCodec: jest.fn().mockReturnThis(),
|
videoCodec: jest.fn().mockReturnThis(),
|
||||||
audioCodec: jest.fn().mockReturnThis(),
|
audioCodec: jest.fn().mockReturnThis(),
|
||||||
outputOptions: jest.fn().mockReturnThis(),
|
addOutputOptions: jest.fn().mockReturnThis(),
|
||||||
on: jest.fn().mockImplementation(function (event, cb) {
|
on: jest.fn().mockImplementation(function (event, cb) {
|
||||||
if (event === "end") setTimeout(cb, 0);
|
if (event === "end") setTimeout(cb, 0);
|
||||||
return this;
|
return this;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export class VideoProcessorStrategy implements IMediaProcessorStrategy {
|
|||||||
private readonly logger = new Logger(VideoProcessorStrategy.name);
|
private readonly logger = new Logger(VideoProcessorStrategy.name);
|
||||||
|
|
||||||
canHandle(mimeType: string): boolean {
|
canHandle(mimeType: string): boolean {
|
||||||
return mimeType.startsWith("video/");
|
return mimeType.startsWith("video/") || mimeType === "image/gif";
|
||||||
}
|
}
|
||||||
|
|
||||||
async process(
|
async process(
|
||||||
@@ -37,13 +37,13 @@ export class VideoProcessorStrategy implements IMediaProcessorStrategy {
|
|||||||
.toFormat("webm")
|
.toFormat("webm")
|
||||||
.videoCodec("libvpx-vp9")
|
.videoCodec("libvpx-vp9")
|
||||||
.audioCodec("libopus")
|
.audioCodec("libopus")
|
||||||
.outputOptions("-crf 30", "-b:v 0");
|
.addOutputOptions("-crf", "30", "-b:v", "0");
|
||||||
} else {
|
} else {
|
||||||
command = command
|
command = command
|
||||||
.toFormat("mp4")
|
.toFormat("mp4")
|
||||||
.videoCodec("libaom-av1")
|
.videoCodec("libaom-av1")
|
||||||
.audioCodec("libopus")
|
.audioCodec("libopus")
|
||||||
.outputOptions("-crf 34", "-b:v 0", "-strict experimental");
|
.addOutputOptions("-crf", "34", "-b:v", "0", "-strict", "experimental");
|
||||||
}
|
}
|
||||||
|
|
||||||
command
|
command
|
||||||
|
|||||||
@@ -23,10 +23,9 @@ describe("ReportsRepository", () => {
|
|||||||
|
|
||||||
const wrapWithThen = (obj: unknown) => {
|
const wrapWithThen = (obj: unknown) => {
|
||||||
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
|
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: Necessary to mock Drizzle's awaitable query builder
|
|
||||||
Object.defineProperty(obj, "then", {
|
Object.defineProperty(obj, "then", {
|
||||||
value: function (onFulfilled: (arg0: unknown) => void) {
|
value: function (onFulfilled: (arg0: unknown) => void) {
|
||||||
const result = (this as any).execute();
|
const result = (this as Record<string, unknown>).execute();
|
||||||
return Promise.resolve(result).then(onFulfilled);
|
return Promise.resolve(result).then(onFulfilled);
|
||||||
},
|
},
|
||||||
configurable: true,
|
configurable: true,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ jest.mock("minio");
|
|||||||
describe("S3Service", () => {
|
describe("S3Service", () => {
|
||||||
let service: S3Service;
|
let service: S3Service;
|
||||||
let configService: ConfigService;
|
let configService: ConfigService;
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: Fine for testing purposes
|
|
||||||
let minioClient: any;
|
let minioClient: any;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -192,7 +191,7 @@ describe("S3Service", () => {
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
const url = service.getPublicUrl("test.webp");
|
const url = service.getPublicUrl("test.webp");
|
||||||
expect(url).toBe("https://api.test.com/media/test.webp");
|
expect(url).toBe("https://api.test.com/media?path=test.webp");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use DOMAIN_NAME and PORT for localhost", () => {
|
it("should use DOMAIN_NAME and PORT for localhost", () => {
|
||||||
@@ -205,7 +204,7 @@ describe("S3Service", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const url = service.getPublicUrl("test.webp");
|
const url = service.getPublicUrl("test.webp");
|
||||||
expect(url).toBe("http://localhost:3000/media/test.webp");
|
expect(url).toBe("http://localhost:3000/media?path=test.webp");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use api.DOMAIN_NAME for production", () => {
|
it("should use api.DOMAIN_NAME for production", () => {
|
||||||
@@ -217,7 +216,7 @@ describe("S3Service", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const url = service.getPublicUrl("test.webp");
|
const url = service.getPublicUrl("test.webp");
|
||||||
expect(url).toBe("https://api.memegoat.fr/media/test.webp");
|
expect(url).toBe("https://api.memegoat.fr/media?path=test.webp");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -173,6 +173,6 @@ export class S3Service implements OnModuleInit, IStorageService {
|
|||||||
baseUrl = `https://api.${domain}`;
|
baseUrl = `https://api.${domain}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${baseUrl}/media/${storageKey}`;
|
return `${baseUrl}/media?path=${storageKey}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,4 +14,12 @@ export class UpdateUserDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
status?: "active" | "verification" | "suspended" | "pending" | "deleted";
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
role?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export class UsersRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async findAll(limit: number, offset: number) {
|
async findAll(limit: number, offset: number) {
|
||||||
return await this.databaseService.db
|
const result = await this.databaseService.db
|
||||||
.select({
|
.select({
|
||||||
uuid: users.uuid,
|
uuid: users.uuid,
|
||||||
username: users.username,
|
username: users.username,
|
||||||
@@ -77,6 +77,8 @@ export class UsersRepository {
|
|||||||
.from(users)
|
.from(users)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset);
|
.offset(offset);
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByUsername(username: string) {
|
async findByUsername(username: string) {
|
||||||
|
|||||||
@@ -112,6 +112,16 @@ export class UsersController {
|
|||||||
return this.usersService.remove(uuid);
|
return this.usersService.remove(uuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch("admin/:uuid")
|
||||||
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
|
@Roles("admin")
|
||||||
|
updateAdmin(
|
||||||
|
@Param("uuid") uuid: string,
|
||||||
|
@Body() updateUserDto: UpdateUserDto,
|
||||||
|
) {
|
||||||
|
return this.usersService.update(uuid, updateUserDto);
|
||||||
|
}
|
||||||
|
|
||||||
// Double Authentification (2FA)
|
// Double Authentification (2FA)
|
||||||
@Post("me/2fa/setup")
|
@Post("me/2fa/setup")
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
|
|||||||
@@ -100,7 +100,14 @@ export class UsersService {
|
|||||||
|
|
||||||
async update(uuid: string, data: UpdateUserDto) {
|
async update(uuid: string, data: UpdateUserDto) {
|
||||||
this.logger.log(`Updating user profile for ${uuid}`);
|
this.logger.log(`Updating user profile for ${uuid}`);
|
||||||
const result = await this.usersRepository.update(uuid, data);
|
|
||||||
|
const { role, ...userData } = data;
|
||||||
|
|
||||||
|
const result = await this.usersRepository.update(uuid, userData);
|
||||||
|
|
||||||
|
if (role) {
|
||||||
|
await this.rbacService.assignRoleToUser(uuid, role);
|
||||||
|
}
|
||||||
|
|
||||||
if (result[0]) {
|
if (result[0]) {
|
||||||
await this.clearUserCache(result[0].username);
|
await this.clearUserCache(result[0].username);
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ services:
|
|||||||
POSTGRES_DB: ${POSTGRES_DB:-app}
|
POSTGRES_DB: ${POSTGRES_DB:-app}
|
||||||
networks:
|
networks:
|
||||||
- nw_memegoat
|
- nw_memegoat
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5432:5432" # not exposed to WAN, LAN only for administration checkup
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -18,15 +18,21 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
|||||||
Inscrit un nouvel utilisateur.
|
Inscrit un nouvel utilisateur.
|
||||||
|
|
||||||
**Corps de la requête (JSON) :**
|
**Corps de la requête (JSON) :**
|
||||||
- `username` (string) : Nom d'utilisateur unique.
|
- `username` (string, max: 32) : Nom d'utilisateur unique.
|
||||||
- `email` (string) : Adresse email valide.
|
- `email` (string) : Adresse email valide.
|
||||||
- `password` (string) : Mot de passe (min. 8 caractères).
|
- `password` (string, min: 8) : Mot de passe.
|
||||||
|
- `displayName` (string, optional, max: 32) : Nom d'affichage.
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `201 Created` : Utilisateur créé.
|
||||||
|
- `400 Bad Request` : Validation échouée ou utilisateur déjà existant.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"username": "goat_user",
|
"username": "goat_user",
|
||||||
"email": "user@memegoat.fr",
|
"email": "user@memegoat.fr",
|
||||||
"password": "strong-password"
|
"password": "strong-password",
|
||||||
|
"displayName": "Le Bouc"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
</Accordion>
|
</Accordion>
|
||||||
@@ -38,16 +44,15 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
|||||||
- `email` (string)
|
- `email` (string)
|
||||||
- `password` (string)
|
- `password` (string)
|
||||||
|
|
||||||
**Réponse (Succès) :**
|
**Réponses :**
|
||||||
|
- `200 OK` : Connexion réussie.
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"message": "User logged in successfully",
|
"message": "User logged in successfully",
|
||||||
"userId": "uuid-v4"
|
"userId": "uuid-v4"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
*Note: L'access_token et le refresh_token sont stockés dans un cookie HttpOnly chiffré.*
|
- `200 OK` (2FA requise) :
|
||||||
|
|
||||||
**Réponse (2FA requise) :**
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"message": "2FA required",
|
"message": "2FA required",
|
||||||
@@ -55,6 +60,9 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
|||||||
"userId": "uuid-v4"
|
"userId": "uuid-v4"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
- `401 Unauthorized` : Identifiants invalides.
|
||||||
|
|
||||||
|
*Note: L'access_token et le refresh_token sont stockés dans un cookie HttpOnly chiffré.*
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="POST /auth/verify-2fa">
|
<Accordion title="POST /auth/verify-2fa">
|
||||||
@@ -63,15 +71,41 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
|||||||
**Corps de la requête :**
|
**Corps de la requête :**
|
||||||
- `userId` (uuid) : ID de l'utilisateur.
|
- `userId` (uuid) : ID de l'utilisateur.
|
||||||
- `token` (string) : Code TOTP à 6 chiffres.
|
- `token` (string) : Code TOTP à 6 chiffres.
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `200 OK` : Vérification réussie, session établie.
|
||||||
|
- `401 Unauthorized` : Token invalide ou utilisateur non autorisé.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="POST /auth/refresh">
|
<Accordion title="POST /auth/refresh">
|
||||||
Obtient un nouvel `access_token` à partir du `refresh_token` stocké dans la session.
|
Obtient un nouvel `access_token` à partir du `refresh_token` stocké dans la session.
|
||||||
Met à jour automatiquement le cookie de session.
|
Met à jour automatiquement le cookie de session.
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `200 OK` : Token rafraîchi.
|
||||||
|
- `401 Unauthorized` : Refresh token absent ou invalide.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="POST /auth/logout">
|
<Accordion title="POST /auth/logout">
|
||||||
Invalide la session actuelle.
|
Invalide la session actuelle en détruisant le cookie de session.
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `200 OK` : Déconnexion réussie.
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion title="GET /auth/bootstrap-admin">
|
||||||
|
Élève les privilèges d'un utilisateur au rang d'administrateur.
|
||||||
|
<Callout type="warn">
|
||||||
|
Cette route n'est active que si aucun administrateur n'existe en base de données. Le token est affiché dans les logs de la console au démarrage.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
**Query Params :**
|
||||||
|
- `token` (string) : Token à usage unique généré par le système.
|
||||||
|
- `username` (string) : Nom de l'utilisateur à promouvoir.
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `200 OK` : Utilisateur promu.
|
||||||
|
- `401 Unauthorized` : Token invalide ou utilisateur non trouvé.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</Accordions>
|
</Accordions>
|
||||||
|
|
||||||
@@ -80,36 +114,62 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
|||||||
<Accordions>
|
<Accordions>
|
||||||
<Accordion title="GET /users/me">
|
<Accordion title="GET /users/me">
|
||||||
Récupère les informations détaillées de l'utilisateur connecté. Requiert l'authentification.
|
Récupère les informations détaillées de l'utilisateur connecté. Requiert l'authentification.
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `200 OK` : Retourne l'objet utilisateur complet (incluant données privées).
|
||||||
|
- `401 Unauthorized` : Session invalide.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="GET /users/public/:username">
|
<Accordion title="GET /users/public/:username">
|
||||||
Récupère le profil public d'un utilisateur par son nom d'utilisateur.
|
Récupère le profil public d'un utilisateur par son nom d'utilisateur. Mise en cache pendant 1 minute.
|
||||||
**Réponse :** `id`, `username`, `displayName`, `avatarUrl`, `createdAt`.
|
|
||||||
|
**Réponses :**
|
||||||
|
- `200 OK` : Profil public (id, username, displayName, bio, avatarUrl, createdAt).
|
||||||
|
- `404 Not Found` : Utilisateur non trouvé.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="GET /users/me/export">
|
<Accordion title="GET /users/me/export">
|
||||||
Extrait l'intégralité des données de l'utilisateur au format JSON (Conformité RGPD).
|
Extrait l'intégralité des données de l'utilisateur au format JSON (Conformité RGPD).
|
||||||
Contient le profil, les contenus et les favoris.
|
Contient le profil, les contenus créés et les favoris.
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `200 OK` : Archive JSON des données.
|
||||||
|
- `401 Unauthorized` : Non authentifié.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="PATCH /users/me">
|
<Accordion title="PATCH /users/me">
|
||||||
Met à jour les informations du profil.
|
Met à jour les informations du profil.
|
||||||
**Corps :**
|
|
||||||
- `displayName` (string)
|
**Corps de la requête :**
|
||||||
- `bio` (string)
|
- `displayName` (string, optional, max: 32)
|
||||||
|
- `bio` (string, optional, max: 255)
|
||||||
|
- `avatarUrl` (string, optional) : URL directe de l'avatar.
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `200 OK` : Profil mis à jour.
|
||||||
|
- `400 Bad Request` : Validation échouée.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="POST /users/me/avatar">
|
<Accordion title="POST /users/me/avatar">
|
||||||
Met à jour l'avatar de l'utilisateur.
|
Met à jour l'avatar de l'utilisateur via upload de fichier.
|
||||||
|
|
||||||
**Type :** `multipart/form-data`
|
**Type :** `multipart/form-data`
|
||||||
**Champ :** `file` (Image)
|
**Champ :** `file` (Image: png, jpeg, webp)
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `201 Created` : Avatar téléchargé et mis à jour.
|
||||||
|
- `400 Bad Request` : Fichier invalide ou trop volumineux.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="PATCH /users/me/consent">
|
<Accordion title="PATCH /users/me/consent">
|
||||||
Met à jour les consentements légaux de l'utilisateur.
|
Met à jour les consentements légaux de l'utilisateur (CGU/RGPD).
|
||||||
**Corps :**
|
|
||||||
- `termsVersion` (string)
|
**Corps de la requête :**
|
||||||
- `privacyVersion` (string)
|
- `termsVersion` (string, max: 16)
|
||||||
|
- `privacyVersion` (string, max: 16)
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `200 OK` : Consentements enregistrés.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="DELETE /users/me">
|
<Accordion title="DELETE /users/me">
|
||||||
@@ -117,132 +177,388 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
|||||||
<Callout type="warn">
|
<Callout type="warn">
|
||||||
Les données sont définitivement purgées après un délai légal de 30 jours.
|
Les données sont définitivement purgées après un délai légal de 30 jours.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `200 OK` : Suppression planifiée.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="Gestion 2FA">
|
<Accordion title="POST /users/me/2fa/setup">
|
||||||
- `POST /users/me/2fa/setup` : Génère un secret et QR Code.
|
Génère un secret et un QR Code pour la configuration de la 2FA.
|
||||||
- `POST /users/me/2fa/enable` : Active après vérification du jeton.
|
|
||||||
- `POST /users/me/2fa/disable` : Désactive avec jeton.
|
**Réponses :**
|
||||||
|
- `201 Created` :
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"secret": "JBSWY3DPEHPK3PXP",
|
||||||
|
"qrCodeDataUrl": "data:image/png;base64,..."
|
||||||
|
}
|
||||||
|
```
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="Administration (Admin uniquement)">
|
<Accordion title="POST /users/me/2fa/enable">
|
||||||
- `GET /users/admin` : Liste tous les utilisateurs (avec pagination `limit`, `offset`).
|
Active la 2FA après vérification du jeton TOTP.
|
||||||
- `DELETE /users/:uuid` : Supprime définitivement un utilisateur par son UUID.
|
|
||||||
|
**Corps de la requête :**
|
||||||
|
- `token` (string) : Code TOTP généré par l'app.
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `200 OK` : 2FA activée.
|
||||||
|
- `400 Bad Request` : Token invalide ou 2FA non initiée.
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion title="POST /users/me/2fa/disable">
|
||||||
|
Désactive la 2FA en utilisant un jeton TOTP valide.
|
||||||
|
|
||||||
|
**Corps de la requête :**
|
||||||
|
- `token` (string) : Code TOTP.
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `200 OK` : 2FA désactivée.
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion title="GET /users/admin">
|
||||||
|
Liste tous les utilisateurs. **Réservé aux administrateurs.**
|
||||||
|
|
||||||
|
**Query Params :**
|
||||||
|
- `limit` (number) : Défaut 10.
|
||||||
|
- `offset` (number) : Défaut 0.
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `200 OK` : Liste paginée des utilisateurs.
|
||||||
|
- `403 Forbidden` : Droits insuffisants.
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion title="DELETE /users/:uuid">
|
||||||
|
Supprime définitivement un utilisateur par son UUID. **Réservé aux administrateurs.**
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `200 OK` : Utilisateur supprimé.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</Accordions>
|
</Accordions>
|
||||||
|
|
||||||
### 🖼️ Contenus (`/contents`)
|
### 🖼️ Contenus (`/contents`)
|
||||||
|
|
||||||
<Accordions>
|
<Accordions>
|
||||||
<Accordion title="GET /contents/explore | /trends | /recent">
|
<Accordion title="GET /contents/explore">
|
||||||
Recherche et filtre les contenus. Ces endpoints sont mis en cache (Redis + Navigateur).
|
Recherche et filtre les contenus. Cet endpoint est mis en cache pendant 1 minute.
|
||||||
|
|
||||||
**Query Params :**
|
**Query Params :**
|
||||||
- `limit` (number) : Défaut 10.
|
- `limit` (number) : Défaut 10.
|
||||||
- `offset` (number) : Défaut 0.
|
- `offset` (number) : Défaut 0.
|
||||||
- `sort` : `trend` | `recent` (uniquement sur `/explore`)
|
- `sort` : `trend` | `recent`
|
||||||
- `tag` (string) : Filtrer par tag.
|
- `tag` (string) : Filtrer par tag (nom).
|
||||||
- `category` (slug ou id) : Filtrer par catégorie.
|
- `category` (slug ou uuid) : Filtrer par catégorie.
|
||||||
- `author` (username) : Filtrer par auteur.
|
- `author` (username) : Filtrer par auteur.
|
||||||
- `query` (titre) : Recherche textuelle.
|
- `query` (string) : Recherche textuelle dans le titre.
|
||||||
- `favoritesOnly` (bool) : Ne montrer que les favoris de l'utilisateur connecté.
|
- `favoritesOnly` (boolean) : Ne montrer que les favoris de l'utilisateur (nécessite auth).
|
||||||
- `userId` (uuid) : Filtrer les contenus d'un utilisateur spécifique.
|
- `userId` (uuid) : Filtrer par ID utilisateur.
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `200 OK` : Liste paginée des contenus.
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion title="GET /contents/trends">
|
||||||
|
Récupère les contenus les plus populaires du moment. Cache de 5 minutes.
|
||||||
|
|
||||||
|
**Query Params :** `limit`, `offset`.
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `200 OK` : Liste des tendances.
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion title="GET /contents/recent">
|
||||||
|
Récupère les contenus les plus récents. Cache de 1 minute.
|
||||||
|
|
||||||
|
**Query Params :** `limit`, `offset`.
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `200 OK` : Liste des contenus récents.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="GET /contents/:idOrSlug">
|
<Accordion title="GET /contents/:idOrSlug">
|
||||||
Récupère un contenu par son ID ou son Slug.
|
Récupère un contenu par son ID ou son Slug. Cache de 1 heure.
|
||||||
|
|
||||||
**Détection de Bots (SEO) :**
|
**Détection de Bots (SEO) :**
|
||||||
Si l'User-Agent correspond à un robot d'indexation (Googlebot, Twitterbot, etc.), l'API retourne un rendu HTML minimal contenant les méta-tags **OpenGraph** et **Twitter Cards** pour un partage optimal. Pour les autres clients, les données sont retournées en JSON.
|
Si l'User-Agent correspond à un robot d'indexation (Googlebot, Twitterbot, etc.), l'API retourne un rendu HTML minimal contenant les méta-tags **OpenGraph** et **Twitter Cards**.
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `200 OK` : Objet Contenu ou Rendu HTML (Bots).
|
||||||
|
- `404 Not Found` : Contenu inexistant.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="POST /contents">
|
<Accordion title="POST /contents">
|
||||||
Crée une entrée de contenu (sans upload de fichier direct). Utile pour référencer des URLs externes.
|
Crée une entrée de contenu à partir d'une ressource déjà uploadée ou externe.
|
||||||
**Corps :** `title`, `description`, `url`, `type`, `categoryId`, `tags`.
|
|
||||||
|
**Corps de la requête :**
|
||||||
|
- `type` : `meme` | `gif`
|
||||||
|
- `title` (string, max: 255)
|
||||||
|
- `storageKey` (string, max: 512) : Clé du fichier sur S3.
|
||||||
|
- `mimeType` (string, max: 128)
|
||||||
|
- `fileSize` (number)
|
||||||
|
- `categoryId` (uuid, optional)
|
||||||
|
- `tags` (string[], optional)
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `201 Created` : Contenu référencé.
|
||||||
|
- `401 Unauthorized` : Non authentifié.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="POST /contents/upload">
|
<Accordion title="POST /contents/upload">
|
||||||
Upload un fichier avec traitement automatique par le serveur.
|
Upload un fichier et crée le contenu associé en une seule étape.
|
||||||
**Type :** `multipart/form-data`
|
|
||||||
|
|
||||||
|
**Type :** `multipart/form-data`
|
||||||
**Champs :**
|
**Champs :**
|
||||||
- `file` (binary) : png, jpeg, webp, webm, gif.
|
- `file` (binary) : png, jpeg, webp, webm, gif.
|
||||||
- `type` : `meme` | `gif`
|
- `type` : `meme` | `gif`
|
||||||
- `title` : string
|
- `title` (string)
|
||||||
- `categoryId`? : uuid
|
- `categoryId` (uuid, optional)
|
||||||
- `tags`? : string[]
|
- `tags` (string[], optional)
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `201 Created` : Upload réussi et contenu créé.
|
||||||
|
- `400 Bad Request` : Fichier non supporté ou données invalides.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="POST /contents/upload-url">
|
<Accordion title="POST /contents/upload-url">
|
||||||
Génère une URL présignée pour un upload direct vers S3.
|
Génère une URL présignée pour un upload direct vers S3.
|
||||||
**Query Param :** `fileName` (string).
|
|
||||||
|
**Query Param :**
|
||||||
|
- `fileName` (string) : Nom du fichier avec extension.
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `201 Created` : Retourne l'URL présignée et les champs requis.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="POST /contents/:id/view | /use">
|
<Accordion title="POST /contents/:id/view">
|
||||||
Incrémente les statistiques de vue ou d'utilisation.
|
Incrémente le compteur de vues d'un contenu.
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `201 Created` : Compteur incrémenté.
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion title="POST /contents/:id/use">
|
||||||
|
Incrémente le compteur d'utilisation (clic sur "Utiliser").
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `201 Created` : Compteur incrémenté.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="DELETE /contents/:id">
|
<Accordion title="DELETE /contents/:id">
|
||||||
Supprime un contenu (Soft Delete). Doit être l'auteur.
|
Supprime un contenu (Soft Delete). Doit être l'auteur.
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `200 OK` : Contenu supprimé.
|
||||||
|
- `403 Forbidden` : Tentative de supprimer le contenu d'autrui.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="DELETE /contents/:id/admin">
|
<Accordion title="DELETE /contents/:id/admin">
|
||||||
Supprime définitivement un contenu. **Réservé aux administrateurs.**
|
Supprime définitivement un contenu. **Réservé aux administrateurs.**
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `200 OK` : Contenu supprimé définitivement.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</Accordions>
|
</Accordions>
|
||||||
|
|
||||||
### 📂 Catégories, ⭐ Favoris, 🚩 Signalements
|
### 📂 Catégories (`/categories`)
|
||||||
|
|
||||||
<Accordions>
|
<Accordions>
|
||||||
<Accordion title="Catégories (/categories)">
|
<Accordion title="GET /categories">
|
||||||
- `GET /categories` : Liste toutes les catégories.
|
Liste toutes les catégories de mèmes disponibles. Cache de 1 heure.
|
||||||
- `GET /categories/:id` : Détails d'une catégorie.
|
|
||||||
- `POST /categories` : Création (Admin uniquement).
|
**Réponses :**
|
||||||
- `PATCH /categories/:id` : Mise à jour (Admin uniquement).
|
- `200 OK` : Liste d'objets catégorie.
|
||||||
- `DELETE /categories/:id` : Suppression (Admin uniquement).
|
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="Favoris (/favorites)">
|
<Accordion title="GET /categories/:id">
|
||||||
Requiert l'authentification.
|
Récupère les détails d'une catégorie spécifique.
|
||||||
- `GET /favorites` : Liste les favoris de l'utilisateur (avec pagination `limit`, `offset`).
|
|
||||||
- `POST /favorites/:contentId` : Ajoute un favori.
|
**Réponses :**
|
||||||
- `DELETE /favorites/:contentId` : Retire un favori.
|
- `200 OK` : Objet catégorie.
|
||||||
|
- `404 Not Found` : Catégorie non trouvée.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="Signalements (/reports)">
|
<Accordion title="POST /categories">
|
||||||
- `POST /reports` : Signale un contenu ou un tag.
|
Crée une nouvelle catégorie. **Admin uniquement.**
|
||||||
- `GET /reports` : Liste des signalements (Pagination `limit`, `offset`). **Admin/Modérateurs**.
|
|
||||||
- `PATCH /reports/:id/status` : Change le statut (`pending`, `resolved`, `dismissed`). **Admin/Modérateurs**.
|
**Corps de la requête :**
|
||||||
|
- `name` (string, max: 64)
|
||||||
|
- `description` (string, optional, max: 255)
|
||||||
|
- `iconUrl` (string, optional, max: 512)
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `201 Created` : Catégorie créée.
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion title="PATCH /categories/:id">
|
||||||
|
Met à jour une catégorie existante. **Admin uniquement.**
|
||||||
|
|
||||||
|
**Corps de la requête :** (Tous optionnels) `name`, `description`, `iconUrl`.
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `200 OK` : Catégorie mise à jour.
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion title="DELETE /categories/:id">
|
||||||
|
Supprime une catégorie. **Admin uniquement.**
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `200 OK` : Catégorie supprimée.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</Accordions>
|
</Accordions>
|
||||||
|
|
||||||
### 🔑 Clés API & 🏷️ Tags
|
### ⭐ Favoris (`/favorites`)
|
||||||
|
|
||||||
<Accordions>
|
<Accordions>
|
||||||
<Accordion title="Clés API (/api-keys)">
|
<Accordion title="GET /favorites">
|
||||||
- `POST /api-keys` : Génère une clé `{ name, expiresAt? }`.
|
Liste les favoris de l'utilisateur connecté.
|
||||||
- `GET /api-keys` : Liste les clés actives.
|
|
||||||
- `DELETE /api-keys/:id` : Révoque une clé.
|
**Query Params :**
|
||||||
|
- `limit` (number) : Défaut 10.
|
||||||
|
- `offset` (number) : Défaut 0.
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `200 OK` : Liste paginée des favoris.
|
||||||
|
- `401 Unauthorized` : Non authentifié.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="Tags (/tags)">
|
<Accordion title="POST /favorites/:contentId">
|
||||||
- `GET /tags` : Recherche de tags.
|
Ajoute un contenu aux favoris de l'utilisateur.
|
||||||
- **Params :** `query` (recherche), `sort` (`popular` | `recent`), `limit`, `offset`.
|
|
||||||
|
**Réponses :**
|
||||||
|
- `201 Created` : Favori ajouté.
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion title="DELETE /favorites/:contentId">
|
||||||
|
Retire un contenu des favoris de l'utilisateur.
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `200 OK` : Favori supprimé.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</Accordions>
|
</Accordions>
|
||||||
|
|
||||||
### 🛠️ Système & Médias
|
### 🚩 Signalements (`/reports`)
|
||||||
|
|
||||||
<Accordions>
|
<Accordions>
|
||||||
<Accordion title="Santé (/health)">
|
<Accordion title="POST /reports">
|
||||||
- `GET /health` : Vérifie l'état de l'API et de la connexion à la base de données.
|
Signale un contenu ou un tag pour modération.
|
||||||
|
|
||||||
|
**Corps de la requête :**
|
||||||
|
- `contentId` (uuid, optional) : ID du contenu à signaler.
|
||||||
|
- `tagId` (uuid, optional) : ID du tag à signaler.
|
||||||
|
- `reason` : `inappropriate` | `spam` | `copyright` | `other`
|
||||||
|
- `description` (string, optional, max: 1000)
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `201 Created` : Signalement enregistré.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="Médias (/media)">
|
<Accordion title="GET /reports">
|
||||||
- `GET /media/*key` : Accès direct aux fichiers stockés sur S3. Supporte la mise en cache agressive.
|
Liste les signalements. **Réservé aux administrateurs et modérateurs.**
|
||||||
|
|
||||||
|
**Query Params :**
|
||||||
|
- `limit` (number) : Défaut 10.
|
||||||
|
- `offset` (number) : Défaut 0.
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `200 OK` : Liste des signalements.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="Administration (/admin)">
|
<Accordion title="PATCH /reports/:id/status">
|
||||||
- `GET /admin/stats` : Récupère les statistiques globales de la plateforme. **Admin uniquement**.
|
Met à jour le statut d'un signalement. **Réservé aux administrateurs et modérateurs.**
|
||||||
|
|
||||||
|
**Corps de la requête :**
|
||||||
|
- `status` : `pending` | `reviewed` | `resolved` | `dismissed`
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `200 OK` : Statut mis à jour.
|
||||||
|
</Accordion>
|
||||||
|
</Accordions>
|
||||||
|
|
||||||
|
### 🔑 Clés API (`/api-keys`)
|
||||||
|
|
||||||
|
<Accordions>
|
||||||
|
<Accordion title="POST /api-keys">
|
||||||
|
Génère une nouvelle clé API pour l'utilisateur.
|
||||||
|
|
||||||
|
**Corps de la requête :**
|
||||||
|
- `name` (string, max: 128) : Nom descriptif de la clé.
|
||||||
|
- `expiresAt` (date-string, optional) : Date d'expiration.
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `201 Created` : Clé générée. Retourne le token (à conserver précieusement, ne sera plus affiché).
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion title="GET /api-keys">
|
||||||
|
Liste toutes les clés API actives de l'utilisateur.
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `200 OK` : Liste des métadonnées des clés (nom, date de création, expiration).
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion title="DELETE /api-keys/:id">
|
||||||
|
Révoque une clé API spécifique.
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `200 OK` : Clé révoquée.
|
||||||
|
</Accordion>
|
||||||
|
</Accordions>
|
||||||
|
|
||||||
|
### 🏷️ Tags (`/tags`)
|
||||||
|
|
||||||
|
<Accordions>
|
||||||
|
<Accordion title="GET /tags">
|
||||||
|
Liste les tags populaires ou recherchés. Cache de 5 minutes.
|
||||||
|
|
||||||
|
**Query Params :**
|
||||||
|
- `limit` (number) : Défaut 10.
|
||||||
|
- `offset` (number) : Défaut 0.
|
||||||
|
- `query` (string, optional) : Recherche par nom.
|
||||||
|
- `sort` : `popular` | `recent`
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `200 OK` : Liste paginée des tags.
|
||||||
|
</Accordion>
|
||||||
|
</Accordions>
|
||||||
|
|
||||||
|
### 🛠️ Système (`/health`)
|
||||||
|
|
||||||
|
<Accordions>
|
||||||
|
<Accordion title="GET /health">
|
||||||
|
Vérifie l'état de santé de l'API et de ses dépendances (DB, Redis).
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `200 OK` : Système opérationnel.
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"timestamp": "2024-01-21T10:00:00.000Z",
|
||||||
|
"database": "connected",
|
||||||
|
"redis": "connected"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `503 Service Unavailable` : Problème sur l'un des composants.
|
||||||
|
</Accordion>
|
||||||
|
</Accordions>
|
||||||
|
|
||||||
|
### 📁 Médias (`/media`)
|
||||||
|
|
||||||
|
<Accordions>
|
||||||
|
<Accordion title="GET /media">
|
||||||
|
Sert un fichier média stocké sur S3 avec une gestion optimisée du cache.
|
||||||
|
|
||||||
|
**Query Params :**
|
||||||
|
- `path` (string) : Chemin relatif du fichier sur le bucket.
|
||||||
|
|
||||||
|
**Réponses :**
|
||||||
|
- `200 OK` : Flux binaire du fichier. Headers `Content-Type` et `Cache-Control` inclus.
|
||||||
|
- `404 Not Found` : Fichier introuvable.
|
||||||
|
</Accordion>
|
||||||
|
</Accordions>
|
||||||
|
|
||||||
|
### 📊 Administration (`/admin`)
|
||||||
|
|
||||||
|
<Accordions>
|
||||||
|
<Accordion title="GET /admin/stats">
|
||||||
|
Récupère les statistiques globales d'utilisation de la plateforme (**Admin uniquement**).
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</Accordions>
|
</Accordions>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Le système utilise plusieurs méthodes d'authentification sécurisées pour ré
|
|||||||
|
|
||||||
Memegoat utilise une architecture de stockage d'objets compatible S3 (MinIO). Les interactions se font de deux manières :
|
Memegoat utilise une architecture de stockage d'objets compatible S3 (MinIO). Les interactions se font de deux manières :
|
||||||
|
|
||||||
1. **Proxification Backend** : Pour l'accès public via `/media/*`.
|
1. **Proxification Backend** : Pour l'accès public via `/media?path=...`.
|
||||||
2. **URLs Présignées** : Pour l'upload sécurisé direct depuis le client (via `/contents/upload-url`).
|
2. **URLs Présignées** : Pour l'upload sécurisé direct depuis le client (via `/contents/upload-url`).
|
||||||
|
|
||||||
### Notifications (Mail)
|
### Notifications (Mail)
|
||||||
|
|||||||
851
dossier-de-projet-cda.md
Normal file
851
dossier-de-projet-cda.md
Normal file
@@ -0,0 +1,851 @@
|
|||||||
|
# 1. Introduction au projet
|
||||||
|
|
||||||
|
Memegoat est une plateforme numérique innovante dédiée à la création, au partage et à la découverte de contenus multimédias éphémères et viraux, tels que les mèmes et les GIFs. Développé dans le cadre du titre professionnel **Concepteur Développeur d'Applications (CDA)**, ce projet transcende la simple fonctionnalité de partage social pour devenir une démonstration technique d'architecture logicielle moderne, de sécurité proactive et de conformité réglementaire.
|
||||||
|
|
||||||
|
Dans un paysage numérique où la protection des données personnelles et la sécurité des infrastructures sont devenues des enjeux critiques, Memegoat se distingue par une approche **"Security by Design"** et **"Privacy by Design"**. L'application ne se contente pas d'offrir une interface utilisateur fluide et réactive ; elle intègre des mécanismes de chiffrement avancés (PGP, ML-KEM), une protection contre les menaces virales via un scan systématique des fichiers (ClamAV), et une architecture robuste basée sur des technologies de pointe telles que Next.js 16, NestJS et PostgreSQL.
|
||||||
|
|
||||||
|
### Objectifs principaux :
|
||||||
|
- **Expérience Utilisateur d'Excellence :** Offrir une plateforme performante et intuitive, capable de gérer des flux de médias importants avec une latence minimale grâce à des stratégies de mise en cache agressives (Redis) et un transcodage optimisé (Sharp, FFmpeg).
|
||||||
|
- **Sécurité de Haut Niveau :** Garantir l'intégrité et la confidentialité des données des utilisateurs par l'implémentation de protocoles de chiffrement asymétrique (PGP), de cryptographie post-quantique (ML-KEM), de hachage robuste (Argon2id) et de mécanismes d'authentification forte (MFA/TOTP).
|
||||||
|
- **Conformité Réglementaire Stricte :** Répondre aux exigences du RGPD par une gestion transparente du consentement, le droit à la portabilité des données et l'automatisation du droit à l'oubli (Soft Delete).
|
||||||
|
- **Innovation Technologique et Sécurité Future :** Anticiper les menaces futures en intégrant des standards de cryptographie post-quantique. La transition vers des algorithmes comme **ML-KEM** (Kyber) est devenue une nécessité stratégique pour contrer la menace dite "Store Now, Decrypt Later", où des acteurs malveillants capturent aujourd'hui des flux chiffrés pour les déchiffrer dès l'avènement des ordinateurs quantiques stables. Memegoat se positionne ainsi à l'avant-garde de la protection des données à long terme.
|
||||||
|
|
||||||
|
# 2. Liste des compétences couvertes par le projet
|
||||||
|
|
||||||
|
Ce projet a été conçu pour couvrir l'intégralité du REAC (Référentiel d'Emploi, d'Activités et de Compétences) **Concepteur Développeur d'Applications (V04)**. Le tableau suivant détaille comment chaque compétence est mise en œuvre au sein de Memegoat.
|
||||||
|
|
||||||
|
| Compétence (CP) | Description | Mise en œuvre dans Memegoat |
|
||||||
|
|:----------------|:---------------------------------------------------------------------------------------------------------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| **CP 1** | Maquetter une application | Conception de maquettes haute fidélité sous **Penpot**, respectant une approche mobile-first et les principes d'accessibilité. Voir [4.3 Maquettage](#43-maquettage). |
|
||||||
|
| **CP 2** | Réaliser une interface utilisateur web statique et adaptable | Intégration **Next.js 16** avec **Tailwind CSS 4** pour un rendu réactif et optimisé. Voir [F.1 Stack technique](#f1---stack-technique-nextjs-16-react-19-tailwind-css-4). |
|
||||||
|
| **CP 3** | Développer une interface utilisateur web dynamique | Développement de composants **React 19** utilisant les Server Actions et une gestion d'état optimisée. Voir [F.3 Interface dynamique](#f3---interface-dynamique-et-ux). |
|
||||||
|
| **CP 4** | Réaliser une interface utilisateur avec une solution de gestion de contenu ou e-commerce | Création d'un module de gestion de contenu personnalisé pour l'administration et la modération. Voir [4.4 Analyse](#44-analyse-et-conception). |
|
||||||
|
| **CP 5** | Créer une base de données | Modélisation et implémentation sous **PostgreSQL** via **Drizzle ORM**, incluant le chiffrement natif PGP. Voir [B.2 Modélisation](#b2---modélisation--base-de-données-drizzle-orm-postgresql). |
|
||||||
|
| **CP 6** | Développer les composants d’accès aux données | Implémentation de services de données sous NestJS avec un typage strict (TypeScript) et validation via Zod. Voir [B.3 Accès aux données](#b3---composant-daccès-aux-données-drizzle-orm). |
|
||||||
|
| **CP 7** | Développer la partie back-end d’une application web ou mobile | Architecture modulaire **NestJS** intégrant JWT, RBAC et services métier complexes. Voir [4.2 Backend](#42-backend). |
|
||||||
|
| **CP 8** | Élaborer et mettre en œuvre des composants dans une application de gestion de contenu ou e-commerce | Développement de tableaux de bord administratifs pour le suivi des signalements et la gestion utilisateur. Voir [B.5 Flux métier](#b5---flux-métier-et-crud). |
|
||||||
|
| **CP 9** | Concevoir une application | Élaboration de diagrammes UML et choix d'une architecture monorepo pour la cohérence globale. Voir [4.4 Analyse](#44-analyse-et-conception). |
|
||||||
|
| **CP 10** | Collaborer à la gestion d’un projet informatique et à l’organisation de l’environnement de développement | Utilisation de Git (GitFlow), **Docker Compose** et gestion des tâches en méthode Agile. Voir [4.1 Organisation](#41-organisation-des-tâches). |
|
||||||
|
| **CP 11** | Préparer le déploiement de l’application | Configuration de conteneurs Docker pour l'orchestration des services (API, DB, Redis, MinIO). Voir [4.5 Déploiement](#45-déploiement-et-infrastructure). |
|
||||||
|
| **CP 12** | Organiser la veille technologique | Veille continue sur les évolutions de React 19, la sécurité Post-Quantique (ML-KEM) et le Green IT. Voir [B.6 Qualité et Tests](#b6---qualité-et-tests). |
|
||||||
|
|
||||||
|
# 3. Cahier des charges
|
||||||
|
|
||||||
|
## 3.1 Spécifications fonctionnelles
|
||||||
|
|
||||||
|
### Gestion des utilisateurs et authentification (MFA, Sessions)
|
||||||
|
Le système d'authentification de Memegoat repose sur une approche multi-niveaux garantissant sécurité et flexibilité. L'inscription et la connexion utilisent le hachage **Argon2id**, résistant aux attaques par force brute. La gestion des sessions est assurée par des jetons **JWT** avec un mécanisme de **rotation des jetons de rafraîchissement** (Refresh Tokens) pour limiter les risques de vol de session. Pour renforcer la sécurité, l'application intègre une authentification à deux facteurs (**MFA/TOTP**), dont les secrets sont chiffrés en base de données via **PGP**.
|
||||||
|
|
||||||
|
### Gestion et partage de contenus (Memes & GIFs)
|
||||||
|
Les utilisateurs peuvent téléverser des contenus multimédias de manière sécurisée. Le processus inclut une validation stricte des types MIME et de la taille des fichiers. Chaque contenu peut être enrichi de métadonnées (description, tags). Le partage est facilité par la génération d'URLs uniques et l'intégration de métadonnées **OpenGraph**. L'interface propose des flux par tendances, nouveautés et favoris, optimisés par une mise en cache **Redis**.
|
||||||
|
|
||||||
|
### Sécurisation avancée (Cryptographie PGP & Post-Quantique)
|
||||||
|
|
||||||
|
Memegoat implémente une couche de sécurité exceptionnelle pour protéger les données les plus sensibles.
|
||||||
|
|
||||||
|
#### Pourquoi le Post-Quantique ?
|
||||||
|
L'avènement prochain de l'informatique quantique menace les algorithmes de chiffrement asymétrique actuels (RSA, Elliptic Curves) via l'algorithme de Shor. Pour parer à cette menace, Memegoat intègre **ML-KEM (Kyber768)**. Ce choix est crucial pour garantir la confidentialité persistante des données : même si une base de données chiffrée est volée aujourd'hui, elle restera indéchiffrable par un ordinateur quantique futur.
|
||||||
|
|
||||||
|
#### Chiffrement des données sensibles (PGP & Argon2id)
|
||||||
|
Les données personnelles identifiables (PII), comme les emails, sont chiffrées au repos dans **PostgreSQL** à l'aide de clés **PGP**. Ce mécanisme assure que même un administrateur ayant accès directement aux fichiers de la base de données ne peut lire les informations personnelles sans la clé de déchiffrement.
|
||||||
|
|
||||||
|
Pour les mots de passe, nous utilisons **Argon2id**, le vainqueur de la "Password Hashing Competition". Ce choix est justifié par sa résistance supérieure aux attaques par "side-channel" et sa capacité à saturer la mémoire, rendant les attaques via GPU ou ASIC extrêmement coûteuses et inefficaces.
|
||||||
|
|
||||||
|
#### Blind Indexing
|
||||||
|
Un système de **Blind Indexing** est mis en œuvre pour permettre la recherche sur les données chiffrées (comme l'email lors de la connexion) sans jamais avoir à les déchiffrer en masse. On génère un condensat (hash) sécurisé et distinct de la donnée originale, utilisé uniquement pour les comparaisons d'égalité, préservant ainsi la confidentialité totale de la donnée stockée.
|
||||||
|
|
||||||
|
### Panneau d’Administration et Modération
|
||||||
|
Un tableau de bord dédié permet aux administrateurs de superviser l'activité : gestion des comptes, modération des contenus signalés et suivi des journaux d'audit. Le système de signalement déclenche un flux de modération où les administrateurs peuvent valider, supprimer ou demander une modification du contenu, assurant un environnement sain.
|
||||||
|
|
||||||
|
### Système de recherche par catégories et tags
|
||||||
|
La découverte de contenus est propulsée par un moteur de recherche multicritère. Les utilisateurs peuvent filtrer par catégories thématiques, tags ou recherche textuelle. L'architecture est optimisée grâce à des index performants et une dénormalisation contrôlée pour garantir des temps de réponse rapides.
|
||||||
|
|
||||||
|
## 3.2 Spécifications non fonctionnelles
|
||||||
|
|
||||||
|
### Performance & Réactivité (Redis, Caching)
|
||||||
|
Pour offrir une expérience fluide, Memegoat utilise **Redis** comme couche de cache pour les données hautement sollicitées. Le traitement des médias est optimisé : les images sont converties au format WebP/AVIF via **Sharp**, et les GIFs/vidéos sont transcodés par **FFmpeg**, réduisant le poids des fichiers sans compromettre la qualité.
|
||||||
|
|
||||||
|
### Observabilité et Sécurité du Transport (Sentry, Helmet, Throttler)
|
||||||
|
|
||||||
|
La surveillance et la protection active de l'application sont assurées par un ensemble d'outils complémentaires :
|
||||||
|
- **Sentry** : Assure le suivi des erreurs et la performance. L'intégration couvre à la fois le backend et le frontend, incluant le **Profiling Node.js** pour détecter les fuites de mémoire ou les fonctions bloquantes.
|
||||||
|
- **Helmet** : Ce middleware sécurise l'application NestJS en configurant de manière appropriée divers en-têtes HTTP. Ces en-têtes protègent les utilisateurs contre des attaques courantes telles que le XSS, le Clickjacking ou le reniflage de type MIME (MIME-sniffing).
|
||||||
|
- **Rate Limiting (Throttler)** : Pour prévenir les abus, les attaques par déni de service (DoS) et les tentatives de brute-force sur l'authentification, un système de limitation de débit est implémenté, restreignant le nombre de requêtes par IP dans une fenêtre de temps donnée.
|
||||||
|
|
||||||
|
#### Focus sur les en-têtes Helmet
|
||||||
|
L'utilisation de Helmet permet d'injecter automatiquement les protections suivantes :
|
||||||
|
- **Content-Security-Policy (CSP)** : Restreint les sources de contenu autorisées (scripts, styles, images), bloquant ainsi l'exécution de scripts malveillants injectés.
|
||||||
|
- **X-Frame-Options** : Interdit l'affichage de l'application dans des `<frame>` ou `<iframe>` tiers, empêchant le Clickjacking.
|
||||||
|
- **Strict-Transport-Security (HSTS)** : Force le navigateur à utiliser uniquement des connexions HTTPS sécurisées.
|
||||||
|
- **X-Content-Type-Options** : Empêche le navigateur d'interpréter un fichier autrement que par son type MIME déclaré, neutralisant certaines attaques par upload de fichiers.
|
||||||
|
|
||||||
|
#### Détection de Crawlers et Protection contre le Scraping
|
||||||
|
Un middleware dédié (`CrawlerDetectionMiddleware`) analyse les motifs de requêtes et les User-Agents. Il identifie les comportements suspects (tentatives d'accès à des fichiers sensibles comme `.env`, scans de vulnérabilités PHP) et les robots connus pour optimiser la charge serveur et protéger le contenu des mèmes contre le pillage automatique.
|
||||||
|
|
||||||
|
### Scalabilité (Stockage S3/Minio)
|
||||||
|
L'architecture sépare les serveurs d'application du stockage des actifs numériques via le protocole **S3** (MinIO). Cette approche facilite la scalabilité horizontale et permet de servir les médias via un réseau de diffusion de contenu (CDN).
|
||||||
|
|
||||||
|
### Expérience utilisateur (UX)
|
||||||
|
L'interface est développée avec une approche **mobile-first**, utilisant **Shadcn UI** et **Radix UI**. Elle propose un mode sombre natif, des états de chargement soignés (skeletons) et une navigation fluide, garantissant une inclusion maximale.
|
||||||
|
|
||||||
|
### SEO (Search Engine Optimization)
|
||||||
|
Memegoat implémente une stratégie SEO agressive et moderne grâce aux capacités natives de **Next.js**. L'application utilise le **Server-Side Rendering (SSR)** pour garantir que les robots d'indexation (Googlebot, Bingbot) reçoivent un code HTML complet et sémantique.
|
||||||
|
|
||||||
|
Les points clés incluent :
|
||||||
|
- **Metadata API** : Gestion centralisée et dynamique des balises `<title>`, `<meta description>` et des balises canoniques.
|
||||||
|
- **Social Graph (OpenGraph & Twitter)** : Optimisation des partages sur les réseaux sociaux avec des images d'aperçu dynamiques pour chaque mème.
|
||||||
|
- **Données Structurées (JSON-LD)** : Utilisation du format Schema.org pour aider les moteurs de recherche à comprendre la nature des contenus (Mèmes, Auteurs, Catégories).
|
||||||
|
|
||||||
|
### Accessibilité (A11Y)
|
||||||
|
Memegoat vise une conformité de niveau **AA selon le WCAG 2.1**. L'accessibilité n'est pas une option mais un pilier du développement :
|
||||||
|
- **Composants Radix UI** : Utilisation de primitives accessibles gérant nativement le focus, les rôles ARIA et les interactions clavier complexes.
|
||||||
|
- **Navigation au clavier** : Parcours utilisateur entièrement réalisable sans souris, avec des indicateurs de focus clairs.
|
||||||
|
- **Lecteurs d'écran** : Sémantique HTML5 stricte (`<main>`, `<nav>`, `<article>`) et utilisation judicieuse des attributs `aria-label` et `aria-live` pour les notifications dynamiques.
|
||||||
|
- **Contrastes** : Respect des ratios de contraste (minimum 4.5:1) pour assurer une lisibilité optimale.
|
||||||
|
|
||||||
|
### Maintenance et Extensibilité
|
||||||
|
La pérennité est assurée par une architecture **monorepo** facilitant le partage de types entre frontend et backend. L'usage de **TypeScript** et la structure modulaire de **NestJS** permettent d'étendre les fonctionnalités sans compromettre la stabilité.
|
||||||
|
|
||||||
|
### Tests automatisés
|
||||||
|
La robustesse repose sur une stratégie de tests rigoureuse avec **Jest**. Les services critiques (cryptographie, authentification) sont couverts par des tests unitaires, tandis que des tests d'intégration vérifient la communication avec la base de données via **Drizzle**.
|
||||||
|
|
||||||
|
## 3.3 Charte graphique
|
||||||
|
|
||||||
|
### Couleurs
|
||||||
|
La palette s'articule autour d'une dominante sombre (**Zinc/Black**) pour le confort visuel, avec des accents de violet électrique et d'indigo pour les éléments d'interaction, reflétant l'aspect moderne et communautaire.
|
||||||
|
|
||||||
|
### Police d’écriture
|
||||||
|
Le choix s'est porté sur **Ubuntu Sans** pour sa lisibilité exceptionnelle et son aspect moderne, complétée par **Ubuntu Mono** pour les éléments techniques et le code, assurant une cohérence visuelle sur tous les supports.
|
||||||
|
|
||||||
|
### Logotype et image de marque
|
||||||
|
Le logotype représente une chèvre stylisée, symbole du **"G.O.A.T"**, incarnant l'ambition de devenir la référence ultime des mèmes tout en inspirant confiance par sa rigueur technique.
|
||||||
|
|
||||||
|
## 3.4 Spécifications de l’infrastructure
|
||||||
|
|
||||||
|
L'infrastructure est entièrement conteneurisée avec **Docker**, garantissant la parité entre environnements.
|
||||||
|
- **Caddy** : Reverse proxy avec gestion automatique du SSL (TLS 1.3). Il agit comme point d'entrée unique, gérant le routage vers le frontend et le backend tout en assurant une couche de sécurité supplémentaire.
|
||||||
|
- **PostgreSQL 17** : Stockage relationnel avec extension `pgcrypto` pour le chiffrement PGP.
|
||||||
|
- **Redis 7** : Utilisé pour la mise en cache des requêtes API et la gestion des sessions à haute performance.
|
||||||
|
- **MinIO** : Serveur de stockage d'objets auto-hébergé, compatible avec l'API Amazon S3, utilisé pour la persistance des fichiers médias.
|
||||||
|
- **ClamAV** : Service d'analyse antivirus intégré au flux d'upload pour protéger l'infrastructure contre les fichiers malveillants.
|
||||||
|
|
||||||
|
## 3.5 Sécurité et Conformité
|
||||||
|
|
||||||
|
Le projet a été conçu selon le principe de **Défense en Profondeur**.
|
||||||
|
- **Sécurité Applicative** : Validation rigoureuse via Zod, hachage Argon2id, et protection contre les failles OWASP (XSS, CSRF) via Helmet.
|
||||||
|
- **Sécurité des Données** : Chiffrement PGP au repos et cryptographie post-quantique (ML-KEM) pour les échanges de clés.
|
||||||
|
- **Disponibilité** : Architecture conteneurisée permettant un redémarrage rapide et une isolation des services.
|
||||||
|
- **Conformité RGPD** : Gestion native des droits utilisateurs (accès, oubli) et minimisation des données collectées.
|
||||||
|
|
||||||
|
# 4. Réalisations
|
||||||
|
|
||||||
|
## 4.1 Organisation des tâches
|
||||||
|
|
||||||
|
La réussite d'un projet de l'envergure de Memegoat repose sur une organisation rigoureuse et une méthodologie de travail éprouvée. Pour ce développement, j'ai adopté une approche **Agile**, s'inspirant du cadre **Scrum**, permettant une itération rapide et une adaptation continue aux défis techniques rencontrés, notamment sur les aspects complexes de cryptographie et de traitement des médias.
|
||||||
|
|
||||||
|
### Gestion de projet et suivi des tâches
|
||||||
|
Le pilotage du projet a été centralisé sur la plateforme **Gitea** (alternative auto-hébergée à GitHub). Chaque fonctionnalité ou correction de bug a fait l'objet d'une "Issue" détaillée, servant de point de départ à la réflexion technique. Pour la gestion visuelle du flux de travail, j'ai utilisé un tableau **Kanban**, permettant de suivre l'évolution des tâches de l'état "Backlog" à "Terminé". Cette visibilité constante a été cruciale pour maintenir une cadence de développement régulière et prioriser les composants critiques liés à la sécurité.
|
||||||
|
|
||||||
|
### Gestion des versions (Versioning)
|
||||||
|
Le code source est géré via **Git**, en suivant une version simplifiée du modèle **GitFlow**. Cette organisation permet de séparer clairement le code en cours de développement (branche `dev`) de la version stable destinée à la production (branche `main`). Chaque nouvelle fonctionnalité est développée sur une branche isolée avant d'être intégrée après une phase de tests, garantissant ainsi l'intégrité de la branche principale.
|
||||||
|
|
||||||
|
### Environnement de développement et Monorepo
|
||||||
|
|
||||||
|
Pour assurer une cohérence parfaite et faciliter le partage de code (notamment les types TypeScript), le projet est structuré en **Monorepo** utilisant les **workspaces pnpm**.
|
||||||
|
|
||||||
|
Ce choix d'architecture monorepo répond à plusieurs besoins critiques du projet :
|
||||||
|
- **Partage de code simplifié** : Les schémas de validation Zod et les types TypeScript sont partagés nativement entre le frontend et le backend. Cela garantit qu'une modification de la structure des données côté serveur est immédiatement détectée par le compilateur côté client, éliminant ainsi toute une classe de bugs liés à la désynchronisation des API.
|
||||||
|
- **Gestion centralisée des dépendances** : Grâce à pnpm, les dépendances communes sont partagées, réduisant l'empreinte disque et accélérant les installations.
|
||||||
|
- **Atomicité des changements** : Il est possible de mettre à jour une fonctionnalité impactant les deux bouts de la chaîne dans une seule et même "Pull Request", facilitant le suivi et la revue de code.
|
||||||
|
- **Simplification du déploiement** : Un seul dépôt facilite la configuration des pipelines CI/CD et l'orchestration Docker, tout en maintenant une isolation stricte des processus à l'exécution.
|
||||||
|
|
||||||
|
L'ensemble de l'environnement est conteneurisé avec **Docker**, garantissant des services standardisés (PostgreSQL, Redis, MinIO) accessibles instantanément sans pollution du système hôte.
|
||||||
|
|
||||||
|
### Pipeline CI/CD (Gitea Actions)
|
||||||
|
L'automatisation est au cœur du processus de qualité. Un pipeline **CI/CD** a été mis en place via **Gitea Actions (Forgejo)**. À chaque tag de version :
|
||||||
|
1. **Validation** : Linting (Biome) et tests automatisés (Jest) sont exécutés sur chaque composant.
|
||||||
|
2. **Build** : Les images Docker sont construites pour valider la compilation.
|
||||||
|
3. **Déploiement** : L'application est automatiquement déployée sur le serveur de production via Docker Compose, assurant une livraison continue et fiable.
|
||||||
|
|
||||||
|
## 4.2 Analyse et Conception
|
||||||
|
|
||||||
|
La phase de conception est le socle sur lequel repose la robustesse de Memegoat. Elle a permis d'anticiper les défis techniques liés à la sécurité et à la gestion des médias.
|
||||||
|
|
||||||
|
### Analyse des besoins et Personas
|
||||||
|
L'analyse a identifié trois profils types (Personas) :
|
||||||
|
1. **Le Créateur de contenu** : Recherche la simplicité d'upload et une visibilité maximale.
|
||||||
|
2. **Le Consommateur** : Privilégie la fluidité de navigation et la pertinence du flux (tendances).
|
||||||
|
3. **Le Modérateur** : Nécessite des outils d'administration efficaces pour garantir la sécurité de la communauté.
|
||||||
|
|
||||||
|
### User Stories
|
||||||
|
- "En tant qu'utilisateur, je veux pouvoir téléverser un mème de manière sécurisée afin de le partager."
|
||||||
|
- "En tant que modérateur, je veux pouvoir suspendre un contenu signalé pour non-respect des règles."
|
||||||
|
- "En tant qu'utilisateur soucieux de ma vie privée, je veux pouvoir activer la double authentification (MFA)."
|
||||||
|
|
||||||
|
### Diagramme de Cas d'Utilisation (Use Case)
|
||||||
|
Il illustre les interactions majeures : Inscription, Recherche, Upload, Modération, et Gestion de profil.
|
||||||
|
|
||||||
|
### Diagramme de Séquence (Flux d'Upload)
|
||||||
|
Détaille le passage du média à travers le scanner antivirus ClamAV avant son stockage sur MinIO et son référencement en base de données.
|
||||||
|
|
||||||
|
## 4.3 Maquettage
|
||||||
|
|
||||||
|
Le design de Memegoat a été guidé par une approche **Mobile-First** et une esthétique épurée.
|
||||||
|
|
||||||
|
### Choix de l'outil : Pourquoi PenPot ?
|
||||||
|
Le choix de **PenPot** s'inscrit dans la démarche Open-Source du projet. Contrairement à Figma, PenPot permet une pleine maîtrise des assets (format SVG natif) et facilite la collaboration sans contraintes de licences propriétaires, tout en offrant des fonctionnalités de prototypage avancées.
|
||||||
|
|
||||||
|
### Workflow de Design
|
||||||
|
1. **Wireframes** : Définition de la structure sans distraction visuelle.
|
||||||
|
2. **Maquettes Haute Fidélité** : Application de la charte graphique (Ubuntu Sans, palette de gris profond).
|
||||||
|
3. **Prototypage** : Simulation des transitions pour valider l'UX (User Experience) avant le développement.
|
||||||
|
|
||||||
|
## 4.4 Backend
|
||||||
|
|
||||||
|
L'architecture backend de Memegoat a été conçue pour être à la fois robuste, évolutive et sécurisée. Le choix s'est porté sur **NestJS**, un framework Node.js progressif, pour sa capacité à structurer le code de manière modulaire et son support natif de **TypeScript**.
|
||||||
|
|
||||||
|
### Architecture du backend (NestJS)
|
||||||
|
|
||||||
|
NestJS impose une structure rigoureuse inspirée de l'architecture Angular, ce qui facilite grandement la maintenance. L'application est découpée en **Modules**, chacun étant responsable d'un domaine fonctionnel précis (ex: `UsersModule`, `AuthModule`, `ContentsModule`). Cette approche garantit une séparation stricte des préoccupations (**Separation of Concerns**).
|
||||||
|
|
||||||
|
#### Controller
|
||||||
|
Les contrôleurs constituent la porte d'entrée de l'API. Ils réceptionnent les requêtes HTTP, délèguent le traitement à la logique métier et retournent une réponse formatée au client. Dans Memegoat, chaque contrôleur utilise des décorateurs NestJS pour définir les routes, les méthodes (GET, POST, etc.) et les mécanismes de sécurité (**Guards**).
|
||||||
|
|
||||||
|
#### Service
|
||||||
|
Les services encapsulent l'intégralité de la logique métier. Ils sont injectés dans les contrôleurs via le mécanisme d'**injection de dépendances**. C'est ici que sont réalisées les opérations complexes : validation des données, calculs métiers, et interactions avec la base de données via l'ORM.
|
||||||
|
|
||||||
|
#### Module
|
||||||
|
Le module est la brique de base de NestJS. Il permet d'organiser le code en domaines logiques et de gérer les dépendances. Memegoat utilise un `AppModule` racine qui orchestre les modules transverses (Database, Config, Cache) et les modules métier.
|
||||||
|
|
||||||
|
#### Middleware
|
||||||
|
Les middlewares sont utilisés pour des traitements transverses sur les requêtes entrantes. Memegoat utilise un middleware de logging (**HTTPLogger**) pour la traçabilité et un middleware de **détection de robots (Crawler Detection)** pour optimiser les ressources et sécuriser l'accès contre le scraping intensif.
|
||||||
|
|
||||||
|
#### Guard
|
||||||
|
Les Guards sont responsables de l'autorisation. Ils déterminent si une requête donnée est autorisée à accéder à une route, en fonction des rôles de l'utilisateur (**RBAC**) ou de la validité de sa session (**JWT**).
|
||||||
|
|
||||||
|
#### Data Transfer Object (DTO)
|
||||||
|
Les DTO définissent la forme des données pour chaque opération de l'API. Couplés à **Zod**, ils permettent une validation stricte et automatique des données entrantes, protégeant l'application contre les données corrompues.
|
||||||
|
|
||||||
|
### B.1 - Installation et configuration de l’environnement
|
||||||
|
|
||||||
|
La mise en place a été optimisée pour la performance via le gestionnaire de paquets **pnpm**. La configuration est centralisée dans des variables d'environnement (`.env`), validées au démarrage par un schéma **Zod**. Cela permet de détecter immédiatement toute erreur de configuration critique.
|
||||||
|
|
||||||
|
### B.2 - Modélisation & Base de données (Drizzle ORM, PostgreSQL)
|
||||||
|
|
||||||
|
Pour la persistance, Memegoat s'appuie sur **PostgreSQL**. L'interaction est gérée par **Drizzle ORM**, un outil moderne "Type-safe" qui permet d'écrire des requêtes SQL de manière intuitive tout en bénéficiant de la puissance du typage TypeScript.
|
||||||
|
|
||||||
|
#### Table Users
|
||||||
|
Pilier de la gestion d'identité. Elle stocke les profils, les secrets MFA et les métadonnées RGPD. Les données sensibles (email) sont chiffrées nativement via **PGP**.
|
||||||
|
|
||||||
|
#### Table Contents (Memes & GIFs)
|
||||||
|
Centralise les métadonnées des médias : titres, slugs, clés de stockage S3 et statistiques d'utilisation.
|
||||||
|
|
||||||
|
#### Table Categories & Tags
|
||||||
|
Permettent une organisation taxonomique flexible des contenus pour faciliter la découverte et le filtrage.
|
||||||
|
|
||||||
|
#### Table Favorites
|
||||||
|
Gère la relation "plusieurs-à-plusieurs" entre utilisateurs et contenus.
|
||||||
|
|
||||||
|
#### Table Audit Logs & Reports
|
||||||
|
Assurent la traçabilité des actions administratives et permettent aux utilisateurs de signaler les contenus inappropriés pour la modération.
|
||||||
|
|
||||||
|
#### Table Sessions & API Keys
|
||||||
|
Gèrent l'accès prolongé (Refresh Tokens) et les accès programmatiques sécurisés.
|
||||||
|
|
||||||
|
#### Table RBAC (Rôles & Permissions)
|
||||||
|
Définit finement les droits d'accès (Utilisateur, Modérateur, Administrateur).
|
||||||
|
|
||||||
|
#### Table PGP (Chiffrement symétrique)
|
||||||
|
Configuration permettant l'usage transparent du chiffrement symétrique au sein de PostgreSQL via l'extension `pgcrypto`.
|
||||||
|
|
||||||
|
#### Migration & Seeding
|
||||||
|
L'évolution du schéma est gérée par **Drizzle Kit** via des fichiers de migration SQL versionnés. Des scripts de seeding permettent de peupler l'environnement avec des données cohérentes.
|
||||||
|
|
||||||
|
### B.3 - Composant d’accès aux données (Drizzle ORM)
|
||||||
|
|
||||||
|
#### Approche Schema-First et Typage End-to-End
|
||||||
|
L'utilisation de **Drizzle ORM** permet une approche "Schema-First". Le schéma de la base de données est défini en TypeScript, ce qui permet de générer à la fois les migrations SQL et les types utilisés par l'application. Cette synchronisation parfaite garantit qu'aucune erreur de type ne survient lors de la manipulation des données.
|
||||||
|
|
||||||
|
#### Requêtes performantes et Typage strict
|
||||||
|
Drizzle permet de manipuler les données avec une syntaxe proche du SQL tout en restant parfaitement typée, offrant une excellente performance sans la surcharge des ORM traditionnels (comme TypeORM ou Sequelize).
|
||||||
|
- **Type-Safety** : Les résultats des requêtes sont automatiquement typés en fonction du schéma.
|
||||||
|
- **Relations explicites** : Les jointures sont gérées de manière performante, évitant le problème du "N+1 queries" grâce à une sélection précise des colonnes nécessaires.
|
||||||
|
|
||||||
|
#### Sécurité et Prévention des Injections SQL
|
||||||
|
Drizzle protège nativement contre les injections SQL grâce à l'utilisation systématique de requêtes paramétrées. Chaque entrée utilisateur est traitée comme une donnée et non comme une partie de la commande SQL, neutralisant ainsi ce vecteur d'attaque critique.
|
||||||
|
|
||||||
|
#### Gestion des Erreurs et Optimisation des Requêtes
|
||||||
|
Le backend intègre une gestion centralisée des exceptions de base de données. Les erreurs SQL (contraintes d'unicité, violations de clés étrangères) sont interceptées et transformées en réponses HTTP claires et sécurisées pour le client, sans jamais exposer la structure interne de la base.
|
||||||
|
|
||||||
|
### B.4 - Composants métier
|
||||||
|
|
||||||
|
#### Validation des données (Zod & DTO)
|
||||||
|
Toutes les données entrantes (corps de requête, paramètres d'URL) sont validées à l'aide de schémas **Zod**. Ce choix garantit :
|
||||||
|
- Un typage strict partagé entre le frontend et le backend.
|
||||||
|
- L'élimination des données malformées avant qu'elles n'atteignent la logique métier.
|
||||||
|
- Une documentation automatique des interfaces.
|
||||||
|
|
||||||
|
#### Gestion des médias (S3/Minio, Sharp, FFmpeg)
|
||||||
|
Le pipeline de traitement inclut :
|
||||||
|
1. **Réception sécurisée** en mémoire (Stream-based processing).
|
||||||
|
2. **Scan antivirus** systématique (ClamAV) avant toute écriture disque.
|
||||||
|
3. **Optimisation** : Images via **Sharp** (WebP/AVIF), GIFs/Vidéos via **FFmpeg** pour un compromis idéal qualité/poids.
|
||||||
|
4. **Stockage persistant** sur MinIO (S3) avec URLs signées pour la sécurité.
|
||||||
|
|
||||||
|
#### Cycle de vie d'un contenu (Upload, Validation, Modération)
|
||||||
|
Encadrement strict : de l'upload au statut "actif" ou "suspendu" suite à un signalement. L'utilisation du **Soft Delete** garantit la conformité RGPD tout en permettant un audit en cas de litige.
|
||||||
|
|
||||||
|
#### Règles Métier et Avantages de Drizzle ORM
|
||||||
|
L'utilisation de Drizzle permet d'implémenter les contraintes métier (unicité, intégrité) de manière fluide. Les transactions sont utilisées pour garantir l'atomicité des opérations complexes (ex: upload média + insertion DB).
|
||||||
|
|
||||||
|
### B.5 - Flux métier et CRUD
|
||||||
|
|
||||||
|
Cette section détaille les interactions dynamiques entre les composants du système pour les fonctionnalités clés.
|
||||||
|
|
||||||
|
#### 1. Inscription et Authentification
|
||||||
|
Le choix de **Argon2id** pour le hachage des mots de passe offre la meilleure résistance connue contre les attaques par force brute et par GPU/ASIC. L'authentification est sécurisée par des sessions **Iron Session** (basées sur des cookies HttpOnly chiffrés) et un support natif du **MFA (TOTP)**.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as Utilisateur
|
||||||
|
participant F as Frontend (Next.js)
|
||||||
|
participant B as Backend (NestJS)
|
||||||
|
participant D as Base de Données (PostgreSQL)
|
||||||
|
participant S as Session (Iron Session)
|
||||||
|
|
||||||
|
Note over U, S: Processus d'Inscription
|
||||||
|
U->>F: Saisie (username, email, password)
|
||||||
|
F->>B: POST /auth/register
|
||||||
|
B->>B: Hachage du mot de passe (Argon2id)
|
||||||
|
B->>B: Calcul du Blind Index (Email Hash)
|
||||||
|
B->>D: INSERT INTO users (Email chiffré PGP)
|
||||||
|
D-->>B: Confirmation
|
||||||
|
B-->>F: Compte créé avec succès
|
||||||
|
|
||||||
|
Note over U, S: Processus de Connexion
|
||||||
|
U->>F: Login/Password
|
||||||
|
F->>B: POST /auth/login
|
||||||
|
B->>D: SELECT user WHERE email_hash = ?
|
||||||
|
D-->>B: Données utilisateur (Password Haché)
|
||||||
|
B->>B: Vérification Argon2id
|
||||||
|
|
||||||
|
alt MFA Activé
|
||||||
|
B-->>F: 200 OK (Require MFA)
|
||||||
|
U->>F: Saisie du code TOTP
|
||||||
|
F->>B: POST /auth/verify-2fa
|
||||||
|
B->>B: Validation du secret chiffré PGP
|
||||||
|
end
|
||||||
|
|
||||||
|
B->>S: Création de la session chiffrée
|
||||||
|
S-->>F: Cookie Set-Cookie (HttpOnly, Secure)
|
||||||
|
F-->>U: Redirection vers le Dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Publication de Contenu (CRUD Create)
|
||||||
|
Le flux de publication privilégie la sécurité (scan antivirus) et la performance utilisateur (traitement asynchrone possible, bien qu'actuellement synchrone pour garantir la disponibilité immédiate).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as Utilisateur
|
||||||
|
participant F as Frontend
|
||||||
|
participant B as Backend
|
||||||
|
participant AV as ClamAV (Antivirus)
|
||||||
|
participant P as Processeur (Sharp/FFmpeg)
|
||||||
|
participant S3 as Stockage S3 (MinIO)
|
||||||
|
participant D as Base de Données
|
||||||
|
|
||||||
|
U->>F: Upload du média (Meme/GIF)
|
||||||
|
F->>B: POST /contents/upload (Multipart)
|
||||||
|
B->>B: Validation du type MIME & Taille
|
||||||
|
B->>AV: Scan binaire du fichier
|
||||||
|
AV-->>B: Résultat Clean
|
||||||
|
|
||||||
|
par Traitement d'optimisation
|
||||||
|
B->>P: Sharp (Resize & WebP)
|
||||||
|
P-->>B: Buffer optimisé
|
||||||
|
and Upload S3
|
||||||
|
B->>S3: Upload vers le bucket "memes"
|
||||||
|
S3-->>B: Key / ETag
|
||||||
|
end
|
||||||
|
|
||||||
|
B->>D: INSERT INTO contents (S3 Key, Metadata)
|
||||||
|
D-->>B: ID Contenu
|
||||||
|
B-->>F: 201 Created (Redirection vers le contenu)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Système de Signalement et Modération
|
||||||
|
La modération est un flux métier critique. Nous utilisons un système de signalement par les utilisateurs qui alimente une file d'attente pour les modérateurs/administrateurs.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as Utilisateur
|
||||||
|
participant B as Backend
|
||||||
|
participant D as Base de Données
|
||||||
|
participant A as Admin/Modérateur
|
||||||
|
|
||||||
|
U->>B: POST /reports (ContenuID, Motif)
|
||||||
|
B->>D: INSERT INTO reports (Status: PENDING)
|
||||||
|
D-->>B: OK
|
||||||
|
|
||||||
|
Note over A, D: Interface de Modération
|
||||||
|
A->>B: GET /reports (Filter: PENDING)
|
||||||
|
B->>D: SELECT reports JOIN contents
|
||||||
|
D-->>B: Liste des signalements
|
||||||
|
B-->>A: Affichage du Dashboard Admin
|
||||||
|
|
||||||
|
A->>B: PATCH /reports/:id (Status: RESOLVED)
|
||||||
|
B->>D: UPDATE contents SET status = 'BLOCKED' (ou Soft Delete)
|
||||||
|
D-->>B: OK
|
||||||
|
B-->>A: Action confirmée
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Recherche et Exploration (CRUD Read)
|
||||||
|
L'optimisation des lectures est cruciale pour une plateforme de médias. Nous utilisons une stratégie de **mise en cache agressive** via Redis pour les flux publics (tendances, nouveautés) afin de réduire la charge sur la base de données PostgreSQL.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as Utilisateur
|
||||||
|
participant F as Frontend
|
||||||
|
participant B as Backend
|
||||||
|
participant R as Cache (Redis)
|
||||||
|
participant D as Base de Données
|
||||||
|
|
||||||
|
U->>F: Navigation vers "Explore"
|
||||||
|
F->>B: GET /contents/explore?sort=trend
|
||||||
|
B->>R: Recherche de la clé cache (URL hash)
|
||||||
|
|
||||||
|
alt Cache HIT (Données présentes)
|
||||||
|
R-->>B: Retourne le JSON mis en cache
|
||||||
|
else Cache MISS (Données absentes ou expirées)
|
||||||
|
B->>D: SELECT contents (filtres, pagination)
|
||||||
|
D-->>B: Résultats de la requête
|
||||||
|
B->>R: Stockage du résultat (SET with TTL)
|
||||||
|
end
|
||||||
|
|
||||||
|
B-->>F: Envoi du flux de données
|
||||||
|
F-->>U: Affichage fluide de la grille
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. Mise à jour et Gestion de compte (CRUD Update)
|
||||||
|
|
||||||
|
La mise à jour des informations utilisateur ou des contenus suit un flux sécurisé garantissant que seuls les propriétaires ou les administrateurs peuvent modifier les données.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as Utilisateur
|
||||||
|
participant F as Frontend
|
||||||
|
participant B as Backend
|
||||||
|
participant D as Base de Données
|
||||||
|
participant R as Cache (Redis)
|
||||||
|
|
||||||
|
U->>F: Modification du profil (ex: Avatar/Bio)
|
||||||
|
F->>B: PATCH /users/me (Multipart/Data)
|
||||||
|
B->>B: Vérification du JWT / Session
|
||||||
|
|
||||||
|
alt Si changement sensible (Email/Password)
|
||||||
|
B->>B: Vérification du mot de passe actuel
|
||||||
|
B->>B: Nouveau hachage / Chiffrement PGP
|
||||||
|
end
|
||||||
|
|
||||||
|
B->>D: UPDATE users SET ... WHERE id = ?
|
||||||
|
D-->>B: Confirmation
|
||||||
|
|
||||||
|
B->>R: Invalidation du cache utilisateur (DEL user:profile:id)
|
||||||
|
R-->>B: OK
|
||||||
|
|
||||||
|
B-->>F: 200 OK (Données mises à jour)
|
||||||
|
F-->>U: Notification de succès
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6. Suppression et Droit à l'oubli (CRUD Delete)
|
||||||
|
|
||||||
|
Conformément au RGPD, l'utilisateur dispose d'un droit à l'effacement. Pour concilier ce droit avec la nécessité de maintenir l'intégrité des données et de prévenir les abus, nous utilisons le **Soft Delete**.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as Utilisateur
|
||||||
|
participant B as Backend
|
||||||
|
participant D as Base de Données
|
||||||
|
participant S3 as Stockage S3
|
||||||
|
|
||||||
|
U->>B: DELETE /contents/:id
|
||||||
|
B->>B: Vérification des droits (Owner/Admin)
|
||||||
|
|
||||||
|
B->>D: UPDATE contents SET deleted_at = NOW()
|
||||||
|
D-->>B: OK
|
||||||
|
|
||||||
|
Note over B, S3: Suppression asynchrone (optionnelle)
|
||||||
|
B-->>U: 204 No Content
|
||||||
|
|
||||||
|
Note right of D: Les requêtes futures excluent <br/>automatiquement les 'deleted_at IS NOT NULL'
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Fonctionnement** : Une colonne `deleted_at` est mise à jour. Les requêtes de lecture standard ignorent ces enregistrements grâce à des filtres globaux ou des clauses WHERE systématiques.
|
||||||
|
- **Action physique** : Les médias associés sur S3 peuvent être supprimés après un délai de rétention de sécurité (ex: 30 jours), garantissant une suppression effective des fichiers volumineux tout en permettant une récupération en cas d'erreur ou de litige.
|
||||||
|
|
||||||
|
### B.6 - Qualité et Tests
|
||||||
|
|
||||||
|
La qualité logicielle de Memegoat repose sur une pyramide des tests équilibrée et l'utilisation d'outils d'analyse statique de pointe.
|
||||||
|
|
||||||
|
#### Stratégie de tests avec Jest
|
||||||
|
Le projet utilise **Jest** comme moteur de test principal. La stratégie se divise en deux axes :
|
||||||
|
1. **Tests Unitaires** : Ils isolent chaque service pour vérifier sa logique interne. Les services critiques tels que `AuthService` (authentification), `CryptoService` (chiffrement PGP/ML-KEM) et les validateurs `Zod` sont couverts à 100% pour garantir qu'aucune régression ne compromette la sécurité.
|
||||||
|
2. **Tests d'Intégration** : Ces tests vérifient la bonne communication entre les modules NestJS et la base de données PostgreSQL. Nous utilisons des conteneurs éphémères pour garantir que les requêtes Drizzle produisent les résultats attendus dans un environnement réel.
|
||||||
|
|
||||||
|
#### Analyse Statique et Qualité du Code
|
||||||
|
Pour maintenir une base de code saine et homogène, nous utilisons **Biome** :
|
||||||
|
- **Linting** : Détection précoce des erreurs potentielles, des variables inutilisées et des mauvaises pratiques JavaScript/TypeScript.
|
||||||
|
- **Formatage** : Uniformisation automatique du style de code, facilitant la lecture et la collaboration au sein du monorepo.
|
||||||
|
- **Performance** : Biome est nettement plus rapide qu'ESLint/Prettier, ce qui accélère la boucle de rétroaction pendant le développement et dans le pipeline CI.
|
||||||
|
|
||||||
|
#### Maintenabilité et Documentation
|
||||||
|
La maintenabilité est assurée par le typage strict de **TypeScript**. En cas de modification d'une interface dans le backend, le compilateur signale immédiatement les erreurs d'impact dans le frontend. Cette "auto-documentation" par les types est complétée par des commentaires standardisés pour les logiques métier complexes.
|
||||||
|
|
||||||
|
### Sécurité & Cryptographie
|
||||||
|
|
||||||
|
Memegoat adopte une approche de défense en profondeur, combinant des standards industriels éprouvés et des technologies prospectives pour garantir la souveraineté des données utilisateurs.
|
||||||
|
|
||||||
|
#### Gestion de l'Identité et Authentification Forte
|
||||||
|
L'authentification ne repose pas uniquement sur le couple identifiant/mot de passe. Nous utilisons un système de sessions sécurisées via **Iron Session**, utilisant des cookies signés et chiffrés côté serveur.
|
||||||
|
- **Hashing avec Argon2id** : Les mots de passe sont hachés avec l'algorithme Argon2id, configuré selon les recommandations de l'ANSSI (Memory: 64MB, Iterations: 3, Parallelism: 4). Ce choix protège contre les attaques par dictionnaire et les tentatives de craquage massif via GPU/ASIC.
|
||||||
|
- **MFA (Multi-Factor Authentication)** : L'implémentation du protocole **TOTP** (Time-based One-Time Password) ajoute une couche de sécurité vitale. Les secrets MFA sont eux-mêmes chiffrés en base de données avant stockage.
|
||||||
|
|
||||||
|
#### Chiffrement des Données au Repos (PGP & pgcrypto)
|
||||||
|
Pour protéger les données personnelles identifiables (PII), Memegoat utilise le chiffrement asymétrique directement au niveau de la couche de persistance.
|
||||||
|
- **Extension pgcrypto** : Nous exploitons les capacités natives de PostgreSQL pour chiffrer les colonnes sensibles (ex: emails).
|
||||||
|
- **Mécanisme PGP** : Les données sont chiffrées avec une clé publique et ne peuvent être déchiffrées que par l'application possédant la clé privée correspondante. Cela garantit que même en cas de compromission physique de la base de données, les informations personnelles restent inexploitables.
|
||||||
|
|
||||||
|
#### Cryptographie Post-Quantique (ML-KEM)
|
||||||
|
Anticipant l'ère de l'informatique quantique, Memegoat intègre **ML-KEM (Kyber768)**, un algorithme basé sur les réseaux (lattice-based cryptography) récemment standardisé par le NIST (FIPS 203).
|
||||||
|
- **Objectif** : Sécuriser les échanges de clés contre les futures capacités de déchiffrement quantique.
|
||||||
|
- **Implémentation** : L'utilisation de la bibliothèque `@noble/post-quantum` permet d'établir des secrets partagés résistants, assurant une "Forward Secrecy" même face à un attaquant disposant d'un ordinateur quantique stable dans le futur.
|
||||||
|
|
||||||
|
#### Protection de la Couche Transport et En-têtes (Helmet)
|
||||||
|
La sécurité du navigateur est renforcée par l'utilisation de **Helmet** côté NestJS, qui configure les en-têtes HTTP essentiels :
|
||||||
|
- **CSP (Content Security Policy)** : Bloque l'exécution de scripts non autorisés, neutralisant les attaques XSS.
|
||||||
|
- **HSTS** : Impose le HTTPS de manière stricte.
|
||||||
|
- **CORS** : Politique de partage de ressources restrictive, autorisant uniquement les appels provenant du domaine frontend légitime.
|
||||||
|
|
||||||
|
#### Antivirus Applicatif et Validation Stricte
|
||||||
|
Chaque fichier téléversé subit un flux de vérification rigoureux avant traitement :
|
||||||
|
- **Scan ClamAV** : Utilisation d'un démon ClamAV pour analyser le binaire de chaque image ou GIF à la recherche de malwares ou de scripts malveillants encapsulés.
|
||||||
|
- **Validation Zod** : Toutes les entrées de l'API sont validées par des schémas Zod, empêchant les injections de données malformées ou les attaques par pollution de prototypes.
|
||||||
|
|
||||||
|
#### Amorçage Sécurisé (Bootstrap Service)
|
||||||
|
Le système intègre un mécanisme d'amorçage unique (`BootstrapService`) qui génère un jeton à usage unique au premier démarrage si aucun administrateur n'est détecté. Cela permet de créer le premier compte "Admin" de manière sécurisée sans exposer d'identifiants par défaut dans le code ou la base de données.
|
||||||
|
|
||||||
|
#### Purge et Maintenance Automatisée (RGPD)
|
||||||
|
Un service de purge automatique (`PurgeService`) s'exécute quotidiennement pour garantir que les données supprimées (Soft Delete) ou expirées (Sessions, Signalements) sont physiquement retirées du système après 30 jours, assurant une conformité stricte avec le principe de limitation de la conservation du RGPD.
|
||||||
|
|
||||||
|
### Veille technologique et de sécurité
|
||||||
|
|
||||||
|
#### OWASP Top Ten : Priorité à la sécurité applicative
|
||||||
|
Conception guidée par les standards de l'OWASP pour prévenir les vulnérabilités les plus critiques.
|
||||||
|
|
||||||
|
#### Veille sur la sécurité Post-Quantique
|
||||||
|
Suivi des standards du NIST et de l'ANSSI pour la migration vers des algorithmes résistants.
|
||||||
|
|
||||||
|
#### CERT-FR (Veille gouvernementale)
|
||||||
|
Surveillance active des vulnérabilités pour maintenir les dépendances à jour.
|
||||||
|
|
||||||
|
## 4.3 Maquettage
|
||||||
|
|
||||||
|
### Choix de l'outil : Pourquoi PenPot ?
|
||||||
|
Utilisation de **PenPot** comme alternative Open-Source à Figma, favorisant la souveraineté des données et une transition fluide vers le code grâce au format SVG et au Flex Layout.
|
||||||
|
|
||||||
|
### Workflow de Design
|
||||||
|
1. **Wireframes** : Focus sur l'UX et l'ergonomie.
|
||||||
|
2. **Maquettes Haute Fidélité** : Application de l'identité visuelle.
|
||||||
|
3. **Prototypage** : Simulation du parcours utilisateur complet.
|
||||||
|
|
||||||
|
## 4.4 Analyse et Conception
|
||||||
|
|
||||||
|
### Analyse des besoins et Personas
|
||||||
|
La phase d'analyse a permis d'identifier les besoins des utilisateurs cibles :
|
||||||
|
- **Le Consommateur** : Recherche un divertissement rapide, fluide et accessible sur mobile.
|
||||||
|
- **Le Créateur** : Souhaite partager ses contenus facilement tout en ayant l'assurance que ses données sont protégées.
|
||||||
|
- **Le Modérateur/Admin** : Nécessite des outils robustes pour maintenir un environnement sain.
|
||||||
|
|
||||||
|
### User Stories
|
||||||
|
Les fonctionnalités ont été priorisées via la méthode **MoSCoW** :
|
||||||
|
- **Must (Indispensable)** : Inscription sécurisée (MFA), Upload de mèmes, Consultation des tendances.
|
||||||
|
- **Should (Important)** : Mise en favoris, Recherche par tags, Signalement de contenu.
|
||||||
|
- **Could (Optionnel)** : Profils personnalisés avancés, Statistiques de vues.
|
||||||
|
|
||||||
|
### Diagramme de Cas d'Utilisation (Use Case)
|
||||||
|
Le diagramme suivant illustre les interactions des acteurs avec le système :
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
V[Visiteur]
|
||||||
|
U[Utilisateur Authentifié]
|
||||||
|
A[Administrateur]
|
||||||
|
|
||||||
|
V --- C1(Consulter les tendances)
|
||||||
|
V --- C2(S'inscrire / Se connecter)
|
||||||
|
|
||||||
|
U --- C3(Poster un mème)
|
||||||
|
U --- C4(Ajouter aux favoris)
|
||||||
|
U --- C5(Signaler un contenu)
|
||||||
|
|
||||||
|
A --- C6(Modérer les contenus)
|
||||||
|
A --- C7(Gérer les utilisateurs)
|
||||||
|
A --- C8(Consulter les statistiques)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Diagramme de Séquence (Flux d'Upload)
|
||||||
|
Détail des interactions lors de la publication d'un contenu, intégrant la sécurité et l'optimisation :
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User as Utilisateur
|
||||||
|
participant API as Backend (NestJS)
|
||||||
|
participant AV as Scanner (ClamAV)
|
||||||
|
participant P as Processeur (Sharp/FFmpeg)
|
||||||
|
participant S3 as Stockage (MinIO)
|
||||||
|
participant DB as PostgreSQL
|
||||||
|
|
||||||
|
User->>API: POST /contents/upload (Multipart)
|
||||||
|
API->>AV: Scan Antivirus du buffer
|
||||||
|
AV-->>API: Résultat: Sain
|
||||||
|
|
||||||
|
par Optimisation et Stockage
|
||||||
|
API->>P: Conversion WebP / Transcodage
|
||||||
|
P-->>API: Média optimisé
|
||||||
|
API->>S3: Transfert vers le bucket
|
||||||
|
end
|
||||||
|
|
||||||
|
API->>DB: INSERT INTO contents (Metadata + S3 Key)
|
||||||
|
DB-->>API: Confirmation (ID)
|
||||||
|
API-->>User: 201 Created (Affichage)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4.5 Frontend
|
||||||
|
|
||||||
|
L'interface utilisateur de Memegoat a été développée avec **Next.js**, en tirant parti des dernières avancées de l'écosystème React pour offrir une expérience fluide, performante et accessible.
|
||||||
|
|
||||||
|
### F.1 - Stack technique (Next.js 16, React 19, Tailwind CSS 4)
|
||||||
|
L'interface de Memegoat repose sur une stack à la pointe de l'écosystème web, choisie pour ses performances et sa maintenabilité :
|
||||||
|
- **Next.js 16 (App Router)** : Utilisation du framework de référence pour React, permettant un rendu hybride. Les pages sont pré-rendues côté serveur (SSR) pour le SEO, tandis que les interactions dynamiques sont gérées côté client.
|
||||||
|
- **React 19** : Cette version majeure introduit des améliorations significatives, notamment dans la gestion des formulaires avec les **Server Actions** et le support natif de l'asynchronisme (use, transition API), réduisant drastiquement le code "boilerplate" de gestion d'état.
|
||||||
|
- **Tailwind CSS 4** : La nouvelle itération de ce framework "Utility-First" offre une compilation ultra-rapide et une configuration simplifiée via CSS-native variables, permettant de construire des interfaces complexes sans quitter le fichier HTML/JSX.
|
||||||
|
|
||||||
|
### F.2 - Architecture et Interfaces
|
||||||
|
L'architecture frontend suit les principes de la **composabilité** et de la séparation des responsabilités. Le frontend est organisé en composants réutilisables, suivant les principes de l'**Atomic Design**.
|
||||||
|
- **Composants et Design System** : Le projet utilise **Shadcn UI**, basé sur **Radix UI**, pour fournir une bibliothèque de composants non stylés mais hautement accessibles.
|
||||||
|
- **Type-Safety** : Les interfaces TypeScript sont partagées avec le backend, garantissant que les données affichées correspondent exactement aux données envoyées par l'API.
|
||||||
|
- **Rendu Hybride** : Nous tirons pleinement parti des **React Server Components (RSC)**. Contrairement aux approches traditionnelles où tout le JavaScript est envoyé au client, les RSC permettent d'exécuter la logique lourde directement sur le serveur.
|
||||||
|
|
||||||
|
### F.3 - Interface dynamique et UX
|
||||||
|
L'expérience utilisateur est au cœur du développement :
|
||||||
|
- **Flux de données et Server Actions** : Pour les mutations de données (comme le partage d'un mème ou l'ajout aux favoris), Memegoat utilise les **Server Actions**, simplifiant l'architecture en éliminant le besoin de définir manuellement des API routes dédiées.
|
||||||
|
- **Optimistic Updates** : Pour des actions comme la mise en favoris, l'interface réagit instantanément avant même la confirmation du serveur, renforçant la sensation de fluidité.
|
||||||
|
- **Streaming et Suspense** : L'utilisation de placeholders animés (**Skeletons**) pendant le chargement des contenus réduit la perception du temps d'attente.
|
||||||
|
|
||||||
|
### F.4 - SEO et Métadonnées avec Next.js
|
||||||
|
Memegoat est optimisé pour les moteurs de recherche :
|
||||||
|
- **Génération dynamique de métadonnées** : Chaque mème possède son propre titre, description et image OpenGraph générés dynamiquement via la fonction `generateMetadata`.
|
||||||
|
- **Données structurées (JSON-LD)** : Intégration de schémas (ImageObject, VideoObject) pour aider les moteurs de recherche à indexer le contenu de manière sémantique et favoriser l'apparition dans les "rich snippets".
|
||||||
|
|
||||||
|
### F.5 - Accessibilité et Design Inclusif (A11Y)
|
||||||
|
Le projet respecte les standards d'accessibilité :
|
||||||
|
- **Composants Radix UI / Shadcn** : Utilisation de primitives accessibles respectant les spécifications WAI-ARIA (Gestion du Focus Trap, Navigation Clavier).
|
||||||
|
- **Contraste et Navigation** : Respect des ratios de contraste WCAG et support complet de la navigation au clavier avec une gestion visible du focus.
|
||||||
|
- **Sémantique HTML** : Utilisation rigoureuse des balises sémantiques (`<header>`, `<main>`, `<section>`) pour faciliter la navigation des lecteurs d'écran.
|
||||||
|
|
||||||
|
## 4.6 Déploiement et Infrastructure
|
||||||
|
|
||||||
|
L'infrastructure de Memegoat est conçue pour être portable, scalable et sécurisée, s'appuyant sur les standards de l'industrie.
|
||||||
|
|
||||||
|
### Conteneurisation avec Docker et Docker Compose
|
||||||
|
L'intégralité de la stack technique est encapsulée dans des conteneurs **Docker**. Cette approche garantit que l'application s'exécute dans un environnement strictement identique, que ce soit sur le poste de développement ou sur le serveur de production. **Docker Compose** orchestre les différents services :
|
||||||
|
- L'API NestJS (Backend)
|
||||||
|
- L'application Next.js (Frontend)
|
||||||
|
- La base de données PostgreSQL
|
||||||
|
- Le cache Redis
|
||||||
|
- Le stockage d'objets MinIO (compatible S3)
|
||||||
|
|
||||||
|
### Reverse Proxy et Sécurité SSL (Caddy)
|
||||||
|
En façade, nous utilisons **Caddy** comme serveur web et reverse proxy. Contrairement à Nginx, Caddy gère nativement et automatiquement le renouvellement des certificats SSL via **Let's Encrypt**. Il est configuré pour imposer le protocole **TLS 1.3**, garantissant des échanges chiffrés au meilleur standard de sécurité actuel.
|
||||||
|
|
||||||
|
### Orchestration des services
|
||||||
|
L'isolation réseau est assurée par des réseaux Docker privés. Seul le proxy Caddy est exposé sur les ports 80 et 443. La communication entre le backend et la base de données ou le cache s'effectue sur un réseau interne, réduisant considérablement la surface d'attaque.
|
||||||
|
|
||||||
|
## 4.7 Écoconception et Accessibilité
|
||||||
|
|
||||||
|
Memegoat intègre des principes de sobriété numérique pour réduire son impact environnemental tout en améliorant l'expérience utilisateur.
|
||||||
|
|
||||||
|
### Stratégie d'Écoconception
|
||||||
|
Notre approche de "sobriété logicielle" se décline sur plusieurs plans :
|
||||||
|
- **Optimisation des médias** : Le transcodage systématique vers des formats modernes (**WebP**, **AVIF**) réduit le volume de données transférées de 30% à 70% par rapport au JPEG/PNG traditionnel.
|
||||||
|
- **Réduction du JavaScript** : L'utilisation des **React Server Components** permet de déplacer une grande partie du calcul vers le serveur, envoyant ainsi beaucoup moins de code au navigateur client, ce qui économise la batterie et les ressources des appareils mobiles.
|
||||||
|
- **Caching intelligent** : L'usage massif de **Redis** et du cache HTTP limite les cycles de calcul CPU redondants, réduisant ainsi la consommation énergétique globale de l'infrastructure.
|
||||||
|
|
||||||
|
### Accessibilité Numérique (RGAA)
|
||||||
|
L'inclusion est au cœur du développement. Memegoat suit les recommandations du **RGAA** :
|
||||||
|
- **Sémantique HTML** : Utilisation rigoureuse des balises sémantiques pour faciliter la navigation des lecteurs d'écran.
|
||||||
|
- **Navigation Clavier** : Grâce à **Radix UI**, tous les éléments interactifs sont entièrement accessibles au clavier avec une gestion visible du focus.
|
||||||
|
- **Contrastes et Lisibilité** : La charte graphique a été testée pour garantir un rapport de contraste suffisant, et la police **Ubuntu Sans** assure un confort de lecture optimal.
|
||||||
|
|
||||||
|
# 5. Respect de la réglementation (RGPD)
|
||||||
|
|
||||||
|
### Registre des traitements
|
||||||
|
L'application tient à jour un registre des traitements limitant la collecte aux données strictement nécessaires au fonctionnement du service :
|
||||||
|
- **Utilisateur** : Pseudonyme, Email (chiffré PGP), Mot de passe (haché Argon2id).
|
||||||
|
- **Médias** : Mèmes et GIFs téléversés, métadonnées associées.
|
||||||
|
- **Sécurité** : Logs d'audit (actions sensibles), Sessions (chiffrées).
|
||||||
|
|
||||||
|
### Droits des personnes
|
||||||
|
Memegoat intègre nativement des mécanismes pour répondre aux sollicitations des utilisateurs :
|
||||||
|
- **Droit d'accès et portabilité** : Possibilité d'exporter l'intégralité des données rattachées à un compte via un service dédié (`exportUserData`).
|
||||||
|
- **Droit à l'effacement (Droit à l'oubli)** : Implémentation du **Soft Delete** permettant une suppression logique immédiate pour l'utilisateur, suivie d'une purge physique automatisée après 30 jours par le `PurgeService`. Ce délai permet de prévenir les suppressions accidentelles et de conserver les preuves nécessaires en cas de litige ou de réquisition judiciaire.
|
||||||
|
- **Droit d'opposition et de rectification** : Interface de gestion de compte permettant la mise à jour ou la suppression des informations personnelles à tout moment.
|
||||||
|
- **Information des utilisateurs** : Une politique de confidentialité claire est accessible, détaillant la finalité des traitements et la durée de conservation des données.
|
||||||
|
|
||||||
|
### Sécurité par défaut (Privacy by Design)
|
||||||
|
- **Minimisation des données** : Seules les informations essentielles sont conservées.
|
||||||
|
- **Chiffrement systématique** : Les données identifiables (PII) sont chiffrées dès leur réception et avant stockage en base de données.
|
||||||
|
- **Transparence** : Information claire de l'utilisateur sur l'usage de ses données lors de l'inscription.
|
||||||
|
|
||||||
|
# 6. Conclusion
|
||||||
|
|
||||||
|
Memegoat démontre qu'il est possible d'allier une thématique ludique à une exigence technique et sécuritaire de haut niveau. Ce projet a permis de maîtriser l'ensemble du cycle de développement d'une application moderne, de la conception UI/UX au déploiement orchestré, tout en intégrant des technologies de pointe en cryptographie.
|
||||||
|
|
||||||
|
### Remerciements
|
||||||
|
Je tiens à remercier l'équipe pédagogique pour son accompagnement tout au long de cette formation, ainsi que mes pairs pour leurs retours constructifs durant la phase de développement.
|
||||||
|
|
||||||
|
# 7. Annexes
|
||||||
|
|
||||||
|
### Annexe 1 - Schéma de classe POO du backend
|
||||||
|
|
||||||
|
Le schéma suivant représente l'architecture logicielle du backend NestJS, mettant en évidence la modularité du système et les relations entre les contrôleurs, services et repositories.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*Note : Le diagramme complet est disponible au format PlantUML dans le fichier `backend.plantuml` à la racine du projet.*
|
||||||
|
|
||||||
|
### Annexe 2 - Sources et ressources
|
||||||
|
- [Documentation NestJS](https://docs.nestjs.com/)
|
||||||
|
- [Documentation Next.js](https://nextjs.org/docs)
|
||||||
|
- [Guide de sécurité OWASP](https://owasp.org/www-project-top-ten/)
|
||||||
|
- [Standard NIST Post-Quantum (ML-KEM)](https://csrc.nist.gov/pubs/fips/203/final)
|
||||||
|
- [Référentiel Général d'Accessibilité (RGAA)](https://www.numerique.gouv.fr/publications/rgaa-accessibilite/)
|
||||||
|
|
||||||
|
### Annexe 3 - Glossaire technique
|
||||||
|
|
||||||
|
* **A11Y (Accessibilité) :**
|
||||||
|
* **Définition :** Contraction du mot "Accessibility" (11 lettres entre le A et le Y).
|
||||||
|
* **Explication :** Désigne l'ensemble des pratiques visant à rendre les services numériques utilisables par tous, y compris les personnes en situation de handicap (visuel, moteur, auditif, etc.). Dans Memegoat, cela se traduit par l'utilisation de composants sémantiques et le respect des normes WCAG.
|
||||||
|
|
||||||
|
* **ANSSI (Agence Nationale de la Sécurité des Systèmes d'Information) :**
|
||||||
|
* **Définition :** Autorité nationale française en matière de cybersécurité.
|
||||||
|
* **Explication :** Memegoat suit les recommandations de l'ANSSI pour le choix des algorithmes de hachage (Argon2id) et la configuration des protocoles TLS afin de garantir un niveau de sécurité étatique.
|
||||||
|
|
||||||
|
* **API (Interface de Programmation d'Application) :**
|
||||||
|
* **Définition :** Ensemble de règles et de protocoles permettant à deux logiciels de communiquer entre eux.
|
||||||
|
* **Explication :** Dans ce projet, l'API NestJS sert de pont entre le frontend (Next.js) et les données stockées en base. Elle expose des points d'accès (endpoints) sécurisés pour récupérer ou modifier les mèmes et les profils utilisateurs.
|
||||||
|
|
||||||
|
* **Argon2id :**
|
||||||
|
* **Définition :** Algorithme de hachage de mots de passe, vainqueur de la Password Hashing Competition.
|
||||||
|
* **Explication :** Contrairement aux méthodes anciennes (MD5, SHA1), Argon2id est conçu pour être extrêmement résistant aux attaques par force brute et par GPU, en utilisant des paramètres de mémoire et de temps configurables. C'est le standard recommandé par l'ANSSI.
|
||||||
|
|
||||||
|
* **Biome :**
|
||||||
|
* **Définition :** Chaîne d'outils (toolchain) ultra-rapide pour le web.
|
||||||
|
* **Explication :** Il remplace ESLint et Prettier pour assurer le formatage et le linting du code. Son utilisation garantit une base de code propre, homogène et performante, tout en accélérant le workflow de développement.
|
||||||
|
|
||||||
|
* **Blind Indexing (Indexation Aveugle) :**
|
||||||
|
* **Définition :** Technique permettant de rechercher des données chiffrées sans les déchiffrer.
|
||||||
|
* **Explication :** Utilisé pour l'unicité des emails. On stocke un hash de l'email à côté de l'email chiffré PGP. Cela permet de vérifier si un email existe déjà en base sans avoir à déchiffrer tous les emails de la table.
|
||||||
|
|
||||||
|
* **CSP (Content Security Policy) :**
|
||||||
|
* **Définition :** Couche de sécurité supplémentaire qui aide à détecter et atténuer certains types d'attaques, comme le XSS.
|
||||||
|
* **Explication :** En définissant quelles sources de contenu (scripts, images) sont autorisées, Memegoat empêche l'exécution de code malveillant injecté par un tiers.
|
||||||
|
|
||||||
|
* **Docker :**
|
||||||
|
* **Définition :** Plateforme permettant de lancer des applications dans des conteneurs isolés.
|
||||||
|
* **Explication :** Docker permet d'empaqueter l'application avec toutes ses dépendances. Cela garantit que le projet fonctionnera de la même manière sur l'ordinateur du développeur, sur le serveur de test et en production.
|
||||||
|
|
||||||
|
* **Drizzle ORM :**
|
||||||
|
* **Définition :** "Object-Relational Mapping" moderne et léger pour TypeScript.
|
||||||
|
* **Explication :** Il permet d'interagir avec la base de données PostgreSQL en utilisant du code TypeScript typé. Contrairement à d'autres ORM plus lourds, Drizzle reste très proche du SQL natif, offrant ainsi de meilleures performances et une plus grande transparence.
|
||||||
|
|
||||||
|
* **JWT (JSON Web Token) :**
|
||||||
|
* **Définition :** Standard ouvert pour la création de jetons d'accès.
|
||||||
|
* **Explication :** Utilisé pour l'authentification, il permet de vérifier l'identité d'un utilisateur sans avoir à interroger la base de données à chaque requête. Memegoat utilise des jetons signés avec une rotation des "Refresh Tokens" pour une sécurité accrue.
|
||||||
|
|
||||||
|
* **JSON-LD :**
|
||||||
|
* **Définition :** JavaScript Object Notation for Linked Data.
|
||||||
|
* **Explication :** Format de données structurées utilisé pour annoter les pages web. Il permet aux moteurs de recherche de mieux comprendre le contenu (mèmes, auteurs, dates) et d'afficher des résultats enrichis (Rich Snippets) dans les pages de résultats.
|
||||||
|
|
||||||
|
* **ML-KEM (Kyber) :**
|
||||||
|
* **Définition :** Algorithme de mécanisme d'établissement de clé (KEM) résistant aux ordinateurs quantiques.
|
||||||
|
* **Explication :** Intégré de manière expérimentale, cet algorithme assure que les échanges de clés restent sécurisés même si un attaquant dispose d'un ordinateur quantique futur capable de casser les chiffrements traditionnels (RSA, ECC).
|
||||||
|
|
||||||
|
* **MFA (Multi-Factor Authentication) :**
|
||||||
|
* **Définition :** Méthode d'authentification nécessitant au moins deux preuves d'identité.
|
||||||
|
* **Explication :** Dans Memegoat, l'utilisateur doit fournir son mot de passe ET un code temporaire (TOTP) généré par une application mobile, doublant ainsi la protection du compte.
|
||||||
|
|
||||||
|
* **PGP (Pretty Good Privacy) :**
|
||||||
|
* **Définition :** Programme de chiffrement et de déchiffrement de données asymétrique.
|
||||||
|
* **Explication :** Utilisé pour chiffrer les données sensibles (comme les emails) directement dans la base de données. Même en cas de fuite de la base, les données restent illisibles sans la clé privée correspondante.
|
||||||
|
|
||||||
|
* **RBAC (Role-Based Access Control) :**
|
||||||
|
* **Définition :** Système de gestion des accès basé sur des rôles.
|
||||||
|
* **Explication :** Permet de définir précisément qui peut faire quoi (ex: un utilisateur peut poster, un modérateur peut supprimer n'importe quel post, un administrateur peut gérer les comptes).
|
||||||
|
|
||||||
|
* **S3 (MinIO) :**
|
||||||
|
* **Définition :** Protocole de stockage d'objets (Simple Storage Service).
|
||||||
|
* **Explication :** MinIO est une alternative open-source compatible avec Amazon S3. Il est utilisé pour stocker les fichiers médias (mèmes, GIFs) de manière performante et scalable, séparément de la base de données.
|
||||||
|
|
||||||
|
* **SSR / SSG (Next.js) :**
|
||||||
|
* **Définition :** Server-Side Rendering (rendu côté serveur) et Static Site Generation (génération de site statique).
|
||||||
|
* **Explication :** Ces techniques permettent de pré-rendre les pages HTML. Cela améliore considérablement le SEO et la vitesse de chargement initiale pour l'utilisateur.
|
||||||
|
|
||||||
|
* **NestJS :**
|
||||||
|
* **Définition :** Framework Node.js progressif pour la construction d'applications côté serveur efficaces et évolutives.
|
||||||
|
* **Explication :** Utilisé pour le backend de Memegoat, il offre une architecture modulaire et un support natif de TypeScript, ce qui facilite grandement la maintenance et le test des différents services (authentification, gestion des médias, etc.).
|
||||||
|
|
||||||
|
* **Next.js :**
|
||||||
|
* **Définition :** Framework React pour le développement web.
|
||||||
|
* **Explication :** Choisi pour le frontend, il permet de bénéficier du rendu hybride (SSR/SSG), optimisant ainsi les performances et le référencement naturel (SEO) de la plateforme.
|
||||||
|
|
||||||
|
* **TypeScript :**
|
||||||
|
* **Définition :** Sur-ensemble typé de JavaScript.
|
||||||
|
* **Explication :** Utilisé sur l'ensemble du projet (frontend et backend), il permet de détecter les erreurs dès la phase de développement grâce à un typage statique rigoureux, améliorant ainsi la robustesse globale du code.
|
||||||
|
|
||||||
|
* **WAI-ARIA :**
|
||||||
|
* **Définition :** Web Accessibility Initiative - Accessible Rich Internet Applications.
|
||||||
|
* **Explication :** Ensemble de spécifications techniques qui définissent des moyens de rendre le contenu Web et les applications Web plus accessibles, notamment pour les personnes handicapées utilisant des technologies d'assistance comme les lecteurs d'écran.
|
||||||
|
|
||||||
|
* **Zod :**
|
||||||
|
* **Définition :** Bibliothèque de déclaration et de validation de schéma TypeScript.
|
||||||
|
* **Explication :** Elle est utilisée pour valider toutes les données entrant dans l'application (formulaires, requêtes API). Si les données ne correspondent pas au schéma attendu, elles sont rejetées immédiatement, évitant ainsi des erreurs ou des failles de sécurité.
|
||||||
|
|
||||||
|
### Annexe 4 - Licences et bibliothèques
|
||||||
|
|
||||||
|
Le projet Memegoat repose exclusivement sur des technologies Open-Source respectueuses de la liberté logicielle.
|
||||||
|
|
||||||
|
#### Frameworks et Coeur du système
|
||||||
|
- **NestJS** : Licence MIT.
|
||||||
|
- **Next.js** : Licence MIT.
|
||||||
|
- **React** : Licence MIT.
|
||||||
|
- **TypeScript** : Licence Apache 2.0.
|
||||||
|
|
||||||
|
#### Gestion des données et Sécurité
|
||||||
|
- **PostgreSQL** : Licence PostgreSQL (type BSD/MIT).
|
||||||
|
- **Drizzle ORM** : Licence Apache 2.0.
|
||||||
|
- **Redis** : Licence BSD 3-Clause.
|
||||||
|
- **Argon2 (node-rs)** : Licence MIT.
|
||||||
|
- **Jose (JWT)** : Licence MIT.
|
||||||
|
- **@noble/post-quantum** : Licence MIT.
|
||||||
|
|
||||||
|
#### Interface et Expérience Utilisateur
|
||||||
|
- **Tailwind CSS** : Licence MIT.
|
||||||
|
- **Radix UI / Shadcn UI** : Licence MIT.
|
||||||
|
- **Lucide React (Icônes)** : Licence ISC.
|
||||||
|
|
||||||
|
#### Traitement Média et Utilitaires
|
||||||
|
- **Sharp** : Licence Apache 2.0.
|
||||||
|
- **FFmpeg** : Licence LGPL / GPL (utilisé via wrapper fluent-ffmpeg).
|
||||||
|
- **ClamAV** : Licence GPL.
|
||||||
|
- **MinIO** : Licence GNU AGPL v3.
|
||||||
@@ -3,6 +3,18 @@ import type { NextConfig } from "next";
|
|||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
/* config options here */
|
||||||
reactCompiler: true,
|
reactCompiler: true,
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "memegoat.fr",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "api.memegoat.fr",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "@memegoat/frontend",
|
"name": "@memegoat/frontend",
|
||||||
"version": "1.0.4",
|
"version": "1.5.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "biome check",
|
"lint": "biome check",
|
||||||
|
"lint:write": "biome check --write",
|
||||||
"format": "biome format --write"
|
"format": "biome format --write"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { CategoryService } from "@/services/category.service";
|
||||||
|
import type { Category } from "@/types/content";
|
||||||
|
|
||||||
|
interface CategoryDialogProps {
|
||||||
|
category?: Category | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoryDialog({
|
||||||
|
category,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSuccess,
|
||||||
|
}: CategoryDialogProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<Partial<Category>>({
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
iconUrl: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (category) {
|
||||||
|
form.reset({
|
||||||
|
name: category.name,
|
||||||
|
description: category.description || "",
|
||||||
|
iconUrl: category.iconUrl || "",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
form.reset({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
iconUrl: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [category, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (values: Partial<Category>) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (category) {
|
||||||
|
await CategoryService.update(category.id, values);
|
||||||
|
} else {
|
||||||
|
await CategoryService.create(values);
|
||||||
|
}
|
||||||
|
onSuccess();
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{category ? "Modifier la catégorie" : "Créer une catégorie"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
rules={{ required: "Le nom est requis" }}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Nom</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Nom de la catégorie" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea {...field} placeholder="Description (optionnel)" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="iconUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>URL de l'icône</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="https://..." />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading ? "Enregistrement..." : "Enregistrer"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { Edit, MoreHorizontal, Plus, Trash2 } from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -12,32 +23,66 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { CategoryService } from "@/services/category.service";
|
import { CategoryService } from "@/services/category.service";
|
||||||
import type { Category } from "@/types/content";
|
import type { Category } from "@/types/content";
|
||||||
|
import { CategoryDialog } from "./category-dialog";
|
||||||
|
|
||||||
export default function AdminCategoriesPage() {
|
export default function AdminCategoriesPage() {
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<Category | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchCategories = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
CategoryService.getAll()
|
CategoryService.getAll()
|
||||||
.then(setCategories)
|
.then(setCategories)
|
||||||
.catch((err) => console.error(err))
|
.catch((err) => console.error(err))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCategories();
|
||||||
|
}, [fetchCategories]);
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm("Êtes-vous sûr de vouloir supprimer cette catégorie ?")) return;
|
||||||
|
try {
|
||||||
|
await CategoryService.remove(id);
|
||||||
|
setCategories(categories.filter((c) => c.id !== id));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (category: Category) => {
|
||||||
|
setSelectedCategory(category);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
setSelectedCategory(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
|
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-3xl font-bold tracking-tight">
|
<h2 className="text-3xl font-bold tracking-tight">
|
||||||
Catégories ({categories.length})
|
Catégories ({categories.length})
|
||||||
</h2>
|
</h2>
|
||||||
|
<Button onClick={handleCreate}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" /> Ajouter une catégorie
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border bg-card">
|
<div className="rounded-md border bg-card">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Nom</TableHead>
|
<TableHead>Nom</TableHead>
|
||||||
<TableHead>Slug</TableHead>
|
<TableHead className="hidden sm:table-cell">Slug</TableHead>
|
||||||
<TableHead>Description</TableHead>
|
<TableHead className="hidden md:table-cell">Description</TableHead>
|
||||||
|
<TableHead className="w-[100px]"></TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -48,17 +93,20 @@ export default function AdminCategoriesPage() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<Skeleton className="h-4 w-[150px]" />
|
<Skeleton className="h-4 w-[150px]" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="hidden sm:table-cell">
|
||||||
<Skeleton className="h-4 w-[150px]" />
|
<Skeleton className="h-4 w-[150px]" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="hidden md:table-cell">
|
||||||
<Skeleton className="h-4 w-[250px]" />
|
<Skeleton className="h-4 w-[250px]" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-8 w-8 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
) : categories.length === 0 ? (
|
) : categories.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={3} className="text-center h-24">
|
<TableCell colSpan={4} className="text-center h-24">
|
||||||
Aucune catégorie trouvée.
|
Aucune catégorie trouvée.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -66,18 +114,61 @@ export default function AdminCategoriesPage() {
|
|||||||
categories.map((category) => (
|
categories.map((category) => (
|
||||||
<TableRow key={category.id}>
|
<TableRow key={category.id}>
|
||||||
<TableCell className="font-medium whitespace-nowrap">
|
<TableCell className="font-medium whitespace-nowrap">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{category.iconUrl && (
|
||||||
|
<div className="relative h-6 w-6">
|
||||||
|
<Image
|
||||||
|
src={category.iconUrl}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
className="rounded object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{category.name}
|
{category.name}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="whitespace-nowrap">{category.slug}</TableCell>
|
<TableCell className="whitespace-nowrap hidden sm:table-cell">
|
||||||
<TableCell className="text-muted-foreground">
|
{category.slug}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground hidden md:table-cell">
|
||||||
{category.description || "Aucune description"}
|
{category.description || "Aucune description"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Actions</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => handleEdit(category)}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" /> Modifier
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleDelete(category.id)}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" /> Supprimer
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
<CategoryDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={setDialogOpen}
|
||||||
|
category={selectedCategory}
|
||||||
|
onSuccess={fetchCategories}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { CategoryService } from "@/services/category.service";
|
||||||
|
import { ContentService } from "@/services/content.service";
|
||||||
|
import type { Category, Content } from "@/types/content";
|
||||||
|
|
||||||
|
interface ContentEditDialogProps {
|
||||||
|
content: Content | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContentEditDialog({
|
||||||
|
content,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSuccess,
|
||||||
|
}: ContentEditDialogProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
|
||||||
|
const form = useForm<{ title: string; categoryId: string }>({
|
||||||
|
defaultValues: {
|
||||||
|
title: "",
|
||||||
|
categoryId: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
CategoryService.getAll().then(setCategories).catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (content) {
|
||||||
|
form.reset({
|
||||||
|
title: content.title,
|
||||||
|
categoryId: content.categoryId || "none",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [content, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (values: { title: string; categoryId: string }) => {
|
||||||
|
if (!content) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
...values,
|
||||||
|
categoryId: values.categoryId === "none" ? null : values.categoryId,
|
||||||
|
};
|
||||||
|
await ContentService.updateAdmin(content.id, data);
|
||||||
|
onSuccess();
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Modifier le contenu</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="title"
|
||||||
|
rules={{ required: "Le titre est requis" }}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Titre</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Titre du contenu" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="categoryId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Catégorie</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
value={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Sélectionner une catégorie" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">Sans catégorie</SelectItem>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<SelectItem key={cat.id} value={cat.id}>
|
||||||
|
{cat.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading ? "Enregistrement..." : "Enregistrer"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,10 +2,26 @@
|
|||||||
|
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { fr } from "date-fns/locale";
|
import { fr } from "date-fns/locale";
|
||||||
import { Download, Eye, Image as ImageIcon, Trash2, Video } from "lucide-react";
|
import {
|
||||||
import { useEffect, useState } from "react";
|
Download,
|
||||||
|
Edit,
|
||||||
|
Eye,
|
||||||
|
Image as ImageIcon,
|
||||||
|
MoreHorizontal,
|
||||||
|
Trash2,
|
||||||
|
Video,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -17,13 +33,17 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { ContentService } from "@/services/content.service";
|
import { ContentService } from "@/services/content.service";
|
||||||
import type { Content } from "@/types/content";
|
import type { Content } from "@/types/content";
|
||||||
|
import { ContentEditDialog } from "./content-edit-dialog";
|
||||||
|
|
||||||
export default function AdminContentsPage() {
|
export default function AdminContentsPage() {
|
||||||
const [contents, setContents] = useState<Content[]>([]);
|
const [contents, setContents] = useState<Content[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
const [selectedContent, setSelectedContent] = useState<Content | null>(null);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchContents = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
ContentService.getExplore({ limit: 20 })
|
ContentService.getExplore({ limit: 20 })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setContents(res.data);
|
setContents(res.data);
|
||||||
@@ -33,6 +53,10 @@ export default function AdminContentsPage() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchContents();
|
||||||
|
}, [fetchContents]);
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!confirm("Êtes-vous sûr de vouloir supprimer ce contenu ?")) return;
|
if (!confirm("Êtes-vous sûr de vouloir supprimer ce contenu ?")) return;
|
||||||
|
|
||||||
@@ -45,6 +69,11 @@ export default function AdminContentsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEdit = (content: Content) => {
|
||||||
|
setSelectedContent(content);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
|
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -57,11 +86,11 @@ export default function AdminContentsPage() {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Contenu</TableHead>
|
<TableHead>Contenu</TableHead>
|
||||||
<TableHead>Catégorie</TableHead>
|
<TableHead className="hidden sm:table-cell">Catégorie</TableHead>
|
||||||
<TableHead>Auteur</TableHead>
|
<TableHead className="hidden md:table-cell">Auteur</TableHead>
|
||||||
<TableHead>Stats</TableHead>
|
<TableHead className="hidden lg:table-cell">Stats</TableHead>
|
||||||
<TableHead>Date</TableHead>
|
<TableHead className="hidden xl:table-cell">Date</TableHead>
|
||||||
<TableHead className="w-[50px]"></TableHead>
|
<TableHead className="w-[100px]"></TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -72,23 +101,26 @@ export default function AdminContentsPage() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<Skeleton className="h-10 w-[200px]" />
|
<Skeleton className="h-10 w-[200px]" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="hidden sm:table-cell">
|
||||||
<Skeleton className="h-4 w-[100px]" />
|
<Skeleton className="h-4 w-[100px]" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="hidden md:table-cell">
|
||||||
<Skeleton className="h-4 w-[100px]" />
|
<Skeleton className="h-4 w-[100px]" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="hidden lg:table-cell">
|
||||||
<Skeleton className="h-4 w-[80px]" />
|
<Skeleton className="h-4 w-[80px]" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="hidden xl:table-cell">
|
||||||
<Skeleton className="h-4 w-[100px]" />
|
<Skeleton className="h-4 w-[100px]" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-8 w-8 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
) : contents.length === 0 ? (
|
) : contents.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5} className="text-center h-24">
|
<TableCell colSpan={6} className="text-center h-24">
|
||||||
Aucun contenu trouvé.
|
Aucun contenu trouvé.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -98,7 +130,7 @@ export default function AdminContentsPage() {
|
|||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded bg-muted">
|
<div className="flex h-10 w-10 items-center justify-center rounded bg-muted">
|
||||||
{content.type === "image" ? (
|
{content.mimeType.startsWith("image/") ? (
|
||||||
<ImageIcon className="h-5 w-5 text-muted-foreground" />
|
<ImageIcon className="h-5 w-5 text-muted-foreground" />
|
||||||
) : (
|
) : (
|
||||||
<Video className="h-5 w-5 text-muted-foreground" />
|
<Video className="h-5 w-5 text-muted-foreground" />
|
||||||
@@ -112,13 +144,15 @@ export default function AdminContentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="hidden sm:table-cell">
|
||||||
<Badge variant="outline">
|
<Badge variant="outline">
|
||||||
{content.category?.name || "Sans catégorie"}
|
{content.category?.name || "Sans catégorie"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>@{content.author.username}</TableCell>
|
<TableCell className="hidden md:table-cell">
|
||||||
<TableCell>
|
@{content.author.username}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden lg:table-cell">
|
||||||
<div className="flex flex-col gap-1 text-xs">
|
<div className="flex flex-col gap-1 text-xs">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Eye className="h-3 w-3" /> {content.views}
|
<Eye className="h-3 w-3" /> {content.views}
|
||||||
@@ -128,18 +162,31 @@ export default function AdminContentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="whitespace-nowrap">
|
<TableCell className="hidden xl:table-cell whitespace-nowrap">
|
||||||
{format(new Date(content.createdAt), "dd/MM/yyyy", { locale: fr })}
|
{format(new Date(content.createdAt), "dd/MM/yyyy", { locale: fr })}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Button
|
<DropdownMenu>
|
||||||
variant="ghost"
|
<DropdownMenuTrigger asChild>
|
||||||
size="icon"
|
<Button variant="ghost" size="icon">
|
||||||
onClick={() => handleDelete(content.id)}
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
<span className="sr-only">Actions</span>
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => handleEdit(content)}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" /> Modifier
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleDelete(content.id)}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" /> Supprimer
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
@@ -147,6 +194,12 @@ export default function AdminContentsPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
<ContentEditDialog
|
||||||
|
content={selectedContent}
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={setDialogOpen}
|
||||||
|
onSuccess={fetchContents}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,18 @@
|
|||||||
|
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { fr } from "date-fns/locale";
|
import { fr } from "date-fns/locale";
|
||||||
import { Trash2 } from "lucide-react";
|
import { Edit, MoreHorizontal, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -17,13 +25,17 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { UserService } from "@/services/user.service";
|
import { UserService } from "@/services/user.service";
|
||||||
import type { User } from "@/types/user";
|
import type { User } from "@/types/user";
|
||||||
|
import { UserEditDialog } from "./user-edit-dialog";
|
||||||
|
|
||||||
export default function AdminUsersPage() {
|
export default function AdminUsersPage() {
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchUsers = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
UserService.getUsersAdmin()
|
UserService.getUsersAdmin()
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setUsers(res.data);
|
setUsers(res.data);
|
||||||
@@ -35,6 +47,10 @@ export default function AdminUsersPage() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers();
|
||||||
|
}, [fetchUsers]);
|
||||||
|
|
||||||
const handleDelete = async (uuid: string) => {
|
const handleDelete = async (uuid: string) => {
|
||||||
if (
|
if (
|
||||||
!confirm(
|
!confirm(
|
||||||
@@ -52,6 +68,11 @@ export default function AdminUsersPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEdit = (user: User) => {
|
||||||
|
setSelectedUser(user);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
|
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -64,11 +85,13 @@ export default function AdminUsersPage() {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Utilisateur</TableHead>
|
<TableHead>Utilisateur</TableHead>
|
||||||
<TableHead>Email</TableHead>
|
<TableHead className="hidden md:table-cell">Email</TableHead>
|
||||||
<TableHead>Rôle</TableHead>
|
<TableHead>Rôle</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead className="hidden sm:table-cell">Status</TableHead>
|
||||||
<TableHead>Date d'inscription</TableHead>
|
<TableHead className="hidden lg:table-cell">
|
||||||
<TableHead className="w-[50px]"></TableHead>
|
Date d'inscription
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[100px]"></TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -79,23 +102,26 @@ export default function AdminUsersPage() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<Skeleton className="h-4 w-[150px]" />
|
<Skeleton className="h-4 w-[150px]" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="hidden md:table-cell">
|
||||||
<Skeleton className="h-4 w-[200px]" />
|
<Skeleton className="h-4 w-[200px]" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Skeleton className="h-4 w-[50px]" />
|
<Skeleton className="h-4 w-[50px]" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="hidden sm:table-cell">
|
||||||
<Skeleton className="h-4 w-[80px]" />
|
<Skeleton className="h-4 w-[80px]" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="hidden lg:table-cell">
|
||||||
<Skeleton className="h-4 w-[100px]" />
|
<Skeleton className="h-4 w-[100px]" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-8 w-8 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
) : users.length === 0 ? (
|
) : users.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5} className="text-center h-24">
|
<TableCell colSpan={6} className="text-center h-24">
|
||||||
Aucun utilisateur trouvé.
|
Aucun utilisateur trouvé.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -106,29 +132,50 @@ export default function AdminUsersPage() {
|
|||||||
{user.displayName || user.username}
|
{user.displayName || user.username}
|
||||||
<div className="text-xs text-muted-foreground">@{user.username}</div>
|
<div className="text-xs text-muted-foreground">@{user.username}</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{user.email}</TableCell>
|
<TableCell className="hidden md:table-cell">{user.email}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={user.role === "admin" ? "default" : "secondary"}>
|
<Badge variant={user.role === "admin" ? "default" : "secondary"}>
|
||||||
{user.role}
|
{user.role}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="hidden sm:table-cell">
|
||||||
<Badge variant={user.status === "active" ? "success" : "destructive"}>
|
<Badge
|
||||||
|
variant={
|
||||||
|
user.status === "active"
|
||||||
|
? "success"
|
||||||
|
: user.status === "suspended"
|
||||||
|
? "destructive"
|
||||||
|
: "secondary"
|
||||||
|
}
|
||||||
|
>
|
||||||
{user.status}
|
{user.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="whitespace-nowrap">
|
<TableCell className="hidden lg:table-cell whitespace-nowrap">
|
||||||
{format(new Date(user.createdAt), "PPP", { locale: fr })}
|
{format(new Date(user.createdAt), "PPP", { locale: fr })}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Button
|
<DropdownMenu>
|
||||||
variant="ghost"
|
<DropdownMenuTrigger asChild>
|
||||||
size="icon"
|
<Button variant="ghost" size="icon">
|
||||||
onClick={() => handleDelete(user.uuid)}
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
<span className="sr-only">Actions</span>
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => handleEdit(user)}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" /> Modifier
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleDelete(user.uuid)}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" /> Supprimer
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
@@ -136,6 +183,12 @@ export default function AdminUsersPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
<UserEditDialog
|
||||||
|
user={selectedUser}
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={setDialogOpen}
|
||||||
|
onSuccess={fetchUsers}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
153
frontend/src/app/(dashboard)/admin/users/user-edit-dialog.tsx
Normal file
153
frontend/src/app/(dashboard)/admin/users/user-edit-dialog.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { UserService } from "@/services/user.service";
|
||||||
|
import type { User } from "@/types/user";
|
||||||
|
|
||||||
|
interface UserEditDialogProps {
|
||||||
|
user: User | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserEditDialog({
|
||||||
|
user,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSuccess,
|
||||||
|
}: UserEditDialogProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<Partial<User>>({
|
||||||
|
defaultValues: {
|
||||||
|
role: "user",
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
form.reset({
|
||||||
|
role: user.role || "user",
|
||||||
|
status: user.status || "active",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [user, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (values: Partial<User>) => {
|
||||||
|
if (!user) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await UserService.updateAdmin(user.uuid, values);
|
||||||
|
onSuccess();
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Modifier l'utilisateur @{user?.username}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="role"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Rôle</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
value={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Sélectionner un rôle" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="user">Utilisateur</SelectItem>
|
||||||
|
<SelectItem value="moderator">Modérateur</SelectItem>
|
||||||
|
<SelectItem value="admin">Administrateur</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="status"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Statut</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
value={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Sélectionner un statut" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="active">Actif</SelectItem>
|
||||||
|
<SelectItem value="suspended">Suspendu</SelectItem>
|
||||||
|
<SelectItem value="pending">En attente</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading ? "Enregistrement..." : "Enregistrer"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
SidebarProvider,
|
SidebarProvider,
|
||||||
SidebarTrigger,
|
SidebarTrigger,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { UserNavMobile } from "@/components/user-nav-mobile";
|
import { UserNavMobile } from "@/components/user-nav-mobile";
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default function DashboardLayout({
|
||||||
@@ -26,7 +27,9 @@ export default function DashboardLayout({
|
|||||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4 lg:hidden sticky top-0 bg-background z-40">
|
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4 lg:hidden sticky top-0 bg-background z-40">
|
||||||
<SidebarTrigger />
|
<SidebarTrigger />
|
||||||
<div className="flex-1 flex justify-center">
|
<div className="flex-1 flex justify-center">
|
||||||
<span className="font-bold text-primary text-lg">MemeGoat</span>
|
<span className="font-bold text-primary text-xl tracking-tight">
|
||||||
|
MemeGoat
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
@@ -46,6 +49,7 @@ export default function DashboardLayout({
|
|||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
<Toaster />
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Calendar, Camera, LogIn, LogOut, Settings } from "lucide-react";
|
import {
|
||||||
|
Calendar,
|
||||||
|
Camera,
|
||||||
|
LogIn,
|
||||||
|
LogOut,
|
||||||
|
Settings,
|
||||||
|
Share2,
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
@@ -59,6 +66,12 @@ export default function ProfilePage() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleShareProfile = () => {
|
||||||
|
const url = `${window.location.origin}/user/${user?.username}`;
|
||||||
|
navigator.clipboard.writeText(url);
|
||||||
|
toast.success("Lien du profil copié !");
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[400px] items-center justify-center">
|
<div className="flex h-[400px] items-center justify-center">
|
||||||
@@ -93,12 +106,12 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto py-8 px-4">
|
<div className="max-w-4xl mx-auto py-8 px-4">
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-8 border shadow-sm mb-8">
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 md:p-8 border shadow-sm mb-8">
|
||||||
<div className="flex flex-col md:flex-row items-center gap-8">
|
<div className="flex flex-col md:flex-row items-center md:items-start gap-6 md:gap-8">
|
||||||
<div className="relative group">
|
<div className="relative group shrink-0">
|
||||||
<Avatar className="h-32 w-32 border-4 border-primary/10">
|
<Avatar className="h-24 w-24 md:h-32 md:w-32 border-4 border-primary/10">
|
||||||
<AvatarImage src={user.avatarUrl} alt={user.username} />
|
<AvatarImage src={user.avatarUrl} alt={user.username} />
|
||||||
<AvatarFallback className="text-4xl">
|
<AvatarFallback className="text-3xl md:text-4xl">
|
||||||
{user.username.slice(0, 2).toUpperCase()}
|
{user.username.slice(0, 2).toUpperCase()}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
@@ -106,8 +119,9 @@ export default function ProfilePage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleAvatarClick}
|
onClick={handleAvatarClick}
|
||||||
className="absolute inset-0 flex items-center justify-center bg-black/40 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
className="absolute inset-0 flex items-center justify-center bg-black/40 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
title="Changer l'avatar"
|
||||||
>
|
>
|
||||||
<Camera className="h-8 w-8" />
|
<Camera className="h-6 w-6 md:h-8 md:w-8" />
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
@@ -118,17 +132,21 @@ export default function ProfilePage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 text-center md:text-left space-y-4">
|
<div className="flex-1 text-center md:text-left space-y-4">
|
||||||
<div>
|
<div className="space-y-1">
|
||||||
<h1 className="text-3xl font-bold">
|
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
|
||||||
{user.displayName || user.username}
|
{user.displayName || user.username}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">@{user.username}</p>
|
<p className="text-muted-foreground font-medium">@{user.username}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{user.bio && (
|
{user.bio && (
|
||||||
<p className="max-w-md text-sm leading-relaxed">{user.bio}</p>
|
<p className="max-w-md text-sm md:text-base leading-relaxed text-balance">
|
||||||
|
{user.bio}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap justify-center md:justify-start gap-4 text-sm text-muted-foreground">
|
<div className="flex flex-wrap justify-center md:justify-start gap-4 text-sm text-muted-foreground">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1.5">
|
||||||
<Calendar className="h-4 w-4" />
|
<Calendar className="h-4 w-4" />
|
||||||
Membre depuis{" "}
|
Membre depuis{" "}
|
||||||
{new Date(user.createdAt).toLocaleDateString("fr-FR", {
|
{new Date(user.createdAt).toLocaleDateString("fr-FR", {
|
||||||
@@ -137,18 +155,28 @@ export default function ProfilePage() {
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap justify-center md:justify-start gap-2">
|
|
||||||
<Button asChild variant="outline" size="sm">
|
<div className="flex flex-wrap justify-center md:justify-start gap-2 pt-2">
|
||||||
|
<Button asChild variant="outline" size="sm" className="h-9 px-4">
|
||||||
<Link href="/settings">
|
<Link href="/settings">
|
||||||
<Settings className="h-4 w-4 mr-2" />
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
Paramètres
|
Paramètres
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 px-4"
|
||||||
|
onClick={handleShareProfile}
|
||||||
|
>
|
||||||
|
<Share2 className="h-4 w-4 mr-2" />
|
||||||
|
Partager
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => logout()}
|
onClick={() => logout()}
|
||||||
className="text-red-500 hover:text-red-600 hover:bg-red-50"
|
className="h-9 px-4 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4 mr-2" />
|
<LogOut className="h-4 w-4 mr-2" />
|
||||||
Déconnexion
|
Déconnexion
|
||||||
@@ -159,18 +187,18 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs value={tab} className="w-full">
|
<Tabs value={tab} className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-2 mb-8">
|
<TabsList className="grid w-full grid-cols-2 mb-8 h-11">
|
||||||
<TabsTrigger value="memes" asChild>
|
<TabsTrigger value="memes" asChild className="text-sm font-semibold">
|
||||||
<Link href="/profile?tab=memes">Mes Mèmes</Link>
|
<Link href="/profile?tab=memes">Mes Mèmes</Link>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="favorites" asChild>
|
<TabsTrigger value="favorites" asChild className="text-sm font-semibold">
|
||||||
<Link href="/profile?tab=favorites">Mes Favoris</Link>
|
<Link href="/profile?tab=favorites">Mes Favoris</Link>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="memes">
|
<TabsContent value="memes" className="mt-0 outline-none">
|
||||||
<ContentList fetchFn={fetchMyMemes} />
|
<ContentList fetchFn={fetchMyMemes} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="favorites">
|
<TabsContent value="favorites" className="mt-0 outline-none">
|
||||||
<ContentList fetchFn={fetchMyFavorites} />
|
<ContentList fetchFn={fetchMyFavorites} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -2,19 +2,34 @@
|
|||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import {
|
import {
|
||||||
|
AlertTriangle,
|
||||||
Laptop,
|
Laptop,
|
||||||
Loader2,
|
Loader2,
|
||||||
Moon,
|
Moon,
|
||||||
Palette,
|
Palette,
|
||||||
Save,
|
Save,
|
||||||
|
Settings,
|
||||||
Sun,
|
Sun,
|
||||||
|
Trash2,
|
||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -49,8 +64,10 @@ type SettingsFormValues = z.infer<typeof settingsSchema>;
|
|||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const { user, isLoading, refreshUser } = useAuth();
|
const { user, isLoading, refreshUser, logout } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
const [isSaving, setIsSaving] = React.useState(false);
|
const [isSaving, setIsSaving] = React.useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||||
const [mounted, setMounted] = React.useState(false);
|
const [mounted, setMounted] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -111,18 +128,37 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteAccount = async () => {
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await UserService.removeMe();
|
||||||
|
toast.success("Votre compte a été supprimé.");
|
||||||
|
logout();
|
||||||
|
router.push("/");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error("Erreur lors de la suppression du compte.");
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto py-12 px-4">
|
<div className="max-w-2xl mx-auto py-12 px-4">
|
||||||
<div className="flex items-center gap-3 mb-8">
|
<div className="flex items-center gap-3 mb-8">
|
||||||
<div className="bg-primary/10 p-3 rounded-xl">
|
<div className="bg-primary/10 p-3 rounded-xl">
|
||||||
<UserIcon className="h-6 w-6 text-primary" />
|
<Settings className="h-6 w-6 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold">Paramètres du profil</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Paramètres</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<div className="space-y-8">
|
||||||
<CardHeader>
|
<Card className="border-none shadow-sm">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<UserIcon className="h-5 w-5 text-primary" />
|
||||||
<CardTitle>Informations personnelles</CardTitle>
|
<CardTitle>Informations personnelles</CardTitle>
|
||||||
|
</div>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Mettez à jour vos informations publiques. Ces données seront visibles par
|
Mettez à jour vos informations publiques. Ces données seront visibles par
|
||||||
les autres utilisateurs.
|
les autres utilisateurs.
|
||||||
@@ -131,19 +167,18 @@ export default function SettingsPage() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-6">
|
||||||
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Nom d'utilisateur</FormLabel>
|
<FormLabel>Nom d'utilisateur</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
value={user.username}
|
value={user.username}
|
||||||
disabled
|
disabled
|
||||||
className="bg-zinc-50 dark:bg-zinc-900"
|
className="bg-muted cursor-not-allowed"
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>Identifiant unique non modifiable.</FormDescription>
|
||||||
Le nom d'utilisateur ne peut pas être modifié.
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
@@ -155,13 +190,12 @@ export default function SettingsPage() {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="Votre nom" {...field} />
|
<Input placeholder="Votre nom" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>Nom visible sur votre profil.</FormDescription>
|
||||||
Le nom qui sera affiché sur votre profil et vos mèmes.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -172,7 +206,7 @@ export default function SettingsPage() {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Racontez-nous quelque chose sur vous..."
|
placeholder="Racontez-nous quelque chose sur vous..."
|
||||||
className="resize-none"
|
className="resize-none min-h-[100px]"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -185,7 +219,8 @@ export default function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" disabled={isSaving} className="w-full sm:w-auto">
|
<div className="flex justify-end border-t pt-6">
|
||||||
|
<Button type="submit" disabled={isSaving} className="min-w-[150px]">
|
||||||
{isSaving ? (
|
{isSaving ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
@@ -194,17 +229,19 @@ export default function SettingsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
Enregistrer les modifications
|
Enregistrer
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="mt-8">
|
|
||||||
<CardHeader>
|
<Card className="border-none shadow-sm">
|
||||||
<div className="flex items-center gap-2">
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<Palette className="h-5 w-5 text-primary" />
|
<Palette className="h-5 w-5 text-primary" />
|
||||||
<CardTitle>Apparence</CardTitle>
|
<CardTitle>Apparence</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
@@ -218,39 +255,98 @@ export default function SettingsPage() {
|
|||||||
onValueChange={(value) => setTheme(value)}
|
onValueChange={(value) => setTheme(value)}
|
||||||
className="grid grid-cols-1 sm:grid-cols-3 gap-4"
|
className="grid grid-cols-1 sm:grid-cols-3 gap-4"
|
||||||
>
|
>
|
||||||
<div>
|
<div className="relative">
|
||||||
<RadioGroupItem value="light" id="light" className="peer sr-only" />
|
<RadioGroupItem value="light" id="light" className="peer sr-only" />
|
||||||
<Label
|
<Label
|
||||||
htmlFor="light"
|
htmlFor="light"
|
||||||
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer"
|
className="flex flex-col items-center justify-between rounded-xl border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer transition-all"
|
||||||
>
|
>
|
||||||
<Sun className="mb-3 h-6 w-6" />
|
<Sun className="mb-3 h-6 w-6" />
|
||||||
<span>Clair</span>
|
<span className="text-sm font-semibold">Clair</span>
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
|
<div className="relative">
|
||||||
<RadioGroupItem value="dark" id="dark" className="peer sr-only" />
|
<RadioGroupItem value="dark" id="dark" className="peer sr-only" />
|
||||||
<Label
|
<Label
|
||||||
htmlFor="dark"
|
htmlFor="dark"
|
||||||
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer"
|
className="flex flex-col items-center justify-between rounded-xl border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer transition-all"
|
||||||
>
|
>
|
||||||
<Moon className="mb-3 h-6 w-6" />
|
<Moon className="mb-3 h-6 w-6" />
|
||||||
<span>Sombre</span>
|
<span className="text-sm font-semibold">Sombre</span>
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
|
<div className="relative">
|
||||||
<RadioGroupItem value="system" id="system" className="peer sr-only" />
|
<RadioGroupItem value="system" id="system" className="peer sr-only" />
|
||||||
<Label
|
<Label
|
||||||
htmlFor="system"
|
htmlFor="system"
|
||||||
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer"
|
className="flex flex-col items-center justify-between rounded-xl border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer transition-all"
|
||||||
>
|
>
|
||||||
<Laptop className="mb-3 h-6 w-6" />
|
<Laptop className="mb-3 h-6 w-6" />
|
||||||
<span>Système</span>
|
<span className="text-sm font-semibold">Système</span>
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-destructive/20 shadow-sm bg-destructive/5">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||||
|
<CardTitle className="text-destructive">Zone de danger</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription className="text-destructive/80 font-medium">
|
||||||
|
Actions irréversibles concernant votre compte.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 p-4 rounded-lg bg-white dark:bg-zinc-900 border border-destructive/20">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-bold">Supprimer mon compte</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Toutes vos données seront supprimées définitivement.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="destructive" size="sm" className="font-semibold">
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Supprimer le compte
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Êtes-vous absolument sûr ?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Cette action est irréversible. Votre compte sera supprimé
|
||||||
|
définitivement ainsi que tous vos mèmes et vos favoris.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isDeleting}>Annuler</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDeleteAccount}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{isDeleting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Suppression...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Confirmer la suppression"
|
||||||
|
)}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import type { Category } from "@/types/content";
|
|||||||
|
|
||||||
const uploadSchema = z.object({
|
const uploadSchema = z.object({
|
||||||
title: z.string().min(3, "Le titre doit faire au moins 3 caractères"),
|
title: z.string().min(3, "Le titre doit faire au moins 3 caractères"),
|
||||||
type: z.enum(["meme", "gif"]),
|
type: z.enum(["meme", "gif", "video"]),
|
||||||
categoryId: z.string().optional(),
|
categoryId: z.string().optional(),
|
||||||
tags: z.string().optional(),
|
tags: z.string().optional(),
|
||||||
});
|
});
|
||||||
@@ -112,6 +112,16 @@ export default function UploadPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setFile(selectedFile);
|
setFile(selectedFile);
|
||||||
|
|
||||||
|
// Auto-détection du type
|
||||||
|
if (selectedFile.type === "image/gif") {
|
||||||
|
form.setValue("type", "gif");
|
||||||
|
} else if (selectedFile.type.startsWith("video/")) {
|
||||||
|
form.setValue("type", "video");
|
||||||
|
} else {
|
||||||
|
form.setValue("type", "meme");
|
||||||
|
}
|
||||||
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onloadend = () => {
|
reader.onloadend = () => {
|
||||||
setPreview(reader.result as string);
|
setPreview(reader.result as string);
|
||||||
@@ -182,7 +192,7 @@ export default function UploadPage() {
|
|||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<FormLabel>Fichier (Image ou GIF)</FormLabel>
|
<FormLabel>Fichier (Image, GIF ou Vidéo)</FormLabel>
|
||||||
{!preview ? (
|
{!preview ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -194,25 +204,31 @@ export default function UploadPage() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="font-medium">Cliquez pour choisir un fichier</p>
|
<p className="font-medium">Cliquez pour choisir un fichier</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
PNG, JPG ou GIF jusqu'à 10Mo
|
PNG, JPG, GIF, MP4 ou MOV jusqu'à 10Mo
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
id="file-upload"
|
id="file-upload"
|
||||||
type="file"
|
type="file"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
accept="image/*,.gif"
|
accept="image/*,video/mp4,video/webm,video/quicktime,.gif"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative rounded-lg overflow-hidden border bg-zinc-100 dark:bg-zinc-800">
|
<div className="relative rounded-lg overflow-hidden border bg-zinc-100 dark:bg-zinc-800">
|
||||||
<div className="relative h-[400px] w-full">
|
<div className="relative h-[400px] w-full flex items-center justify-center">
|
||||||
|
{file?.type.startsWith("video/") ? (
|
||||||
|
<video src={preview} controls className="max-h-full max-w-full">
|
||||||
|
<track kind="captions" />
|
||||||
|
</video>
|
||||||
|
) : (
|
||||||
<NextImage
|
<NextImage
|
||||||
src={preview}
|
src={preview}
|
||||||
alt="Preview"
|
alt="Preview"
|
||||||
fill
|
fill
|
||||||
className="object-contain"
|
className="object-contain"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -260,6 +276,7 @@ export default function UploadPage() {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="meme">Image fixe</SelectItem>
|
<SelectItem value="meme">Image fixe</SelectItem>
|
||||||
<SelectItem value="gif">GIF Animé</SelectItem>
|
<SelectItem value="gif">GIF Animé</SelectItem>
|
||||||
|
<SelectItem value="video">Vidéo</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Calendar, User as UserIcon } from "lucide-react";
|
import { Calendar, Share2, User as UserIcon } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { ContentList } from "@/components/content-list";
|
import { ContentList } from "@/components/content-list";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { ContentService } from "@/services/content.service";
|
import { ContentService } from "@/services/content.service";
|
||||||
import { UserService } from "@/services/user.service";
|
import { UserService } from "@/services/user.service";
|
||||||
@@ -31,6 +33,12 @@ export default function PublicProfilePage({
|
|||||||
[username],
|
[username],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleShareProfile = () => {
|
||||||
|
const url = `${window.location.origin}/user/${username}`;
|
||||||
|
navigator.clipboard.writeText(url);
|
||||||
|
toast.success("Lien du profil copié !");
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[400px] items-center justify-center">
|
<div className="flex h-[400px] items-center justify-center">
|
||||||
@@ -55,28 +63,28 @@ export default function PublicProfilePage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto py-8 px-4">
|
<div className="max-w-4xl mx-auto py-8 px-4">
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-8 border shadow-sm mb-8">
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 md:p-8 border shadow-sm mb-8">
|
||||||
<div className="flex flex-col md:flex-row items-center gap-8">
|
<div className="flex flex-col md:flex-row items-center md:items-start gap-6 md:gap-8">
|
||||||
<Avatar className="h-32 w-32 border-4 border-primary/10">
|
<Avatar className="h-24 w-24 md:h-32 md:w-32 border-4 border-primary/10">
|
||||||
<AvatarImage src={user.avatarUrl} alt={user.username} />
|
<AvatarImage src={user.avatarUrl} alt={user.username} />
|
||||||
<AvatarFallback className="text-4xl">
|
<AvatarFallback className="text-3xl md:text-4xl">
|
||||||
{user.username.slice(0, 2).toUpperCase()}
|
{user.username.slice(0, 2).toUpperCase()}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex-1 text-center md:text-left space-y-4">
|
<div className="flex-1 text-center md:text-left space-y-4">
|
||||||
<div>
|
<div className="space-y-1">
|
||||||
<h1 className="text-3xl font-bold">
|
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
|
||||||
{user.displayName || user.username}
|
{user.displayName || user.username}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">@{user.username}</p>
|
<p className="text-muted-foreground font-medium">@{user.username}</p>
|
||||||
</div>
|
</div>
|
||||||
{user.bio && (
|
{user.bio && (
|
||||||
<p className="max-w-md text-sm leading-relaxed mx-auto md:mx-0">
|
<p className="max-w-md text-sm md:text-base leading-relaxed mx-auto md:mx-0 text-balance">
|
||||||
{user.bio}
|
{user.bio}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap justify-center md:justify-start gap-4 text-sm text-muted-foreground">
|
<div className="flex flex-wrap justify-center md:justify-start gap-4 text-sm text-muted-foreground">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1.5">
|
||||||
<Calendar className="h-4 w-4" />
|
<Calendar className="h-4 w-4" />
|
||||||
Membre depuis{" "}
|
Membre depuis{" "}
|
||||||
{new Date(user.createdAt).toLocaleDateString("fr-FR", {
|
{new Date(user.createdAt).toLocaleDateString("fr-FR", {
|
||||||
@@ -85,12 +93,23 @@ export default function PublicProfilePage({
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex justify-center md:justify-start pt-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 px-4"
|
||||||
|
onClick={handleShareProfile}
|
||||||
|
>
|
||||||
|
<Share2 className="h-4 w-4 mr-2" />
|
||||||
|
Partager le profil
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<h2 className="text-xl font-bold border-b pb-4">Ses mèmes</h2>
|
<h2 className="text-xl md:text-2xl font-bold border-b pb-4">Ses mèmes</h2>
|
||||||
<ContentList fetchFn={fetchUserMemes} />
|
<ContentList fetchFn={fetchUserMemes} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ export const metadata: Metadata = {
|
|||||||
images: ["/memegoat-og.png"],
|
images: ["/memegoat-og.png"],
|
||||||
},
|
},
|
||||||
icons: "/memegoat-color.svg",
|
icons: "/memegoat-color.svg",
|
||||||
|
metadataBase: new URL(
|
||||||
|
process.env.NEXT_PUBLIC_APP_URL || "https://memegoat.fr",
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Eye, Heart, MoreHorizontal, Share2 } from "lucide-react";
|
import { Edit, Eye, Heart, MoreHorizontal, Share2, Trash2 } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -15,20 +15,37 @@ import {
|
|||||||
CardFooter,
|
CardFooter,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
import { useAuth } from "@/providers/auth-provider";
|
import { useAuth } from "@/providers/auth-provider";
|
||||||
import { ContentService } from "@/services/content.service";
|
import { ContentService } from "@/services/content.service";
|
||||||
import { FavoriteService } from "@/services/favorite.service";
|
import { FavoriteService } from "@/services/favorite.service";
|
||||||
import type { Content } from "@/types/content";
|
import type { Content } from "@/types/content";
|
||||||
|
import { UserContentEditDialog } from "./user-content-edit-dialog";
|
||||||
|
|
||||||
interface ContentCardProps {
|
interface ContentCardProps {
|
||||||
content: Content;
|
content: Content;
|
||||||
|
onUpdate?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContentCard({ content }: ContentCardProps) {
|
export function ContentCard({ content, onUpdate }: ContentCardProps) {
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated, user } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isLiked, setIsLiked] = React.useState(content.isLiked || false);
|
const [isLiked, setIsLiked] = React.useState(content.isLiked || false);
|
||||||
const [likesCount, setLikesCount] = React.useState(content.favoritesCount);
|
const [likesCount, setLikesCount] = React.useState(content.favoritesCount);
|
||||||
|
const [editDialogOpen, setEditDialogOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const isAuthor = user?.uuid === content.authorId;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setIsLiked(content.isLiked || false);
|
setIsLiked(content.isLiked || false);
|
||||||
@@ -71,12 +88,32 @@ export function ContentCard({ content }: ContentCardProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!confirm("Êtes-vous sûr de vouloir supprimer ce mème ?")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ContentService.remove(content.id);
|
||||||
|
toast.success("Mème supprimé !");
|
||||||
|
if (onUpdate) {
|
||||||
|
onUpdate();
|
||||||
|
} else {
|
||||||
|
// Si pas de onUpdate, on est probablement sur la page de détail
|
||||||
|
router.push("/");
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
toast.error("Erreur lors de la suppression.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Card className="overflow-hidden border-none shadow-sm hover:shadow-md transition-shadow">
|
<Card className="overflow-hidden border-none shadow-sm hover:shadow-md transition-shadow">
|
||||||
<CardHeader className="p-4 flex flex-row items-center space-y-0 gap-3">
|
<CardHeader className="p-4 flex flex-row items-center space-y-0 gap-3">
|
||||||
<Avatar className="h-8 w-8">
|
<Avatar className="h-8 w-8">
|
||||||
<AvatarImage src={content.author.avatarUrl} />
|
<AvatarImage src={content.author.avatarUrl} />
|
||||||
<AvatarFallback>{content.author.username[0].toUpperCase()}</AvatarFallback>
|
<AvatarFallback>
|
||||||
|
{content.author.username[0].toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<Link
|
<Link
|
||||||
@@ -89,19 +126,48 @@ export function ContentCard({ content }: ContentCardProps) {
|
|||||||
{new Date(content.createdAt).toLocaleDateString("fr-FR")}
|
{new Date(content.createdAt).toLocaleDateString("fr-FR")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="icon" className="ml-auto h-8 w-8">
|
|
||||||
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{isAuthor && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem onClick={() => setEditDialogOpen(true)}>
|
||||||
|
<Edit className="h-4 w-4 mr-2" />
|
||||||
|
Modifier
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Supprimer
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem onClick={() => toast.success("Lien copié !")}>
|
||||||
|
<Share2 className="h-4 w-4 mr-2" />
|
||||||
|
Partager
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0 relative bg-zinc-100 dark:bg-zinc-900 aspect-square flex items-center justify-center">
|
<CardContent className="p-0 relative bg-zinc-200 dark:bg-zinc-900 aspect-square flex items-center justify-center">
|
||||||
<Link href={`/meme/${content.slug}`} className="w-full h-full relative">
|
<Link href={`/meme/${content.slug}`} className="w-full h-full relative">
|
||||||
{content.type === "image" ? (
|
{content.mimeType.startsWith("image/") ? (
|
||||||
<Image
|
<Image
|
||||||
src={content.url}
|
src={content.url}
|
||||||
alt={content.title}
|
alt={content.title}
|
||||||
fill
|
fill
|
||||||
className="object-contain"
|
className="object-contain"
|
||||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||||
|
priority={false}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<video
|
<video
|
||||||
@@ -110,6 +176,7 @@ export function ContentCard({ content }: ContentCardProps) {
|
|||||||
autoPlay
|
autoPlay
|
||||||
muted
|
muted
|
||||||
loop
|
loop
|
||||||
|
playsInline
|
||||||
className="w-full h-full object-contain"
|
className="w-full h-full object-contain"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -118,27 +185,60 @@ export function ContentCard({ content }: ContentCardProps) {
|
|||||||
<CardFooter className="p-4 flex flex-col gap-4">
|
<CardFooter className="p-4 flex flex-col gap-4">
|
||||||
<div className="w-full flex items-center justify-between">
|
<div className="w-full flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={`gap-1.5 h-8 ${isLiked ? "text-red-500 hover:text-red-600" : ""}`}
|
className={`gap-1.5 h-8 px-2 ${isLiked ? "text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20" : ""}`}
|
||||||
onClick={handleLike}
|
onClick={handleLike}
|
||||||
>
|
>
|
||||||
<Heart className={`h-4 w-4 ${isLiked ? "fill-current" : ""}`} />
|
<Heart className={`h-4 w-4 ${isLiked ? "fill-current" : ""}`} />
|
||||||
<span className="text-xs">{likesCount}</span>
|
<span className="text-xs font-medium">{likesCount}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="gap-1.5 h-8">
|
</TooltipTrigger>
|
||||||
<Eye className="h-4 w-4" />
|
<TooltipContent>Liker</TooltipContent>
|
||||||
<span className="text-xs">{content.views}</span>
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5 h-8 px-2 cursor-default"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-xs font-medium">{content.views}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Vues</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(
|
||||||
|
`${window.location.origin}/meme/${content.slug}`,
|
||||||
|
);
|
||||||
|
toast.success("Lien copié !");
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Share2 className="h-4 w-4" />
|
<Share2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Partager</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="text-xs h-8"
|
className="text-xs h-8 font-semibold"
|
||||||
onClick={handleUse}
|
onClick={handleUse}
|
||||||
>
|
>
|
||||||
Utiliser
|
Utiliser
|
||||||
@@ -146,13 +246,22 @@ export function ContentCard({ content }: ContentCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full space-y-2">
|
<div className="w-full space-y-2">
|
||||||
<h3 className="font-medium text-sm line-clamp-2">{content.title}</h3>
|
<Link href={`/meme/${content.slug}`}>
|
||||||
<div className="flex flex-wrap gap-1">
|
<h3 className="font-semibold text-base line-clamp-2 hover:text-primary transition-colors">
|
||||||
|
{content.title}
|
||||||
|
</h3>
|
||||||
|
</Link>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{content.category && (
|
||||||
|
<Badge variant="outline" className="text-[10px] py-0 px-2 bg-muted/50">
|
||||||
|
{content.category.name}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{content.tags.slice(0, 3).map((tag, _i) => (
|
{content.tags.slice(0, 3).map((tag, _i) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={typeof tag === "string" ? tag : tag.id}
|
key={typeof tag === "string" ? tag : tag.id}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="text-[10px] py-0 px-1.5"
|
className="text-[10px] py-0 px-2"
|
||||||
>
|
>
|
||||||
#{typeof tag === "string" ? tag : tag.name}
|
#{typeof tag === "string" ? tag : tag.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -161,5 +270,12 @@ export function ContentCard({ content }: ContentCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
|
<UserContentEditDialog
|
||||||
|
content={content}
|
||||||
|
open={editDialogOpen}
|
||||||
|
onOpenChange={setEditDialogOpen}
|
||||||
|
onSuccess={() => onUpdate?.()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,27 @@ export function ContentList({ fetchFn, title }: ContentListProps) {
|
|||||||
const [offset, setOffset] = React.useState(0);
|
const [offset, setOffset] = React.useState(0);
|
||||||
const [hasMore, setHasMore] = React.useState(true);
|
const [hasMore, setHasMore] = React.useState(true);
|
||||||
|
|
||||||
|
const fetchInitial = React.useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetchFn({
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
setContents(response.data);
|
||||||
|
setOffset(0);
|
||||||
|
setHasMore(response.data.length === 10);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch contents:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [fetchFn]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetchInitial();
|
||||||
|
}, [fetchInitial]);
|
||||||
|
|
||||||
const loadMore = React.useCallback(async () => {
|
const loadMore = React.useCallback(async () => {
|
||||||
if (!hasMore || loading) return;
|
if (!hasMore || loading) return;
|
||||||
|
|
||||||
@@ -46,32 +67,12 @@ export function ContentList({ fetchFn, title }: ContentListProps) {
|
|||||||
onLoadMore: loadMore,
|
onLoadMore: loadMore,
|
||||||
});
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const fetchInitial = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await fetchFn({
|
|
||||||
limit: 10,
|
|
||||||
offset: 0,
|
|
||||||
});
|
|
||||||
setContents(response.data);
|
|
||||||
setHasMore(response.data.length === 10);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch contents:", error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchInitial();
|
|
||||||
}, [fetchFn]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto py-8 px-4 space-y-8">
|
<div className="max-w-7xl mx-auto py-8 px-4 space-y-8">
|
||||||
{title && <h1 className="text-2xl font-bold">{title}</h1>}
|
{title && <h1 className="text-2xl font-bold">{title}</h1>}
|
||||||
<div className="flex flex-col gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
{contents.map((content) => (
|
{contents.map((content) => (
|
||||||
<ContentCard key={content.id} content={content} />
|
<ContentCard key={content.id} content={content} onUpdate={fetchInitial} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
158
frontend/src/components/user-content-edit-dialog.tsx
Normal file
158
frontend/src/components/user-content-edit-dialog.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { CategoryService } from "@/services/category.service";
|
||||||
|
import { ContentService } from "@/services/content.service";
|
||||||
|
import type { Category, Content } from "@/types/content";
|
||||||
|
|
||||||
|
interface UserContentEditDialogProps {
|
||||||
|
content: Content | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserContentEditDialog({
|
||||||
|
content,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSuccess,
|
||||||
|
}: UserContentEditDialogProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
|
||||||
|
const form = useForm<{ title: string; categoryId: string }>({
|
||||||
|
defaultValues: {
|
||||||
|
title: "",
|
||||||
|
categoryId: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
CategoryService.getAll().then(setCategories).catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (content) {
|
||||||
|
form.reset({
|
||||||
|
title: content.title,
|
||||||
|
categoryId: content.categoryId || "none",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [content, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (values: { title: string; categoryId: string }) => {
|
||||||
|
if (!content) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
...values,
|
||||||
|
categoryId: values.categoryId === "none" ? null : values.categoryId,
|
||||||
|
};
|
||||||
|
await ContentService.update(content.id, data);
|
||||||
|
toast.success("Mème mis à jour !");
|
||||||
|
onSuccess();
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error("Erreur lors de la mise à jour.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Modifier mon mème</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="title"
|
||||||
|
rules={{ required: "Le titre est requis" }}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Titre</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Titre du mème" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="categoryId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Catégorie</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
value={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Sélectionner une catégorie" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">Sans catégorie</SelectItem>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<SelectItem key={cat.id} value={cat.id}>
|
||||||
|
{cat.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading ? "Enregistrement..." : "Enregistrer"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -53,8 +53,11 @@ api.interceptors.response.use(
|
|||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
// If refresh fails, we might want to redirect to login on the client
|
// If refresh fails, we might want to redirect to login on the client
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
|
// On évite de rediriger vers login si on y est déjà pour éviter les boucles
|
||||||
|
if (!window.location.pathname.includes("/login")) {
|
||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return Promise.reject(refreshError);
|
return Promise.reject(refreshError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const refreshUser = React.useCallback(async () => {
|
const refreshUser = React.useCallback(async () => {
|
||||||
|
// Éviter de lancer plusieurs refresh en même temps
|
||||||
|
if (!isLoading) setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const userData = await UserService.getMe();
|
const userData = await UserService.getMe();
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
@@ -34,11 +36,26 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [isLoading]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
refreshUser();
|
let isMounted = true;
|
||||||
}, [refreshUser]);
|
const initAuth = async () => {
|
||||||
|
try {
|
||||||
|
const userData = await UserService.getMe();
|
||||||
|
if (isMounted) setUser(userData);
|
||||||
|
} catch (_error) {
|
||||||
|
if (isMounted) setUser(null);
|
||||||
|
} finally {
|
||||||
|
if (isMounted) setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initAuth();
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const login = async (email: string, password: string) => {
|
const login = async (email: string, password: string) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -11,4 +11,18 @@ export const CategoryService = {
|
|||||||
const { data } = await api.get<Category>(`/categories/${id}`);
|
const { data } = await api.get<Category>(`/categories/${id}`);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async create(category: Partial<Category>): Promise<Category> {
|
||||||
|
const { data } = await api.post<Category>("/categories", category);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id: string, category: Partial<Category>): Promise<Category> {
|
||||||
|
const { data } = await api.patch<Category>(`/categories/${id}`, category);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
|
await api.delete(`/categories/${id}`);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -65,4 +65,18 @@ export const ContentService = {
|
|||||||
async removeAdmin(id: string): Promise<void> {
|
async removeAdmin(id: string): Promise<void> {
|
||||||
await api.delete(`/contents/${id}/admin`);
|
await api.delete(`/contents/${id}/admin`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async update(id: string, update: Partial<Content>): Promise<Content> {
|
||||||
|
const { data } = await api.patch<Content>(`/contents/${id}`, update);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
|
await api.delete(`/contents/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateAdmin(id: string, update: Partial<Content>): Promise<Content> {
|
||||||
|
const { data } = await api.patch<Content>(`/contents/${id}/admin`, update);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export const UserService = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async removeMe(): Promise<void> {
|
||||||
|
await api.delete("/users/me");
|
||||||
|
},
|
||||||
|
|
||||||
async getUsersAdmin(
|
async getUsersAdmin(
|
||||||
limit = 10,
|
limit = 10,
|
||||||
offset = 0,
|
offset = 0,
|
||||||
@@ -34,6 +38,11 @@ export const UserService = {
|
|||||||
await api.delete(`/users/${uuid}`);
|
await api.delete(`/users/${uuid}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updateAdmin(uuid: string, update: Partial<User>): Promise<User> {
|
||||||
|
const { data } = await api.patch<User>(`/users/admin/${uuid}`, update);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
async updateAvatar(file: File): Promise<User> {
|
async updateAvatar(file: File): Promise<User> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export interface Content {
|
|||||||
description?: string;
|
description?: string;
|
||||||
url: string;
|
url: string;
|
||||||
thumbnailUrl?: string;
|
thumbnailUrl?: string;
|
||||||
type: "image" | "video";
|
type: "meme" | "gif" | "video";
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
size: number;
|
size: number;
|
||||||
width?: number;
|
width?: number;
|
||||||
@@ -18,6 +18,7 @@ export interface Content {
|
|||||||
favoritesCount: number;
|
favoritesCount: number;
|
||||||
isLiked?: boolean;
|
isLiked?: boolean;
|
||||||
tags: (string | Tag)[];
|
tags: (string | Tag)[];
|
||||||
|
categoryId?: string | null;
|
||||||
category?: Category;
|
category?: Category;
|
||||||
authorId: string;
|
authorId: string;
|
||||||
author: User;
|
author: User;
|
||||||
@@ -36,6 +37,7 @@ export interface Category {
|
|||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
iconUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export interface User {
|
|||||||
displayName?: string;
|
displayName?: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
bio?: string;
|
bio?: string;
|
||||||
role?: "user" | "admin";
|
role?: "user" | "admin" | "moderator";
|
||||||
status?: "active" | "verification" | "suspended" | "pending" | "deleted";
|
status?: "active" | "verification" | "suspended" | "pending" | "deleted";
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@memegoat/source",
|
"name": "@memegoat/source",
|
||||||
"version": "1.0.4",
|
"version": "1.5.6",
|
||||||
"description": "",
|
"description": "",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"version:get": "cmake -P version.cmake GET",
|
"version:get": "cmake -P version.cmake GET",
|
||||||
@@ -13,9 +13,13 @@
|
|||||||
"build:back": "pnpm run -F @memegoat/backend build",
|
"build:back": "pnpm run -F @memegoat/backend build",
|
||||||
"build:docs": "pnpm run -F @memegoat/documentation build",
|
"build:docs": "pnpm run -F @memegoat/documentation build",
|
||||||
"lint": "pnpm run lint:back && pnpm run lint:front && pnpm run lint:docs",
|
"lint": "pnpm run lint:back && pnpm run lint:front && pnpm run lint:docs",
|
||||||
|
"lint:fix": "pnpm run lint:back:fix && pnpm run lint:front:fix && pnpm run lint:docs:fix",
|
||||||
"lint:back": "pnpm run -F @memegoat/backend lint",
|
"lint:back": "pnpm run -F @memegoat/backend lint",
|
||||||
|
"lint:back:fix": "pnpm run -F @memegoat/backend lint:write",
|
||||||
"lint:front": "pnpm run -F @memegoat/frontend lint",
|
"lint:front": "pnpm run -F @memegoat/frontend lint",
|
||||||
|
"lint:front:fix": "pnpm run -F @memegoat/frontend lint:write",
|
||||||
"lint:docs": "pnpm run -F @memegoat/documentation lint",
|
"lint:docs": "pnpm run -F @memegoat/documentation lint",
|
||||||
|
"lint:docs:fix": "pnpm run -F @memegoat/documentation lint:write",
|
||||||
"test": "pnpm run test:back && pnpm run test:front",
|
"test": "pnpm run test:back && pnpm run test:front",
|
||||||
"test:back": "pnpm run -F @memegoat/backend test",
|
"test:back": "pnpm run -F @memegoat/backend test",
|
||||||
"test:front": "pnpm run -F @memegoat/frontend test",
|
"test:front": "pnpm run -F @memegoat/frontend test",
|
||||||
|
|||||||
@@ -39,6 +39,42 @@ function(increment_version CURRENT_VERSION TYPE OUT_VAR)
|
|||||||
set(${OUT_VAR} "${MAJOR}.${MINOR}.${PATCH}" PARENT_SCOPE)
|
set(${OUT_VAR} "${MAJOR}.${MINOR}.${PATCH}" PARENT_SCOPE)
|
||||||
endfunction()
|
endfunction()
|
||||||
|
|
||||||
|
# Fonction pour créer un commit git pour les changements de version
|
||||||
|
function(commit_version_changes VERSION)
|
||||||
|
find_package(Git QUIET)
|
||||||
|
if(GIT_FOUND)
|
||||||
|
# On n'ajoute que les fichiers package.json modifiés
|
||||||
|
set(ADDED_ANY FALSE)
|
||||||
|
foreach(JSON_FILE ${PACKAGE_JSON_FILES})
|
||||||
|
if(EXISTS "${JSON_FILE}")
|
||||||
|
execute_process(
|
||||||
|
COMMAND ${GIT_EXECUTABLE} add "${JSON_FILE}"
|
||||||
|
WORKING_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}"
|
||||||
|
)
|
||||||
|
set(ADDED_ANY TRUE)
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
|
||||||
|
if(ADDED_ANY)
|
||||||
|
# On commit uniquement les fichiers qui ont été ajoutés (staged)
|
||||||
|
# L'utilisation de --only ou spécifier les fichiers à nouveau assure qu'on ne prend pas d'autres changements
|
||||||
|
execute_process(
|
||||||
|
COMMAND ${GIT_EXECUTABLE} commit -m "chore: bump version to ${VERSION}" -- ${PACKAGE_JSON_FILES}
|
||||||
|
WORKING_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}"
|
||||||
|
RESULT_VARIABLE COMMIT_RESULT
|
||||||
|
)
|
||||||
|
|
||||||
|
if(COMMIT_RESULT EQUAL 0)
|
||||||
|
message(STATUS "Changements commités avec succès pour la version ${VERSION}")
|
||||||
|
else()
|
||||||
|
message(WARNING "Échec du commit des changements. Il n'y a peut-être rien à commiter ou aucun changement sur les fichiers JSON.")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
else()
|
||||||
|
message(WARNING "Git non trouvé, impossible de commiter les changements.")
|
||||||
|
endif()
|
||||||
|
endfunction()
|
||||||
|
|
||||||
# Fonction pour créer un tag git
|
# Fonction pour créer un tag git
|
||||||
function(create_git_tag VERSION)
|
function(create_git_tag VERSION)
|
||||||
find_package(Git QUIET)
|
find_package(Git QUIET)
|
||||||
@@ -73,6 +109,9 @@ function(set_new_version NEW_VERSION)
|
|||||||
endif()
|
endif()
|
||||||
endforeach()
|
endforeach()
|
||||||
|
|
||||||
|
# Commiter les changements
|
||||||
|
commit_version_changes(${NEW_VERSION})
|
||||||
|
|
||||||
# Créer le tag git
|
# Créer le tag git
|
||||||
create_git_tag(${NEW_VERSION})
|
create_git_tag(${NEW_VERSION})
|
||||||
endfunction()
|
endfunction()
|
||||||
|
|||||||
Reference in New Issue
Block a user