Compare commits

..

2 Commits

Author SHA1 Message Date
7a928df73c Merge pull request 'dev' (#11) from dev into prod
Some checks failed
Backend Tests / test (push) Has been cancelled
Lint / lint (push) Has been cancelled
Deploy to Production / deploy (push) Successful in 6m37s
Reviewed-on: #11
2026-01-14 23:23:06 +01:00
a1c48bb792 Merge pull request 'refactor(modules): mark DatabaseModule and CryptoModule as global and remove redundant imports' (#10) from dev into prod
Some checks failed
Backend Tests / test (push) Has been cancelled
Lint / lint (push) Has been cancelled
Deploy to Production / deploy (push) Successful in 6m10s
Reviewed-on: #10
2026-01-14 22:52:03 +01:00
160 changed files with 840 additions and 15694 deletions

View File

@@ -0,0 +1,22 @@
name: Backend Tests
on:
push:
paths:
- 'backend/**'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Run Backend Tests
run: pnpm -F @memegoat/backend test

View File

@@ -1,108 +0,0 @@
# Pipeline CI/CD pour Gitea Actions (Forgejo)
# Compatible avec GitHub Actions pour la portabilité
name: CI/CD Pipeline
on:
push:
tags:
- 'v*'
jobs:
validate:
name: Valider ${{ matrix.component }}
runs-on: ubuntu-latest
strategy:
matrix:
component: [backend, frontend, documentation]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Installer pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Configurer Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Obtenir le chemin du store pnpm
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> "${GITEA_OUTPUT:-$GITHUB_OUTPUT}"
- name: Configurer le cache pnpm
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Installer les dépendances
run: pnpm install --frozen-lockfile --prefer-offline
- name: Lint ${{ matrix.component }}
run: pnpm -F @memegoat/${{ matrix.component }} lint
- name: Tester ${{ matrix.component }}
if: matrix.component == 'backend' || matrix.component == 'frontend'
run: |
if pnpm -F @memegoat/${{ matrix.component }} run | grep -q "test"; then
pnpm -F @memegoat/${{ matrix.component }} test
else
echo "Pas de script de test trouvé pour ${{ matrix.component }}, passage."
fi
- name: Build ${{ matrix.component }}
run: pnpm -F @memegoat/${{ matrix.component }} build
env:
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
deploy:
name: Déploiement en Production
needs: validate
# Déclenchement uniquement sur push sur main ou tag de version
# Gitea supporte le contexte 'github' pour la compatibilité
if: gitea.event_name == 'push' && (gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/v'))
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Vérifier l'environnement Docker
run: |
docker version
docker compose version
- name: Déployer avec Docker Compose
run: |
docker compose -f docker-compose.prod.yml up -d --build --remove-orphans
env:
BACKEND_PORT: ${{ secrets.BACKEND_PORT }}
FRONTEND_PORT: ${{ secrets.FRONTEND_PORT }}
POSTGRES_HOST: ${{ secrets.POSTGRES_HOST }}
POSTGRES_PORT: ${{ secrets.POSTGRES_PORT }}
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
REDIS_HOST: ${{ secrets.REDIS_HOST }}
REDIS_PORT: ${{ secrets.REDIS_PORT }}
S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }}
S3_PORT: ${{ secrets.S3_PORT }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
S3_BUCKET_NAME: ${{ secrets.S3_BUCKET_NAME }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
PGP_ENCRYPTION_KEY: ${{ secrets.PGP_ENCRYPTION_KEY }}
SESSION_PASSWORD: ${{ secrets.SESSION_PASSWORD }}
MAIL_HOST: ${{ secrets.MAIL_HOST }}
MAIL_PASS: ${{ secrets.MAIL_PASS }}
MAIL_USER: ${{ secrets.MAIL_USER }}
MAIL_FROM: ${{ secrets.MAIL_FROM }}
DOMAIN_NAME: ${{ secrets.DOMAIN_NAME }}
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}

View File

@@ -0,0 +1,87 @@
name: Deploy to Production
on:
push:
branches:
- prod
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 20
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITEA_ENV
- name: Setup pnpm cache
uses: actions/cache@v3
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
- name: Lint - Backend
run: pnpm run lint:back
- name: Build - Backend
run: pnpm run build:back
env:
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
- name: Lint - Frontend
run: pnpm run lint:front
- name: Build - Frontend
run: pnpm run build:front
- name: Lint - Documentation
run: pnpm run lint:docs
- name: Build - Documentation
run: pnpm run build:docs
- name: Deploy with Docker Compose
run: |
docker compose -f docker-compose.prod.yml up -d --build
env:
BACKEND_PORT: ${{ secrets.BACKEND_PORT }}
FRONTEND_PORT: ${{ secrets.FRONTEND_PORT }}
POSTGRES_HOST: ${{ secrets.POSTGRES_HOST }}
POSTGRES_PORT: ${{ secrets.POSTGRES_PORT }}
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
REDIS_HOST: ${{ secrets.REDIS_HOST }}
REDIS_PORT: ${{ secrets.REDIS_PORT }}
S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }}
S3_PORT: ${{ secrets.S3_PORT }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
S3_BUCKET_NAME: ${{ secrets.S3_BUCKET_NAME }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
PGP_ENCRYPTION_KEY: ${{ secrets.PGP_ENCRYPTION_KEY }}
SESSION_PASSWORD: ${{ secrets.SESSION_PASSWORD }}
MAIL_HOST: ${{ secrets.MAIL_HOST }}
MAIL_PASS: ${{ secrets.MAIL_PASS }}
MAIL_USER: ${{ secrets.MAIL_USER }}
MAIL_FROM: ${{ secrets.MAIL_FROM }}
DOMAIN_NAME: ${{ secrets.DOMAIN_NAME }}
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}

31
.gitea/workflows/lint.yml Normal file
View File

@@ -0,0 +1,31 @@
name: Lint
on:
push:
paths:
- 'frontend/**'
- 'backend/**'
- 'documentation/**'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Lint Frontend
if: success() || failure()
run: pnpm -F @memegoat/frontend lint
- name: Lint Backend
if: success() || failure()
run: pnpm -F @memegoat/backend lint
- name: Lint Documentation
if: success() || failure()
run: pnpm -F @bypass/documentation lint

Binary file not shown.

View File

@@ -59,28 +59,12 @@ Pour approfondir vos connaissances techniques sur le projet :
## Comment l'utiliser ?
### Déploiement en Production
### Installation locale
Le projet est prêt pour la production via Docker Compose.
1. **Prérequis** : Docker et Docker Compose installés.
2. **Variables d'environnement** : Copiez `.env.example` en `.env.prod` et ajustez les valeurs (clés secrètes, hosts, Sentry DSN, etc.).
3. **Lancement** :
```bash
docker-compose -f docker-compose.prod.yml up -d
```
4. **Services inclus** :
- **Frontend** : Next.js en mode standalone optimisé.
- **Backend** : NestJS avec clustering et monitoring Sentry.
- **Caddy** : Gestion automatique du SSL/TLS.
- **ClamAV** : Scan antivirus en temps réel des médias.
- **Redis** : Cache, sessions et limitation de débit (Throttling/Bot detection).
- **MinIO** : Stockage compatible S3.
### Sécurité et Performance
- **Transcodage Auto** : Toutes les images sont converties en WebP et les vidéos en WebM pour minimiser la bande passante.
- **Bot Detection** : Système intégré de détection et de bannissement automatique des crawlers malveillants via Redis.
- **Monitoring** : Tracking d'erreurs et profilage de performance via Sentry (Node.js et Next.js).
1. Clonez le dépôt.
2. Installez les dépendances avec `pnpm install`.
3. Configurez les variables d'environnement (voir `.env.example`).
4. Lancez les services via Docker ou manuellement.
### Clés API

View File

@@ -1,50 +0,0 @@
# 🐐 Memegoat - Roadmap & Critères de Production
Ce document définit les objectifs, les critères techniques et les fonctionnalités à atteindre pour que le projet Memegoat soit considéré comme prêt pour la production et conforme aux normes européennes (RGPD) et françaises.
## 1. 🏗️ Architecture & Infrastructure
- [x] Backend NestJS (TypeScript)
- [x] Base de données PostgreSQL avec Drizzle ORM
- [x] Stockage d'objets compatible S3 (MinIO)
- [x] Service d'Emailing (Nodemailer / SMTPS)
- [x] Documentation Technique & Référence API (`docs.memegoat.fr`)
- [x] Health Checks (`/health`)
- [x] Gestion des variables d'environnement (Validation avec Zod)
- [ ] CI/CD (Build, Lint, Test, Deploy)
## 2. 🔐 Sécurité & Authentification
- [x] Hachage des mots de passe (Argon2id)
- [x] Gestion des sessions robuste (JWT avec Refresh Token et Rotation)
- [x] RBAC (Role Based Access Control) fonctionnel
- [x] Système de Clés API (Hachées en base)
- [x] Double Authentification (2FA / TOTP)
- [x] Limitation de débit (Rate Limiting / Throttler)
- [x] Validation stricte des entrées (DTOs + ValidationPipe)
- [x] Protection contre les vulnérabilités OWASP (Helmet, CORS)
## 3. ⚖️ Conformité RGPD (EU & France)
- [x] Chiffrement natif des données personnelles (PII) via PGP (pgcrypto)
- [x] Hachage aveugle (Blind Indexing) pour l'email (recherche/unicité)
- [x] Journalisation d'audit complète (Audit Logs) pour les actions sensibles
- [x] Gestion du consentement (Versionnage CGU/Politique de Confidentialité)
- [x] Droit à l'effacement : Flux de suppression (Soft Delete -> Purge définitive)
- [x] Droit à la portabilité : Export des données utilisateur (JSON)
- [x] Purge automatique des données obsolètes (Signalements, Sessions expirées)
- [x] Anonymisation des adresses IP (Hachage) dans les logs
## 4. 🖼️ Fonctionnalités Coeur (Media & Galerie)
- [x] Exploration (Trends, Recent, Favoris)
- [x] Recherche par Tags, Catégories, Auteur, Texte
- [x] Gestion des Favoris
- [x] Upload sécurisé via S3 (URLs présignées)
- [x] Scan Antivirus (ClamAV) et traitement des médias (WebP, WebM, AVIF, AV1)
- [x] Limitation de la taille et des formats de fichiers entrants (Configurable)
- [x] Système de Signalement (Reports) et workflow de modération
- [ ] SEO : Metatags dynamiques et slugs sémantiques
## 5. ✅ Qualité & Robustesse
- [ ] Couverture de tests unitaires (Jest) > 80%
- [ ] Tests d'intégration et E2E
- [x] Gestion centralisée des erreurs (Filters NestJS)
- [ ] Monitoring et centralisation des logs (ex: Sentry, ELK/Loki)
- [x] Performance : Cache (Redis) pour les tendances et recherches fréquentes

View File

@@ -1,756 +0,0 @@
@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

View File

@@ -1 +0,0 @@
ALTER TYPE "public"."content_type" ADD VALUE 'video';

View File

@@ -1,54 +0,0 @@
CREATE TABLE "comment_likes" (
"comment_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "comment_likes_comment_id_user_id_pk" PRIMARY KEY("comment_id","user_id")
);
--> statement-breakpoint
CREATE TABLE "comments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"content_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"parent_id" uuid,
"text" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "conversation_participants" (
"conversation_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"joined_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "conversation_participants_conversation_id_user_id_pk" PRIMARY KEY("conversation_id","user_id")
);
--> statement-breakpoint
CREATE TABLE "conversations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "messages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"conversation_id" uuid NOT NULL,
"sender_id" uuid NOT NULL,
"text" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"read_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "comment_likes" ADD CONSTRAINT "comment_likes_comment_id_comments_id_fk" FOREIGN KEY ("comment_id") REFERENCES "public"."comments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comment_likes" ADD CONSTRAINT "comment_likes_user_id_users_uuid_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_content_id_contents_id_fk" FOREIGN KEY ("content_id") REFERENCES "public"."contents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_user_id_users_uuid_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_parent_id_comments_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."comments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "conversation_participants" ADD CONSTRAINT "conversation_participants_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "conversation_participants" ADD CONSTRAINT "conversation_participants_user_id_users_uuid_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "messages" ADD CONSTRAINT "messages_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_id_users_uuid_fk" FOREIGN KEY ("sender_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "comments_content_id_idx" ON "comments" USING btree ("content_id");--> statement-breakpoint
CREATE INDEX "comments_user_id_idx" ON "comments" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "comments_parent_id_idx" ON "comments" USING btree ("parent_id");--> statement-breakpoint
CREATE INDEX "messages_conversation_id_idx" ON "messages" USING btree ("conversation_id");--> statement-breakpoint
CREATE INDEX "messages_sender_id_idx" ON "messages" USING btree ("sender_id");

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -50,20 +50,6 @@
"when": 1768423315172,
"tag": "0006_friendly_adam_warlock",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1769605995410,
"tag": "0007_melodic_synch",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1769696731978,
"tag": "0008_bitter_darwin",
"breakpoints": true
}
]
}

View File

@@ -1,28 +1,18 @@
# syntax=docker/dockerfile:1
FROM node:22-alpine AS base
FROM node:22-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN apk add --no-cache ffmpeg
FROM base AS build
WORKDIR /usr/src/app
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY backend/package.json ./backend/
COPY frontend/package.json ./frontend/
COPY documentation/package.json ./documentation/
# Utilisation du cache pour pnpm et installation figée
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile --force
RUN pnpm install --no-frozen-lockfile
COPY . .
# Deuxième passe avec cache pour les scripts/liens
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile --force
# On réinstalle après COPY pour s'assurer que tous les scripts de cycle de vie et les liens sont corrects
RUN pnpm install --no-frozen-lockfile
RUN pnpm run --filter @memegoat/backend build
RUN pnpm deploy --filter=@memegoat/backend --prod --legacy /app
RUN cp -r backend/dist /app/dist

View File

@@ -24,8 +24,7 @@
"rules": {
"recommended": true,
"suspicious": {
"noUnknownAtRules": "off",
"noExplicitAny": "off"
"noUnknownAtRules": "off"
},
"style": {
"useImportType": "off"

View File

@@ -1,6 +1,6 @@
{
"name": "@memegoat/backend",
"version": "1.9.2",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
@@ -13,7 +13,7 @@
"scripts": {
"build": "nest build",
"lint": "biome check",
"lint:write": "biome check --write --unsafe",
"lint:write": "biome check --write",
"format": "biome format --write",
"start": "nest start",
"start:dev": "nest start --watch",
@@ -36,10 +36,8 @@
"@nestjs/core": "^11.0.1",
"@nestjs/mapped-types": "^2.1.0",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/platform-socket.io": "^11.1.12",
"@nestjs/schedule": "^6.1.0",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.12",
"@noble/post-quantum": "^0.5.4",
"@node-rs/argon2": "^2.0.2",
"@sentry/nestjs": "^10.32.1",
@@ -50,7 +48,6 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"dotenv": "^17.2.3",
"drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.1",
"fluent-ffmpeg": "^2.1.3",
"helmet": "^8.1.0",
@@ -64,12 +61,23 @@
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sharp": "^0.34.5",
"socket.io": "^4.8.3",
"uuid": "^13.0.0",
"zod": "^4.3.5"
"zod": "^4.3.5",
"drizzle-kit": "^0.31.8"
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",
"globals": "^16.0.0",
"jest": "^30.0.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.21.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
@@ -81,21 +89,9 @@
"@types/pg": "^8.16.0",
"@types/qrcode": "^1.5.6",
"@types/sharp": "^0.32.0",
"@types/socket.io": "^3.0.2",
"@types/supertest": "^6.0.2",
"@types/uuid": "^11.0.0",
"drizzle-kit": "^0.31.8",
"globals": "^16.0.0",
"jest": "^30.0.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.21.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
"drizzle-kit": "^0.31.8"
},
"jest": {
"moduleFileExtensions": [
@@ -111,7 +107,7 @@
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"transformIgnorePatterns": [
"node_modules/(?!(.pnpm/)?(jose|@noble|uuid))"
"node_modules/(?!(.pnpm/)?(jose|@noble|uuid)/)"
],
"transform": {
"^.+\\.(t|j)sx?$": "ts-jest"

View File

@@ -1,62 +0,0 @@
jest.mock("uuid", () => ({
v4: jest.fn(() => "mocked-uuid"),
}));
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
ml_kem768: {
keygen: jest.fn(),
encapsulate: jest.fn(),
decapsulate: jest.fn(),
},
}));
jest.mock("jose", () => ({
SignJWT: jest.fn().mockReturnValue({
setProtectedHeader: jest.fn().mockReturnThis(),
setIssuedAt: jest.fn().mockReturnThis(),
setExpirationTime: jest.fn().mockReturnThis(),
sign: jest.fn().mockResolvedValue("mocked-jwt"),
}),
jwtVerify: jest.fn(),
}));
import { Test, TestingModule } from "@nestjs/testing";
import { AuthGuard } from "../auth/guards/auth.guard";
import { RolesGuard } from "../auth/guards/roles.guard";
import { AdminController } from "./admin.controller";
import { AdminService } from "./admin.service";
describe("AdminController", () => {
let controller: AdminController;
let service: AdminService;
const mockAdminService = {
getStats: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AdminController],
providers: [{ provide: AdminService, useValue: mockAdminService }],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<AdminController>(AdminController);
service = module.get<AdminService>(AdminService);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("getStats", () => {
it("should call service.getStats", async () => {
await controller.getStats();
expect(service.getStats).toHaveBeenCalled();
});
});
});

View File

@@ -1,58 +0,0 @@
import { Test, TestingModule } from "@nestjs/testing";
import { CategoriesRepository } from "../categories/repositories/categories.repository";
import { ContentsRepository } from "../contents/repositories/contents.repository";
import { UsersRepository } from "../users/repositories/users.repository";
import { AdminService } from "./admin.service";
describe("AdminService", () => {
let service: AdminService;
let _usersRepository: UsersRepository;
let _contentsRepository: ContentsRepository;
let _categoriesRepository: CategoriesRepository;
const mockUsersRepository = {
countAll: jest.fn(),
};
const mockContentsRepository = {
count: jest.fn(),
};
const mockCategoriesRepository = {
countAll: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AdminService,
{ provide: UsersRepository, useValue: mockUsersRepository },
{ provide: ContentsRepository, useValue: mockContentsRepository },
{ provide: CategoriesRepository, useValue: mockCategoriesRepository },
],
}).compile();
service = module.get<AdminService>(AdminService);
_usersRepository = module.get<UsersRepository>(UsersRepository);
_contentsRepository = module.get<ContentsRepository>(ContentsRepository);
_categoriesRepository =
module.get<CategoriesRepository>(CategoriesRepository);
});
it("should return stats", async () => {
mockUsersRepository.countAll.mockResolvedValue(10);
mockContentsRepository.count.mockResolvedValue(20);
mockCategoriesRepository.countAll.mockResolvedValue(5);
const result = await service.getStats();
expect(result).toEqual({
users: 10,
contents: 20,
categories: 5,
});
expect(mockUsersRepository.countAll).toHaveBeenCalled();
expect(mockContentsRepository.count).toHaveBeenCalledWith({});
expect(mockCategoriesRepository.countAll).toHaveBeenCalled();
});
});

View File

@@ -1,95 +0,0 @@
jest.mock("uuid", () => ({
v4: jest.fn(() => "mocked-uuid"),
}));
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
ml_kem768: {
keygen: jest.fn(),
encapsulate: jest.fn(),
decapsulate: jest.fn(),
},
}));
jest.mock("jose", () => ({
SignJWT: jest.fn().mockReturnValue({
setProtectedHeader: jest.fn().mockReturnThis(),
setIssuedAt: jest.fn().mockReturnThis(),
setExpirationTime: jest.fn().mockReturnThis(),
sign: jest.fn().mockResolvedValue("mocked-jwt"),
}),
jwtVerify: jest.fn(),
}));
import { Test, TestingModule } from "@nestjs/testing";
import { AuthGuard } from "../auth/guards/auth.guard";
import { AuthenticatedRequest } from "../common/interfaces/request.interface";
import { ApiKeysController } from "./api-keys.controller";
import { ApiKeysService } from "./api-keys.service";
describe("ApiKeysController", () => {
let controller: ApiKeysController;
let service: ApiKeysService;
const mockApiKeysService = {
create: jest.fn(),
findAll: jest.fn(),
revoke: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ApiKeysController],
providers: [{ provide: ApiKeysService, useValue: mockApiKeysService }],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<ApiKeysController>(ApiKeysController);
service = module.get<ApiKeysService>(ApiKeysService);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("create", () => {
it("should call service.create", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
const dto = { name: "Key Name", expiresAt: "2026-01-20T12:00:00Z" };
await controller.create(req, dto);
expect(service.create).toHaveBeenCalledWith(
"user-uuid",
"Key Name",
new Date(dto.expiresAt),
);
});
it("should call service.create without expiresAt", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
const dto = { name: "Key Name" };
await controller.create(req, dto);
expect(service.create).toHaveBeenCalledWith(
"user-uuid",
"Key Name",
undefined,
);
});
});
describe("findAll", () => {
it("should call service.findAll", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.findAll(req);
expect(service.findAll).toHaveBeenCalledWith("user-uuid");
});
});
describe("revoke", () => {
it("should call service.revoke", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.revoke(req, "key-id");
expect(service.revoke).toHaveBeenCalledWith("user-uuid", "key-id");
});
});
});

View File

@@ -1,83 +0,0 @@
import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "../../database/database.service";
import { ApiKeysRepository } from "./api-keys.repository";
describe("ApiKeysRepository", () => {
let repository: ApiKeysRepository;
let _databaseService: DatabaseService;
const mockDb = {
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
returning: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
execute: jest.fn(),
};
const wrapWithThen = (obj: unknown) => {
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
Object.defineProperty(obj, "then", {
value: function (onFulfilled: (arg0: unknown) => void) {
const result = (this as Record<string, unknown>).execute();
return Promise.resolve(result).then(onFulfilled);
},
configurable: true,
});
return obj;
};
wrapWithThen(mockDb);
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ApiKeysRepository,
{ provide: DatabaseService, useValue: { db: mockDb } },
],
}).compile();
repository = module.get<ApiKeysRepository>(ApiKeysRepository);
_databaseService = module.get<DatabaseService>(DatabaseService);
});
it("should create an api key", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
await repository.create({
userId: "u1",
name: "n",
prefix: "p",
keyHash: "h",
});
expect(mockDb.insert).toHaveBeenCalled();
});
it("should find all keys for user", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
const result = await repository.findAll("u1");
expect(result).toHaveLength(1);
});
it("should revoke a key", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([
{ id: "1", isActive: false },
]);
const result = await repository.revoke("u1", "k1");
expect(result[0].isActive).toBe(false);
});
it("should find active by hash", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
const result = await repository.findActiveByKeyHash("h");
expect(result.id).toBe("1");
});
it("should update last used", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
await repository.updateLastUsed("1");
expect(mockDb.update).toHaveBeenCalled();
});
});

View File

@@ -10,10 +10,8 @@ import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { AuthModule } from "./auth/auth.module";
import { CategoriesModule } from "./categories/categories.module";
import { CommentsModule } from "./comments/comments.module";
import { CommonModule } from "./common/common.module";
import { CrawlerDetectionMiddleware } from "./common/middlewares/crawler-detection.middleware";
import { HTTPLoggerMiddleware } from "./common/middlewares/http-logger.middleware";
import { validateEnv } from "./config/env.schema";
import { ContentsModule } from "./contents/contents.module";
import { CryptoModule } from "./crypto/crypto.module";
@@ -22,8 +20,6 @@ import { FavoritesModule } from "./favorites/favorites.module";
import { HealthController } from "./health.controller";
import { MailModule } from "./mail/mail.module";
import { MediaModule } from "./media/media.module";
import { MessagesModule } from "./messages/messages.module";
import { RealtimeModule } from "./realtime/realtime.module";
import { ReportsModule } from "./reports/reports.module";
import { S3Module } from "./s3/s3.module";
import { SessionsModule } from "./sessions/sessions.module";
@@ -40,15 +36,12 @@ import { UsersModule } from "./users/users.module";
UsersModule,
AuthModule,
CategoriesModule,
CommentsModule,
ContentsModule,
FavoritesModule,
TagsModule,
MediaModule,
MessagesModule,
SessionsModule,
ReportsModule,
RealtimeModule,
ApiKeysModule,
AdminModule,
ScheduleModule.forRoot(),
@@ -83,8 +76,6 @@ import { UsersModule } from "./users/users.module";
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(HTTPLoggerMiddleware, CrawlerDetectionMiddleware)
.forRoutes("*");
consumer.apply(CrawlerDetectionMiddleware).forRoutes("*");
}
}

View File

@@ -1,190 +0,0 @@
jest.mock("uuid", () => ({
v4: jest.fn(() => "mocked-uuid"),
}));
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
ml_kem768: {
keygen: jest.fn(),
encapsulate: jest.fn(),
decapsulate: jest.fn(),
},
}));
jest.mock("jose", () => ({
SignJWT: jest.fn().mockReturnValue({
setProtectedHeader: jest.fn().mockReturnThis(),
setIssuedAt: jest.fn().mockReturnThis(),
setExpirationTime: jest.fn().mockReturnThis(),
sign: jest.fn().mockResolvedValue("mocked-jwt"),
}),
jwtVerify: jest.fn(),
}));
import { ConfigService } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { BootstrapService } from "./bootstrap.service";
jest.mock("iron-session", () => ({
getIronSession: jest.fn().mockResolvedValue({
save: jest.fn(),
destroy: jest.fn(),
}),
}));
describe("AuthController", () => {
let controller: AuthController;
let authService: AuthService;
let _configService: ConfigService;
const mockAuthService = {
register: jest.fn(),
login: jest.fn(),
verifyTwoFactorLogin: jest.fn(),
refresh: jest.fn(),
};
const mockBootstrapService = {
consumeToken: jest.fn(),
};
const mockConfigService = {
get: jest
.fn()
.mockReturnValue("complex_password_at_least_32_characters_long"),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
providers: [
{ provide: AuthService, useValue: mockAuthService },
{ provide: BootstrapService, useValue: mockBootstrapService },
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
controller = module.get<AuthController>(AuthController);
authService = module.get<AuthService>(AuthService);
_configService = module.get<ConfigService>(ConfigService);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("register", () => {
it("should call authService.register", async () => {
const dto = {
email: "test@example.com",
password: "password",
username: "test",
};
await controller.register(dto as any);
expect(authService.register).toHaveBeenCalledWith(dto);
});
});
describe("login", () => {
it("should call authService.login and setup session if success", async () => {
const dto = { email: "test@example.com", password: "password" };
const req = { ip: "127.0.0.1" } as any;
const res = { json: jest.fn() } as any;
const loginResult = {
access_token: "at",
refresh_token: "rt",
userId: "1",
message: "ok",
};
mockAuthService.login.mockResolvedValue(loginResult);
await controller.login(dto as any, "ua", req, res);
expect(authService.login).toHaveBeenCalledWith(dto, "ua", "127.0.0.1");
expect(res.json).toHaveBeenCalledWith({ message: "ok", userId: "1" });
});
it("should return result if no access_token", async () => {
const dto = { email: "test@example.com", password: "password" };
const req = { ip: "127.0.0.1" } as any;
const res = { json: jest.fn() } as any;
const loginResult = { message: "2fa_required", userId: "1" };
mockAuthService.login.mockResolvedValue(loginResult);
await controller.login(dto as any, "ua", req, res);
expect(res.json).toHaveBeenCalledWith(loginResult);
});
});
describe("verifyTwoFactor", () => {
it("should call authService.verifyTwoFactorLogin and setup session", async () => {
const dto = { userId: "1", token: "123456" };
const req = { ip: "127.0.0.1" } as any;
const res = { json: jest.fn() } as any;
const verifyResult = {
access_token: "at",
refresh_token: "rt",
message: "ok",
};
mockAuthService.verifyTwoFactorLogin.mockResolvedValue(verifyResult);
await controller.verifyTwoFactor(dto, "ua", req, res);
expect(authService.verifyTwoFactorLogin).toHaveBeenCalledWith(
"1",
"123456",
"ua",
"127.0.0.1",
);
expect(res.json).toHaveBeenCalledWith({ message: "ok" });
});
});
describe("refresh", () => {
it("should refresh token if session has refresh token", async () => {
const { getIronSession } = require("iron-session");
const session = { refreshToken: "rt", save: jest.fn() };
getIronSession.mockResolvedValue(session);
const req = {} as any;
const res = { json: jest.fn() } as any;
mockAuthService.refresh.mockResolvedValue({
access_token: "at2",
refresh_token: "rt2",
});
await controller.refresh(req, res);
expect(authService.refresh).toHaveBeenCalledWith("rt");
expect(res.json).toHaveBeenCalledWith({ message: "Token refreshed" });
});
it("should return 401 if no refresh token", async () => {
const { getIronSession } = require("iron-session");
const session = { save: jest.fn() };
getIronSession.mockResolvedValue(session);
const req = {} as any;
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() } as any;
await controller.refresh(req, res);
expect(res.status).toHaveBeenCalledWith(401);
});
});
describe("logout", () => {
it("should destroy session", async () => {
const { getIronSession } = require("iron-session");
const session = { destroy: jest.fn() };
getIronSession.mockResolvedValue(session);
const req = {} as any;
const res = { json: jest.fn() } as any;
await controller.logout(req, res);
expect(session.destroy).toHaveBeenCalled();
expect(res.json).toHaveBeenCalledWith({ message: "User logged out" });
});
});
});

View File

@@ -1,19 +1,9 @@
import {
Body,
Controller,
Get,
Headers,
Post,
Query,
Req,
Res,
} from "@nestjs/common";
import { Body, Controller, Headers, Post, Req, Res } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Throttle } from "@nestjs/throttler";
import type { Request, Response } from "express";
import { getIronSession } from "iron-session";
import { AuthService } from "./auth.service";
import { BootstrapService } from "./bootstrap.service";
import { LoginDto } from "./dto/login.dto";
import { RegisterDto } from "./dto/register.dto";
import { Verify2faDto } from "./dto/verify-2fa.dto";
@@ -23,7 +13,6 @@ import { getSessionOptions, SessionData } from "./session.config";
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly bootstrapService: BootstrapService,
private readonly configService: ConfigService,
) {}
@@ -131,12 +120,4 @@ export class AuthController {
session.destroy();
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);
}
}

View File

@@ -3,7 +3,6 @@ import { SessionsModule } from "../sessions/sessions.module";
import { UsersModule } from "../users/users.module";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { BootstrapService } from "./bootstrap.service";
import { AuthGuard } from "./guards/auth.guard";
import { OptionalAuthGuard } from "./guards/optional-auth.guard";
import { RolesGuard } from "./guards/roles.guard";
@@ -16,7 +15,6 @@ import { RbacRepository } from "./repositories/rbac.repository";
providers: [
AuthService,
RbacService,
BootstrapService,
RbacRepository,
AuthGuard,
OptionalAuthGuard,

View File

@@ -148,7 +148,7 @@ describe("AuthService", () => {
const dto = {
username: "test",
email: "test@example.com",
password: "Password1!",
password: "password",
};
mockHashingService.hashPassword.mockResolvedValue("hashed-password");
mockHashingService.hashEmail.mockResolvedValue("hashed-email");
@@ -165,7 +165,7 @@ describe("AuthService", () => {
describe("login", () => {
it("should login a user", async () => {
const dto = { email: "test@example.com", password: "Password1!" };
const dto = { email: "test@example.com", password: "password" };
const user = {
uuid: "user-id",
username: "test",

View File

@@ -110,7 +110,6 @@ export class AuthService {
const user = await this.usersService.findByEmailHash(emailHash);
if (!user) {
this.logger.warn(`Login failed: user not found for email hash`);
throw new UnauthorizedException("Invalid credentials");
}
@@ -120,12 +119,10 @@ export class AuthService {
);
if (!isPasswordValid) {
this.logger.warn(`Login failed: invalid password for user ${user.uuid}`);
throw new UnauthorizedException("Invalid credentials");
}
if (user.isTwoFactorEnabled) {
this.logger.log(`2FA required for user ${user.uuid}`);
return {
message: "2FA required",
requires2FA: true,
@@ -136,7 +133,6 @@ export class AuthService {
const accessToken = await this.jwtService.generateJwt({
sub: user.uuid,
username: user.username,
role: user.role,
});
const session = await this.sessionsService.createSession(
@@ -145,7 +141,6 @@ export class AuthService {
ip,
);
this.logger.log(`User ${user.uuid} logged in successfully`);
return {
message: "User logged in successfully",
access_token: accessToken,
@@ -170,16 +165,12 @@ export class AuthService {
const isValid = authenticator.verify({ token, secret });
if (!isValid) {
this.logger.warn(
`2FA verification failed for user ${userId}: invalid token`,
);
throw new UnauthorizedException("Invalid 2FA token");
}
const accessToken = await this.jwtService.generateJwt({
sub: user.uuid,
username: user.username,
role: user.role,
});
const session = await this.sessionsService.createSession(
@@ -188,7 +179,6 @@ export class AuthService {
ip,
);
this.logger.log(`User ${userId} logged in successfully via 2FA`);
return {
message: "User logged in successfully (2FA)",
access_token: accessToken,
@@ -207,7 +197,6 @@ export class AuthService {
const accessToken = await this.jwtService.generateJwt({
sub: user.uuid,
username: user.username,
role: user.role,
});
return {

View File

@@ -1,114 +0,0 @@
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");
});
});
});

View File

@@ -1,67 +0,0 @@
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` };
}
}

View File

@@ -2,7 +2,6 @@ import {
IsEmail,
IsNotEmpty,
IsString,
Matches,
MaxLength,
MinLength,
} from "class-validator";
@@ -11,10 +10,6 @@ export class RegisterDto {
@IsString()
@IsNotEmpty()
@MaxLength(32)
@Matches(/^[a-z0-9_]+$/, {
message:
"username must contain only lowercase letters, numbers, and underscores",
})
username!: string;
@IsString()
@@ -26,15 +21,5 @@ export class RegisterDto {
@IsString()
@MinLength(8)
@Matches(/[A-Z]/, {
message: "password must contain at least one uppercase letter",
})
@Matches(/[a-z]/, {
message: "password must contain at least one lowercase letter",
})
@Matches(/[0-9]/, { message: "password must contain at least one number" })
@Matches(/[^A-Za-z0-9]/, {
message: "password must contain at least one special character",
})
password!: string;
}

View File

@@ -1,89 +0,0 @@
import { ExecutionContext, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
import { getIronSession } from "iron-session";
import { JwtService } from "../../crypto/services/jwt.service";
import { AuthGuard } from "./auth.guard";
jest.mock("jose", () => ({}));
jest.mock("iron-session", () => ({
getIronSession: jest.fn(),
}));
describe("AuthGuard", () => {
let guard: AuthGuard;
let _jwtService: JwtService;
let _configService: ConfigService;
const mockJwtService = {
verifyJwt: jest.fn(),
};
const mockConfigService = {
get: jest.fn().mockReturnValue("session-password"),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthGuard,
{ provide: JwtService, useValue: mockJwtService },
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
guard = module.get<AuthGuard>(AuthGuard);
_jwtService = module.get<JwtService>(JwtService);
_configService = module.get<ConfigService>(ConfigService);
});
it("should return true for valid token", async () => {
const request = { user: null };
const context = {
switchToHttp: () => ({
getRequest: () => request,
getResponse: () => ({}),
}),
} as unknown as ExecutionContext;
(getIronSession as jest.Mock).mockResolvedValue({
accessToken: "valid-token",
});
mockJwtService.verifyJwt.mockResolvedValue({ sub: "user1" });
const result = await guard.canActivate(context);
expect(result).toBe(true);
expect(request.user).toEqual({ sub: "user1" });
});
it("should throw UnauthorizedException if no token", async () => {
const context = {
switchToHttp: () => ({
getRequest: () => ({}),
getResponse: () => ({}),
}),
} as ExecutionContext;
(getIronSession as jest.Mock).mockResolvedValue({});
await expect(guard.canActivate(context)).rejects.toThrow(
UnauthorizedException,
);
});
it("should throw UnauthorizedException if token invalid", async () => {
const context = {
switchToHttp: () => ({
getRequest: () => ({}),
getResponse: () => ({}),
}),
} as ExecutionContext;
(getIronSession as jest.Mock).mockResolvedValue({ accessToken: "invalid" });
mockJwtService.verifyJwt.mockRejectedValue(new Error("invalid"));
await expect(guard.canActivate(context)).rejects.toThrow(
UnauthorizedException,
);
});
});

View File

@@ -1,84 +0,0 @@
import { ExecutionContext } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
import { getIronSession } from "iron-session";
import { JwtService } from "../../crypto/services/jwt.service";
import { OptionalAuthGuard } from "./optional-auth.guard";
jest.mock("jose", () => ({}));
jest.mock("iron-session", () => ({
getIronSession: jest.fn(),
}));
describe("OptionalAuthGuard", () => {
let guard: OptionalAuthGuard;
let _jwtService: JwtService;
const mockJwtService = {
verifyJwt: jest.fn(),
};
const mockConfigService = {
get: jest.fn().mockReturnValue("session-password"),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
OptionalAuthGuard,
{ provide: JwtService, useValue: mockJwtService },
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
guard = module.get<OptionalAuthGuard>(OptionalAuthGuard);
_jwtService = module.get<JwtService>(JwtService);
});
it("should return true and set user for valid token", async () => {
const request = { user: null };
const context = {
switchToHttp: () => ({
getRequest: () => request,
getResponse: () => ({}),
}),
} as unknown as ExecutionContext;
(getIronSession as jest.Mock).mockResolvedValue({ accessToken: "valid" });
mockJwtService.verifyJwt.mockResolvedValue({ sub: "u1" });
const result = await guard.canActivate(context);
expect(result).toBe(true);
expect(request.user).toEqual({ sub: "u1" });
});
it("should return true if no token", async () => {
const context = {
switchToHttp: () => ({
getRequest: () => ({}),
getResponse: () => ({}),
}),
} as ExecutionContext;
(getIronSession as jest.Mock).mockResolvedValue({});
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
it("should return true even if token invalid", async () => {
const context = {
switchToHttp: () => ({
getRequest: () => ({ user: null }),
getResponse: () => ({}),
}),
} as ExecutionContext;
(getIronSession as jest.Mock).mockResolvedValue({ accessToken: "invalid" });
mockJwtService.verifyJwt.mockRejectedValue(new Error("invalid"));
const result = await guard.canActivate(context);
expect(result).toBe(true);
expect(context.switchToHttp().getRequest().user).toBeNull();
});
});

View File

@@ -1,90 +0,0 @@
import { ExecutionContext } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Test, TestingModule } from "@nestjs/testing";
import { RbacService } from "../rbac.service";
import { RolesGuard } from "./roles.guard";
describe("RolesGuard", () => {
let guard: RolesGuard;
let _reflector: Reflector;
let _rbacService: RbacService;
const mockReflector = {
getAllAndOverride: jest.fn(),
};
const mockRbacService = {
getUserRoles: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
RolesGuard,
{ provide: Reflector, useValue: mockReflector },
{ provide: RbacService, useValue: mockRbacService },
],
}).compile();
guard = module.get<RolesGuard>(RolesGuard);
_reflector = module.get<Reflector>(Reflector);
_rbacService = module.get<RbacService>(RbacService);
});
it("should return true if no roles required", async () => {
mockReflector.getAllAndOverride.mockReturnValue(null);
const context = {
getHandler: () => ({}),
getClass: () => ({}),
} as ExecutionContext;
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
it("should return false if no user in request", async () => {
mockReflector.getAllAndOverride.mockReturnValue(["admin"]);
const context = {
getHandler: () => ({}),
getClass: () => ({}),
switchToHttp: () => ({
getRequest: () => ({ user: null }),
}),
} as ExecutionContext;
const result = await guard.canActivate(context);
expect(result).toBe(false);
});
it("should return true if user has required role", async () => {
mockReflector.getAllAndOverride.mockReturnValue(["admin"]);
const context = {
getHandler: () => ({}),
getClass: () => ({}),
switchToHttp: () => ({
getRequest: () => ({ user: { sub: "u1" } }),
}),
} as ExecutionContext;
mockRbacService.getUserRoles.mockResolvedValue(["admin", "user"]);
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
it("should return false if user doesn't have required role", async () => {
mockReflector.getAllAndOverride.mockReturnValue(["admin"]);
const context = {
getHandler: () => ({}),
getClass: () => ({}),
switchToHttp: () => ({
getRequest: () => ({ user: { sub: "u1" } }),
}),
} as ExecutionContext;
mockRbacService.getUserRoles.mockResolvedValue(["user"]);
const result = await guard.canActivate(context);
expect(result).toBe(false);
});
});

View File

@@ -9,8 +9,6 @@ describe("RbacService", () => {
const mockRbacRepository = {
findRolesByUserId: jest.fn(),
findPermissionsByUserId: jest.fn(),
countRoles: jest.fn(),
createRole: jest.fn(),
};
beforeEach(async () => {
@@ -60,35 +58,4 @@ describe("RbacService", () => {
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();
});
});
});

View File

@@ -1,53 +1,10 @@
import { Injectable, Logger, OnApplicationBootstrap } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { RbacRepository } from "./repositories/rbac.repository";
@Injectable()
export class RbacService implements OnApplicationBootstrap {
private readonly logger = new Logger(RbacService.name);
export class RbacService {
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) {
return this.rbacRepository.findRolesByUserId(userId);
}
@@ -55,12 +12,4 @@ export class RbacService implements OnApplicationBootstrap {
async getUserPermissions(userId: string) {
return this.rbacRepository.findPermissionsByUserId(userId);
}
async countAdmins() {
return this.rbacRepository.countAdmins();
}
async assignRoleToUser(userId: string, roleSlug: string) {
return this.rbacRepository.assignRole(userId, roleSlug);
}
}

View File

@@ -39,52 +39,4 @@ export class RbacRepository {
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();
}
}

View File

@@ -1,105 +0,0 @@
jest.mock("uuid", () => ({
v4: jest.fn(() => "mocked-uuid"),
}));
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
ml_kem768: {
keygen: jest.fn(),
encapsulate: jest.fn(),
decapsulate: jest.fn(),
},
}));
jest.mock("jose", () => ({
SignJWT: jest.fn().mockReturnValue({
setProtectedHeader: jest.fn().mockReturnThis(),
setIssuedAt: jest.fn().mockReturnThis(),
setExpirationTime: jest.fn().mockReturnThis(),
sign: jest.fn().mockResolvedValue("mocked-jwt"),
}),
jwtVerify: jest.fn(),
}));
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Test, TestingModule } from "@nestjs/testing";
import { AuthGuard } from "../auth/guards/auth.guard";
import { RolesGuard } from "../auth/guards/roles.guard";
import { CategoriesController } from "./categories.controller";
import { CategoriesService } from "./categories.service";
describe("CategoriesController", () => {
let controller: CategoriesController;
let service: CategoriesService;
const mockCategoriesService = {
findAll: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
update: jest.fn(),
remove: jest.fn(),
};
const mockCacheManager = {
get: jest.fn(),
set: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [CategoriesController],
providers: [
{ provide: CategoriesService, useValue: mockCategoriesService },
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<CategoriesController>(CategoriesController);
service = module.get<CategoriesService>(CategoriesService);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("findAll", () => {
it("should call service.findAll", async () => {
await controller.findAll();
expect(service.findAll).toHaveBeenCalled();
});
});
describe("findOne", () => {
it("should call service.findOne", async () => {
await controller.findOne("1");
expect(service.findOne).toHaveBeenCalledWith("1");
});
});
describe("create", () => {
it("should call service.create", async () => {
const dto = { name: "Cat", slug: "cat" };
await controller.create(dto);
expect(service.create).toHaveBeenCalledWith(dto);
});
});
describe("update", () => {
it("should call service.update", async () => {
const dto = { name: "New Name" };
await controller.update("1", dto);
expect(service.update).toHaveBeenCalledWith("1", dto);
});
});
describe("remove", () => {
it("should call service.remove", async () => {
await controller.remove("1");
expect(service.remove).toHaveBeenCalledWith("1");
});
});
});

View File

@@ -1,82 +0,0 @@
import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "../../database/database.service";
import { CategoriesRepository } from "./categories.repository";
describe("CategoriesRepository", () => {
let repository: CategoriesRepository;
const mockDb = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
returning: jest.fn().mockReturnThis(),
execute: jest.fn(),
};
const wrapWithThen = (obj: unknown) => {
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
Object.defineProperty(obj, "then", {
value: function (onFulfilled: (arg0: unknown) => void) {
const result = (this as Record<string, unknown>).execute();
return Promise.resolve(result).then(onFulfilled);
},
configurable: true,
});
return obj;
};
wrapWithThen(mockDb);
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
CategoriesRepository,
{ provide: DatabaseService, useValue: { db: mockDb } },
],
}).compile();
repository = module.get<CategoriesRepository>(CategoriesRepository);
});
it("should find all", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
const result = await repository.findAll();
expect(result).toHaveLength(1);
});
it("should count all", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ count: 5 }]);
const result = await repository.countAll();
expect(result).toBe(5);
});
it("should find one", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
const result = await repository.findOne("1");
expect(result.id).toBe("1");
});
it("should create", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
await repository.create({ name: "C", slug: "s" });
expect(mockDb.insert).toHaveBeenCalled();
});
it("should update", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
await repository.update("1", { name: "N", updatedAt: new Date() });
expect(mockDb.update).toHaveBeenCalled();
});
it("should remove", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
await repository.remove("1");
expect(mockDb.delete).toHaveBeenCalled();
});
});

View File

@@ -1,80 +0,0 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Req,
UseGuards,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { getIronSession } from "iron-session";
import { AuthGuard } from "../auth/guards/auth.guard";
import { getSessionOptions } from "../auth/session.config";
import type { AuthenticatedRequest } from "../common/interfaces/request.interface";
import { JwtService } from "../crypto/services/jwt.service";
import { CommentsService } from "./comments.service";
import { CreateCommentDto } from "./dto/create-comment.dto";
@Controller()
export class CommentsController {
constructor(
private readonly commentsService: CommentsService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
@Get("contents/:contentId/comments")
async findAllByContentId(
@Param("contentId") contentId: string,
@Req() req: any,
) {
// Tentative de récupération de l'utilisateur pour isLiked (optionnel)
let userId: string | undefined;
try {
const session = await getIronSession<any>(
req,
req.res,
getSessionOptions(this.configService.get("SESSION_PASSWORD") as string),
);
if (session.accessToken) {
const payload = await this.jwtService.verifyJwt(session.accessToken);
userId = payload.sub;
}
} catch (_e) {
// Ignorer les erreurs de session
}
return this.commentsService.findAllByContentId(contentId, userId);
}
@Post("contents/:contentId/comments")
@UseGuards(AuthGuard)
create(
@Req() req: AuthenticatedRequest,
@Param("contentId") contentId: string,
@Body() dto: CreateCommentDto,
) {
return this.commentsService.create(req.user.sub, contentId, dto);
}
@Delete("comments/:id")
@UseGuards(AuthGuard)
remove(@Req() req: AuthenticatedRequest, @Param("id") id: string) {
const isAdmin = req.user.role === "admin" || req.user.role === "moderator";
return this.commentsService.remove(req.user.sub, id, isAdmin);
}
@Post("comments/:id/like")
@UseGuards(AuthGuard)
like(@Req() req: AuthenticatedRequest, @Param("id") id: string) {
return this.commentsService.like(req.user.sub, id);
}
@Delete("comments/:id/like")
@UseGuards(AuthGuard)
unlike(@Req() req: AuthenticatedRequest, @Param("id") id: string) {
return this.commentsService.unlike(req.user.sub, id);
}
}

View File

@@ -1,22 +0,0 @@
import { forwardRef, Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { ContentsModule } from "../contents/contents.module";
import { RealtimeModule } from "../realtime/realtime.module";
import { S3Module } from "../s3/s3.module";
import { CommentsController } from "./comments.controller";
import { CommentsService } from "./comments.service";
import { CommentLikesRepository } from "./repositories/comment-likes.repository";
import { CommentsRepository } from "./repositories/comments.repository";
@Module({
imports: [
AuthModule,
S3Module,
RealtimeModule,
forwardRef(() => ContentsModule),
],
controllers: [CommentsController],
providers: [CommentsService, CommentsRepository, CommentLikesRepository],
exports: [CommentsService],
})
export class CommentsModule {}

View File

@@ -1,151 +0,0 @@
import { ForbiddenException, NotFoundException } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import { ContentsRepository } from "../contents/repositories/contents.repository";
import { EventsGateway } from "../realtime/events.gateway";
import { S3Service } from "../s3/s3.service";
import { CommentsService } from "./comments.service";
import { CommentLikesRepository } from "./repositories/comment-likes.repository";
import { CommentsRepository } from "./repositories/comments.repository";
describe("CommentsService", () => {
let service: CommentsService;
let repository: CommentsRepository;
const mockCommentsRepository = {
create: jest.fn(),
findAllByContentId: jest.fn(),
findOne: jest.fn(),
findOneEnriched: jest.fn(),
delete: jest.fn(),
};
const mockCommentLikesRepository = {
addLike: jest.fn(),
removeLike: jest.fn(),
countByCommentId: jest.fn(),
isLikedByUser: jest.fn(),
};
const mockContentsRepository = {
findOne: jest.fn(),
};
const mockS3Service = {
getPublicUrl: jest.fn(),
};
const mockEventsGateway = {
sendToContent: jest.fn(),
sendToUser: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
CommentsService,
{ provide: CommentsRepository, useValue: mockCommentsRepository },
{ provide: CommentLikesRepository, useValue: mockCommentLikesRepository },
{ provide: ContentsRepository, useValue: mockContentsRepository },
{ provide: S3Service, useValue: mockS3Service },
{ provide: EventsGateway, useValue: mockEventsGateway },
],
}).compile();
service = module.get<CommentsService>(CommentsService);
repository = module.get<CommentsRepository>(CommentsRepository);
});
it("should be defined", () => {
expect(service).toBeDefined();
});
describe("create", () => {
it("should create a comment", async () => {
const userId = "user1";
const contentId = "content1";
const dto = { text: "Nice meme", parentId: undefined };
const createdComment = { id: "c1", ...dto, user: { username: "u1" } };
mockCommentsRepository.create.mockResolvedValue(createdComment);
mockCommentsRepository.findOneEnriched.mockResolvedValue(createdComment);
mockCommentLikesRepository.countByCommentId.mockResolvedValue(0);
mockCommentLikesRepository.isLikedByUser.mockResolvedValue(false);
const result = await service.create(userId, contentId, dto);
expect(result.id).toBe("c1");
expect(repository.create).toHaveBeenCalledWith({
userId,
contentId,
text: dto.text,
parentId: undefined,
});
expect(mockEventsGateway.sendToContent).toHaveBeenCalledWith(
contentId,
"new_comment",
expect.any(Object),
);
});
});
describe("findAllByContentId", () => {
it("should return comments for a content", async () => {
mockCommentsRepository.findAllByContentId.mockResolvedValue([
{ id: "c1", user: { avatarUrl: "path" } },
]);
mockCommentLikesRepository.countByCommentId.mockResolvedValue(5);
mockCommentLikesRepository.isLikedByUser.mockResolvedValue(true);
mockS3Service.getPublicUrl.mockReturnValue("url");
const result = await service.findAllByContentId("content1", "u1");
expect(result).toHaveLength(1);
expect(result[0].likesCount).toBe(5);
expect(result[0].isLiked).toBe(true);
expect(result[0].user.avatarUrl).toBe("url");
});
});
describe("remove", () => {
it("should remove comment if owner", async () => {
mockCommentsRepository.findOne.mockResolvedValue({ userId: "u1" });
await service.remove("u1", "c1");
expect(repository.delete).toHaveBeenCalledWith("c1");
});
it("should remove comment if admin", async () => {
mockCommentsRepository.findOne.mockResolvedValue({ userId: "u1" });
await service.remove("other", "c1", true);
expect(repository.delete).toHaveBeenCalledWith("c1");
});
it("should throw NotFoundException if comment does not exist", async () => {
mockCommentsRepository.findOne.mockResolvedValue(null);
await expect(service.remove("u1", "c1")).rejects.toThrow(NotFoundException);
});
it("should throw ForbiddenException if not owner and not admin", async () => {
mockCommentsRepository.findOne.mockResolvedValue({ userId: "u1" });
await expect(service.remove("other", "c1")).rejects.toThrow(
ForbiddenException,
);
});
});
describe("like", () => {
it("should add like", async () => {
mockCommentsRepository.findOne.mockResolvedValue({ id: "c1" });
await service.like("u1", "c1");
expect(mockCommentLikesRepository.addLike).toHaveBeenCalledWith("c1", "u1");
});
});
describe("unlike", () => {
it("should remove like", async () => {
mockCommentsRepository.findOne.mockResolvedValue({ id: "c1" });
await service.unlike("u1", "c1");
expect(mockCommentLikesRepository.removeLike).toHaveBeenCalledWith(
"c1",
"u1",
);
});
});
});

View File

@@ -1,177 +0,0 @@
import {
ForbiddenException,
forwardRef,
Inject,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { ContentsRepository } from "../contents/repositories/contents.repository";
import { EventsGateway } from "../realtime/events.gateway";
import { S3Service } from "../s3/s3.service";
import type { CreateCommentDto } from "./dto/create-comment.dto";
import { CommentLikesRepository } from "./repositories/comment-likes.repository";
import { CommentsRepository } from "./repositories/comments.repository";
@Injectable()
export class CommentsService {
constructor(
private readonly commentsRepository: CommentsRepository,
private readonly commentLikesRepository: CommentLikesRepository,
@Inject(forwardRef(() => ContentsRepository))
private readonly contentsRepository: ContentsRepository,
private readonly s3Service: S3Service,
private readonly eventsGateway: EventsGateway,
) {}
async create(userId: string, contentId: string, dto: CreateCommentDto) {
const comment = await this.commentsRepository.create({
userId,
contentId,
text: dto.text,
parentId: dto.parentId,
});
// Récupérer le commentaire avec les infos utilisateur pour le WebSocket
const enrichedComment = await this.findOneEnriched(comment.id, userId);
if (!enrichedComment) return null;
// Notifier les autres utilisateurs sur ce contenu (room de contenu)
this.eventsGateway.sendToContent(contentId, "new_comment", enrichedComment);
// Notifications ciblées
try {
// 1. Notifier l'auteur du post
const content = await this.contentsRepository.findOne(contentId);
if (content && content.userId !== userId) {
this.eventsGateway.sendToUser(content.userId, "notification", {
type: "comment",
userId: userId,
username: enrichedComment.user.username,
contentId: contentId,
commentId: comment.id,
text: `a commenté votre post : "${dto.text.substring(0, 30)}${dto.text.length > 30 ? "..." : ""}"`,
});
}
// 2. Si c'est une réponse, notifier l'auteur du commentaire parent
if (dto.parentId) {
const parentComment = await this.commentsRepository.findOne(dto.parentId);
if (
parentComment &&
parentComment.userId !== userId &&
(!content || parentComment.userId !== content.userId)
) {
this.eventsGateway.sendToUser(parentComment.userId, "notification", {
type: "reply",
userId: userId,
username: enrichedComment.user.username,
contentId: contentId,
commentId: comment.id,
text: `a répondu à votre commentaire : "${dto.text.substring(0, 30)}${dto.text.length > 30 ? "..." : ""}"`,
});
}
}
} catch (error) {
console.error("Failed to send notification:", error);
}
return enrichedComment;
}
async findOneEnriched(commentId: string, currentUserId?: string) {
const comment = await this.commentsRepository.findOneEnriched(commentId);
if (!comment) return null;
const [likesCount, isLiked] = await Promise.all([
this.commentLikesRepository.countByCommentId(comment.id),
currentUserId
? this.commentLikesRepository.isLikedByUser(comment.id, currentUserId)
: Promise.resolve(false),
]);
return {
...comment,
likesCount,
isLiked,
user: {
...comment.user,
avatarUrl: comment.user.avatarUrl
? this.s3Service.getPublicUrl(comment.user.avatarUrl)
: null,
},
};
}
async findAllByContentId(contentId: string, userId?: string) {
const comments = await this.commentsRepository.findAllByContentId(contentId);
return Promise.all(
comments.map(async (comment) => {
const [likesCount, isLiked] = await Promise.all([
this.commentLikesRepository.countByCommentId(comment.id),
userId
? this.commentLikesRepository.isLikedByUser(comment.id, userId)
: Promise.resolve(false),
]);
return {
...comment,
likesCount,
isLiked,
user: {
...comment.user,
avatarUrl: comment.user.avatarUrl
? this.s3Service.getPublicUrl(comment.user.avatarUrl)
: null,
},
};
}),
);
}
async remove(userId: string, commentId: string, isAdmin = false) {
const comment = await this.commentsRepository.findOne(commentId);
if (!comment) {
throw new NotFoundException("Comment not found");
}
if (!isAdmin && comment.userId !== userId) {
throw new ForbiddenException("You cannot delete this comment");
}
await this.commentsRepository.delete(commentId);
}
async like(userId: string, commentId: string) {
const comment = await this.commentsRepository.findOne(commentId);
if (!comment) {
throw new NotFoundException("Comment not found");
}
await this.commentLikesRepository.addLike(commentId, userId);
// Notifier l'auteur du commentaire
if (comment.userId !== userId) {
try {
const liker = await this.findOneEnriched(commentId, userId);
this.eventsGateway.sendToUser(comment.userId, "notification", {
type: "like_comment",
userId: userId,
username: liker?.user.username,
contentId: comment.contentId,
commentId: commentId,
text: "a aimé votre commentaire",
});
} catch (error) {
console.error("Failed to send like notification:", error);
}
}
}
async unlike(userId: string, commentId: string) {
const comment = await this.commentsRepository.findOne(commentId);
if (!comment) {
throw new NotFoundException("Comment not found");
}
await this.commentLikesRepository.removeLike(commentId, userId);
}
}

View File

@@ -1,18 +0,0 @@
import {
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from "class-validator";
export class CreateCommentDto {
@IsString()
@IsNotEmpty()
@MaxLength(1000)
text!: string;
@IsOptional()
@IsUUID()
parentId?: string;
}

View File

@@ -1,42 +0,0 @@
import { Injectable } from "@nestjs/common";
import { and, eq, sql } from "drizzle-orm";
import { DatabaseService } from "../../database/database.service";
import { commentLikes } from "../../database/schemas/comment_likes";
@Injectable()
export class CommentLikesRepository {
constructor(private readonly databaseService: DatabaseService) {}
async addLike(commentId: string, userId: string) {
await this.databaseService.db
.insert(commentLikes)
.values({ commentId, userId })
.onConflictDoNothing();
}
async removeLike(commentId: string, userId: string) {
await this.databaseService.db
.delete(commentLikes)
.where(
and(eq(commentLikes.commentId, commentId), eq(commentLikes.userId, userId)),
);
}
async countByCommentId(commentId: string) {
const results = await this.databaseService.db
.select({ count: sql<number>`count(*)` })
.from(commentLikes)
.where(eq(commentLikes.commentId, commentId));
return Number(results[0]?.count || 0);
}
async isLikedByUser(commentId: string, userId: string) {
const results = await this.databaseService.db
.select()
.from(commentLikes)
.where(
and(eq(commentLikes.commentId, commentId), eq(commentLikes.userId, userId)),
);
return !!results[0];
}
}

View File

@@ -1,75 +0,0 @@
import { Injectable } from "@nestjs/common";
import { and, desc, eq, isNull } from "drizzle-orm";
import { DatabaseService } from "../../database/database.service";
import { comments, users } from "../../database/schemas";
import type { NewCommentInDb } from "../../database/schemas/comments";
@Injectable()
export class CommentsRepository {
constructor(private readonly databaseService: DatabaseService) {}
async create(data: NewCommentInDb) {
const results = await this.databaseService.db
.insert(comments)
.values(data)
.returning();
return results[0];
}
async findAllByContentId(contentId: string) {
return this.databaseService.db
.select({
id: comments.id,
text: comments.text,
parentId: comments.parentId,
createdAt: comments.createdAt,
updatedAt: comments.updatedAt,
user: {
uuid: users.uuid,
username: users.username,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
},
})
.from(comments)
.innerJoin(users, eq(comments.userId, users.uuid))
.where(and(eq(comments.contentId, contentId), isNull(comments.deletedAt)))
.orderBy(desc(comments.createdAt));
}
async findOne(id: string) {
const results = await this.databaseService.db
.select()
.from(comments)
.where(and(eq(comments.id, id), isNull(comments.deletedAt)));
return results[0];
}
async findOneEnriched(id: string) {
const results = await this.databaseService.db
.select({
id: comments.id,
text: comments.text,
parentId: comments.parentId,
createdAt: comments.createdAt,
updatedAt: comments.updatedAt,
user: {
uuid: users.uuid,
username: users.username,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
},
})
.from(comments)
.innerJoin(users, eq(comments.userId, users.uuid))
.where(and(eq(comments.id, id), isNull(comments.deletedAt)));
return results[0];
}
async delete(id: string) {
await this.databaseService.db
.update(comments)
.set({ deletedAt: new Date() })
.where(eq(comments.id, id));
}
}

View File

@@ -9,14 +9,6 @@ import {
import * as Sentry from "@sentry/nestjs";
import { Request, Response } from "express";
interface RequestWithUser extends Request {
user?: {
sub?: string;
username?: string;
id?: string;
};
}
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger("ExceptionFilter");
@@ -24,7 +16,7 @@ export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<RequestWithUser>();
const request = ctx.getRequest<Request>();
const status =
exception instanceof HttpException
@@ -36,9 +28,6 @@ export class AllExceptionsFilter implements ExceptionFilter {
? exception.getResponse()
: "Internal server error";
const userId = request.user?.sub || request.user?.id;
const userPart = userId ? `[User: ${userId}] ` : "";
const errorResponse = {
statusCode: status,
timestamp: new Date().toISOString(),
@@ -53,12 +42,12 @@ export class AllExceptionsFilter implements ExceptionFilter {
if (status === HttpStatus.INTERNAL_SERVER_ERROR) {
Sentry.captureException(exception);
this.logger.error(
`${userPart}${request.method} ${request.url} - Error: ${exception instanceof Error ? exception.message : "Unknown error"}`,
`${request.method} ${request.url} - Error: ${exception instanceof Error ? exception.message : "Unknown error"}`,
exception instanceof Error ? exception.stack : "",
);
} else {
this.logger.warn(
`${userPart}${request.method} ${request.url} - Status: ${status} - Message: ${JSON.stringify(message)}`,
`${request.method} ${request.url} - Status: ${status} - Message: ${JSON.stringify(message)}`,
);
}

View File

@@ -4,6 +4,5 @@ export interface AuthenticatedRequest extends Request {
user: {
sub: string;
username: string;
role: string;
};
}

View File

@@ -1,14 +1,10 @@
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Inject, Injectable, Logger, NestMiddleware } from "@nestjs/common";
import type { Cache } from "cache-manager";
import { Injectable, Logger, NestMiddleware } from "@nestjs/common";
import type { NextFunction, Request, Response } from "express";
@Injectable()
export class CrawlerDetectionMiddleware implements NestMiddleware {
private readonly logger = new Logger("CrawlerDetection");
constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}
private readonly SUSPICIOUS_PATTERNS = [
/\.env/,
/wp-admin/,
@@ -28,7 +24,7 @@ export class CrawlerDetectionMiddleware implements NestMiddleware {
/db\./,
/backup\./,
/cgi-bin/,
/\.well-known\/security\.txt/,
/\.well-known\/security\.txt/, // Bien que légitime, souvent scanné
];
private readonly BOT_USER_AGENTS = [
@@ -44,21 +40,11 @@ export class CrawlerDetectionMiddleware implements NestMiddleware {
/masscan/i,
];
async use(req: Request, res: Response, next: NextFunction) {
use(req: Request, res: Response, next: NextFunction) {
const { method, url, ip } = req;
const userAgent = req.get("user-agent") || "unknown";
// Vérifier si l'IP est bannie
const isBanned = await this.cacheManager.get(`banned_ip:${ip}`);
if (isBanned) {
this.logger.warn(`Banned IP attempt: ${ip} -> ${method} ${url}`);
res.status(403).json({
message: "Access denied: Your IP has been temporarily banned.",
});
return;
}
res.on("finish", async () => {
res.on("finish", () => {
if (res.statusCode === 404) {
const isSuspiciousPath = this.SUSPICIOUS_PATTERNS.some((pattern) =>
pattern.test(url),
@@ -71,9 +57,7 @@ export class CrawlerDetectionMiddleware implements NestMiddleware {
this.logger.warn(
`Potential crawler detected: [${ip}] ${method} ${url} - User-Agent: ${userAgent}`,
);
// Bannir l'IP pour 24h via Redis
await this.cacheManager.set(`banned_ip:${ip}`, true, 86400000);
// Ici, on pourrait ajouter une logique pour bannir l'IP temporairement via Redis
}
}
});

View File

@@ -1,37 +0,0 @@
import { createHash } from "node:crypto";
import { Injectable, Logger, NestMiddleware } from "@nestjs/common";
import { NextFunction, Request, Response } from "express";
@Injectable()
export class HTTPLoggerMiddleware implements NestMiddleware {
private readonly logger = new Logger("HTTP");
use(request: Request, response: Response, next: NextFunction): void {
const { method, originalUrl, ip } = request;
const userAgent = request.get("user-agent") || "";
const startTime = Date.now();
response.on("finish", () => {
const { statusCode } = response;
const contentLength = response.get("content-length");
const duration = Date.now() - startTime;
const hashedIp = createHash("sha256")
.update(ip as string)
.digest("hex");
const message = `${method} ${originalUrl} ${statusCode} ${contentLength || 0} - ${userAgent} ${hashedIp} +${duration}ms`;
if (statusCode >= 500) {
return this.logger.error(message);
}
if (statusCode >= 400) {
return this.logger.warn(message);
}
return this.logger.log(message);
});
next();
}
}

View File

@@ -48,7 +48,6 @@ export const envSchema = z.object({
// Media Limits
MAX_IMAGE_SIZE_KB: z.coerce.number().default(512),
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>;

View File

@@ -1,230 +0,0 @@
jest.mock("uuid", () => ({
v4: jest.fn(() => "mocked-uuid"),
}));
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
ml_kem768: {
keygen: jest.fn(),
encapsulate: jest.fn(),
decapsulate: jest.fn(),
},
}));
jest.mock("jose", () => ({
SignJWT: jest.fn().mockReturnValue({
setProtectedHeader: jest.fn().mockReturnThis(),
setIssuedAt: jest.fn().mockReturnThis(),
setExpirationTime: jest.fn().mockReturnThis(),
sign: jest.fn().mockResolvedValue("mocked-jwt"),
}),
jwtVerify: jest.fn(),
}));
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Test, TestingModule } from "@nestjs/testing";
import { AuthGuard } from "../auth/guards/auth.guard";
import { OptionalAuthGuard } from "../auth/guards/optional-auth.guard";
import { RolesGuard } from "../auth/guards/roles.guard";
import { AuthenticatedRequest } from "../common/interfaces/request.interface";
import { ContentsController } from "./contents.controller";
import { ContentsService } from "./contents.service";
describe("ContentsController", () => {
let controller: ContentsController;
let service: ContentsService;
const mockContentsService = {
create: jest.fn(),
getUploadUrl: jest.fn(),
uploadAndProcess: jest.fn(),
findAll: jest.fn(),
findOne: jest.fn(),
incrementViews: jest.fn(),
incrementUsage: jest.fn(),
remove: jest.fn(),
removeAdmin: jest.fn(),
generateBotHtml: jest.fn(),
};
const mockCacheManager = {
get: jest.fn(),
set: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ContentsController],
providers: [
{ provide: ContentsService, useValue: mockContentsService },
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.overrideGuard(OptionalAuthGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<ContentsController>(ContentsController);
service = module.get<ContentsService>(ContentsService);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("create", () => {
it("should call service.create", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
const dto = { title: "Title", type: "image" as any };
await controller.create(req, dto as any);
expect(service.create).toHaveBeenCalledWith("user-uuid", dto);
});
});
describe("getUploadUrl", () => {
it("should call service.getUploadUrl", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.getUploadUrl(req, "test.jpg");
expect(service.getUploadUrl).toHaveBeenCalledWith("user-uuid", "test.jpg");
});
});
describe("upload", () => {
it("should call service.uploadAndProcess", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
const file = {} as Express.Multer.File;
const dto = { title: "Title" };
await controller.upload(req, file, dto as any);
expect(service.uploadAndProcess).toHaveBeenCalledWith(
"user-uuid",
file,
dto,
);
});
});
describe("explore", () => {
it("should call service.findAll", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.explore(
req,
10,
0,
"trend",
"tag",
"cat",
"auth",
"query",
false,
undefined,
);
expect(service.findAll).toHaveBeenCalledWith({
limit: 10,
offset: 0,
sortBy: "trend",
tag: "tag",
category: "cat",
author: "auth",
query: "query",
favoritesOnly: false,
userId: "user-uuid",
});
});
});
describe("trends", () => {
it("should call service.findAll with trend sort", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.trends(req, 10, 0);
expect(service.findAll).toHaveBeenCalledWith({
limit: 10,
offset: 0,
sortBy: "trend",
userId: "user-uuid",
});
});
});
describe("recent", () => {
it("should call service.findAll with recent sort", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.recent(req, 10, 0);
expect(service.findAll).toHaveBeenCalledWith({
limit: 10,
offset: 0,
sortBy: "recent",
userId: "user-uuid",
});
});
});
describe("findOne", () => {
it("should return json for normal user", async () => {
const req = { user: { sub: "user-uuid" }, headers: {} } as any;
const res = { json: jest.fn(), send: jest.fn() } as any;
const content = { id: "1" };
mockContentsService.findOne.mockResolvedValue(content);
await controller.findOne("1", req, res);
expect(res.json).toHaveBeenCalledWith(content);
});
it("should return html for bot", async () => {
const req = {
user: { sub: "user-uuid" },
headers: { "user-agent": "Googlebot" },
} as any;
const res = { json: jest.fn(), send: jest.fn() } as any;
const content = { id: "1" };
mockContentsService.findOne.mockResolvedValue(content);
mockContentsService.generateBotHtml.mockReturnValue("<html></html>");
await controller.findOne("1", req, res);
expect(res.send).toHaveBeenCalledWith("<html></html>");
});
it("should throw NotFoundException if not found", async () => {
const req = { user: { sub: "user-uuid" }, headers: {} } as any;
const res = { json: jest.fn(), send: jest.fn() } as any;
mockContentsService.findOne.mockResolvedValue(null);
await expect(controller.findOne("1", req, res)).rejects.toThrow(
"Contenu non trouvé",
);
});
});
describe("incrementViews", () => {
it("should call service.incrementViews", async () => {
await controller.incrementViews("1");
expect(service.incrementViews).toHaveBeenCalledWith("1");
});
});
describe("incrementUsage", () => {
it("should call service.incrementUsage", async () => {
await controller.incrementUsage("1");
expect(service.incrementUsage).toHaveBeenCalledWith("1");
});
});
describe("remove", () => {
it("should call service.remove", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.remove("1", req);
expect(service.remove).toHaveBeenCalledWith("1", "user-uuid");
});
});
describe("removeAdmin", () => {
it("should call service.removeAdmin", async () => {
await controller.removeAdmin("1");
expect(service.removeAdmin).toHaveBeenCalledWith("1");
});
});
});

View File

@@ -10,7 +10,6 @@ import {
Param,
ParseBoolPipe,
ParseIntPipe,
Patch,
Post,
Query,
Req,
@@ -174,16 +173,6 @@ export class ContentsController {
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")
@UseGuards(AuthGuard)
remove(@Param("id") id: string, @Req() req: AuthenticatedRequest) {
@@ -196,11 +185,4 @@ export class ContentsController {
removeAdmin(@Param("id") id: string) {
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);
}
}

View File

@@ -1,14 +1,13 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { MediaModule } from "../media/media.module";
import { RealtimeModule } from "../realtime/realtime.module";
import { S3Module } from "../s3/s3.module";
import { ContentsController } from "./contents.controller";
import { ContentsService } from "./contents.service";
import { ContentsRepository } from "./repositories/contents.repository";
@Module({
imports: [S3Module, AuthModule, MediaModule, RealtimeModule],
imports: [S3Module, AuthModule, MediaModule],
controllers: [ContentsController],
providers: [ContentsService, ContentsRepository],
exports: [ContentsRepository],

View File

@@ -7,7 +7,6 @@ import { BadRequestException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
import { MediaService } from "../media/media.service";
import { EventsGateway } from "../realtime/events.gateway";
import { S3Service } from "../s3/s3.service";
import { ContentsService } from "./contents.service";
import { ContentsRepository } from "./repositories/contents.repository";
@@ -24,7 +23,6 @@ describe("ContentsService", () => {
incrementViews: jest.fn(),
incrementUsage: jest.fn(),
softDelete: jest.fn(),
softDeleteAdmin: jest.fn(),
findOne: jest.fn(),
findBySlug: jest.fn(),
};
@@ -50,10 +48,6 @@ describe("ContentsService", () => {
del: jest.fn(),
};
const mockEventsGateway = {
sendToUser: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
@@ -65,7 +59,6 @@ describe("ContentsService", () => {
{ provide: MediaService, useValue: mockMediaService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
{ provide: EventsGateway, useValue: mockEventsGateway },
],
}).compile();
@@ -154,81 +147,4 @@ describe("ContentsService", () => {
expect(result[0].views).toBe(1);
});
});
describe("incrementUsage", () => {
it("should increment usage", async () => {
mockContentsRepository.incrementUsage.mockResolvedValue([
{ id: "1", usageCount: 1 },
]);
await service.incrementUsage("1");
expect(mockContentsRepository.incrementUsage).toHaveBeenCalledWith("1");
});
});
describe("remove", () => {
it("should soft delete content", async () => {
mockContentsRepository.softDelete.mockResolvedValue({ id: "1" });
await service.remove("1", "u1");
expect(mockContentsRepository.softDelete).toHaveBeenCalledWith("1", "u1");
});
});
describe("removeAdmin", () => {
it("should soft delete content without checking owner", async () => {
mockContentsRepository.softDeleteAdmin.mockResolvedValue({ id: "1" });
await service.removeAdmin("1");
expect(mockContentsRepository.softDeleteAdmin).toHaveBeenCalledWith("1");
});
});
describe("findOne", () => {
it("should return content by id", async () => {
mockContentsRepository.findOne.mockResolvedValue({
id: "1",
storageKey: "k",
author: { avatarUrl: "a" },
});
mockS3Service.getPublicUrl.mockReturnValue("url");
const result = await service.findOne("1");
expect(result.id).toBe("1");
expect(result.url).toBe("url");
});
it("should return content by slug", async () => {
mockContentsRepository.findOne.mockResolvedValue({
id: "1",
slug: "s",
storageKey: "k",
});
const result = await service.findOne("s");
expect(result.slug).toBe("s");
});
});
describe("generateBotHtml", () => {
it("should generate html with og tags", () => {
const content = { title: "Title", storageKey: "k" };
mockS3Service.getPublicUrl.mockReturnValue("url");
const html = service.generateBotHtml(content as any);
expect(html).toContain("<title>Title</title>");
expect(html).toContain('content="Title"');
expect(html).toContain('content="url"');
});
});
describe("ensureUniqueSlug", () => {
it("should return original slug if unique", async () => {
mockContentsRepository.findBySlug.mockResolvedValue(null);
const slug = (service as any).ensureUniqueSlug("My Title");
await expect(slug).resolves.toBe("my-title");
});
it("should append counter if not unique", async () => {
mockContentsRepository.findBySlug
.mockResolvedValueOnce({ id: "1" })
.mockResolvedValueOnce(null);
const slug = await (service as any).ensureUniqueSlug("My Title");
expect(slug).toBe("my-title-1");
});
});
});

View File

@@ -14,7 +14,6 @@ import type {
} from "../common/interfaces/media.interface";
import type { IStorageService } from "../common/interfaces/storage.interface";
import { MediaService } from "../media/media.service";
import { EventsGateway } from "../realtime/events.gateway";
import { S3Service } from "../s3/s3.service";
import { CreateContentDto } from "./dto/create-content.dto";
import { UploadContentDto } from "./dto/upload-content.dto";
@@ -30,7 +29,6 @@ export class ContentsService {
@Inject(MediaService) private readonly mediaService: IMediaService,
private readonly configService: ConfigService,
@Inject(CACHE_MANAGER) private cacheManager: Cache,
private readonly eventsGateway: EventsGateway,
) {}
private async clearContentsCache() {
@@ -50,11 +48,6 @@ export class ContentsService {
data: UploadContentDto,
) {
this.logger.log(`Uploading and processing file for user ${userId}`);
this.eventsGateway.sendToUser(userId, "upload_progress", {
status: "starting",
progress: 0,
});
// 0. Validation du format et de la taille
const allowedMimeTypes = [
"image/png",
@@ -62,117 +55,59 @@ export class ContentsService {
"image/webp",
"image/gif",
"video/webm",
"video/mp4",
"video/quicktime",
];
if (!allowedMimeTypes.includes(file.mimetype)) {
this.eventsGateway.sendToUser(userId, "upload_progress", {
status: "error",
message: "Format de fichier non supporté",
});
throw new BadRequestException(
"Format de fichier non supporté. Formats acceptés: png, jpeg, jpg, webp, webm, mp4, mov, gif.",
"Format de fichier non supporté. Formats acceptés: png, jpeg, jpg, webp, webm, gif.",
);
}
// Autodétermination du type si non fourni ou pour valider
let contentType: "meme" | "gif" | "video" = "meme";
if (file.mimetype === "image/gif") {
contentType = "gif";
} else if (file.mimetype.startsWith("video/")) {
contentType = "video";
}
const isGif = contentType === "gif";
const isVideo = contentType === "video";
let maxSizeKb: number;
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);
}
const isGif = file.mimetype === "image/gif";
const maxSizeKb = isGif
? this.configService.get<number>("MAX_GIF_SIZE_KB", 1024)
: this.configService.get<number>("MAX_IMAGE_SIZE_KB", 512);
if (file.size > maxSizeKb * 1024) {
this.eventsGateway.sendToUser(userId, "upload_progress", {
status: "error",
message: "Fichier trop volumineux",
});
throw new BadRequestException(
`Fichier trop volumineux. Limite pour ${isGif ? "GIF" : isVideo ? "vidéo" : "image"}: ${maxSizeKb} Ko.`,
`Fichier trop volumineux. Limite pour ${isGif ? "GIF" : "image"}: ${maxSizeKb} Ko.`,
);
}
// 1. Scan Antivirus
this.eventsGateway.sendToUser(userId, "upload_progress", {
status: "scanning",
progress: 20,
});
const scanResult = await this.mediaService.scanFile(
file.buffer,
file.originalname,
);
if (scanResult.isInfected) {
this.eventsGateway.sendToUser(userId, "upload_progress", {
status: "error",
message: "Fichier infecté",
});
throw new BadRequestException(
`Le fichier est infecté par ${scanResult.virusName}`,
);
}
// 2. Transcodage
this.eventsGateway.sendToUser(userId, "upload_progress", {
status: "processing",
progress: 40,
});
let processed: MediaProcessingResult;
if (file.mimetype.startsWith("image/") && file.mimetype !== "image/gif") {
// Image -> WebP (format moderne, bien supporté)
if (file.mimetype.startsWith("image/")) {
// Image ou GIF -> WebP (format moderne, bien supporté)
processed = await this.mediaService.processImage(file.buffer, "webp");
} else if (
file.mimetype.startsWith("video/") ||
file.mimetype === "image/gif"
) {
// Vidéo ou GIF -> WebM
} else if (file.mimetype.startsWith("video/")) {
// Vidéo -> WebM
processed = await this.mediaService.processVideo(file.buffer, "webm");
} else {
throw new BadRequestException("Format de fichier non supporté");
}
// 3. Upload vers S3
this.eventsGateway.sendToUser(userId, "upload_progress", {
status: "uploading_s3",
progress: 70,
});
const key = `contents/${userId}/${Date.now()}-${uuidv4()}.${processed.extension}`;
await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType);
this.logger.log(`File uploaded successfully to S3: ${key}`);
// 4. Création en base de données
this.eventsGateway.sendToUser(userId, "upload_progress", {
status: "saving",
progress: 90,
});
const content = await this.create(userId, {
return await this.create(userId, {
...data,
type: contentType, // Utiliser le type autodéterminé
storageKey: key,
mimeType: processed.mimeType,
fileSize: processed.size,
});
this.eventsGateway.sendToUser(userId, "upload_progress", {
status: "completed",
progress: 100,
contentId: content.id,
});
return content;
}
async findAll(options: {
@@ -248,35 +183,6 @@ export class ContentsService {
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) {
const content = await this.contentsRepository.findOne(idOrSlug, userId);
if (!content) return null;

View File

@@ -12,12 +12,11 @@ import {
export enum ContentType {
MEME = "meme",
GIF = "gif",
VIDEO = "video",
}
export class CreateContentDto {
@IsEnum(ContentType)
type!: "meme" | "gif" | "video";
type!: "meme" | "gif";
@IsString()
@IsNotEmpty()

View File

@@ -11,7 +11,7 @@ import { ContentType } from "./create-content.dto";
export class UploadContentDto {
@IsEnum(ContentType)
type!: "meme" | "gif" | "video";
type!: "meme" | "gif";
@IsString()
@IsNotEmpty()

View File

@@ -404,15 +404,6 @@ export class ContentsRepository {
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) {
const [result] = await this.databaseService.db
.select()

View File

@@ -1,67 +0,0 @@
import { ConfigService } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "./database.service";
jest.mock("pg", () => {
const mPool = {
connect: jest.fn(),
query: jest.fn(),
end: jest.fn(),
on: jest.fn(),
};
return { Pool: jest.fn(() => mPool) };
});
jest.mock("drizzle-orm/node-postgres", () => ({
drizzle: jest.fn().mockReturnValue({}),
}));
describe("DatabaseService", () => {
let service: DatabaseService;
let _configService: ConfigService;
const mockConfigService = {
get: jest.fn((key) => {
const config = {
POSTGRES_PASSWORD: "p",
POSTGRES_USER: "u",
POSTGRES_HOST: "h",
POSTGRES_PORT: "5432",
POSTGRES_DB: "db",
NODE_ENV: "development",
};
return config[key];
}),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
DatabaseService,
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
service = module.get<DatabaseService>(DatabaseService);
_configService = module.get<ConfigService>(ConfigService);
});
it("should be defined", () => {
expect(service).toBeDefined();
});
describe("onModuleInit", () => {
it("should skip migrations in development", async () => {
await service.onModuleInit();
expect(mockConfigService.get).toHaveBeenCalledWith("NODE_ENV");
});
});
describe("onModuleDestroy", () => {
it("should close pool", async () => {
const pool = (service as any).pool;
await service.onModuleDestroy();
expect(pool.end).toHaveBeenCalled();
});
});
});

View File

@@ -1,21 +0,0 @@
import { pgTable, primaryKey, timestamp, uuid } from "drizzle-orm/pg-core";
import { comments } from "./comments";
import { users } from "./users";
export const commentLikes = pgTable(
"comment_likes",
{
commentId: uuid("comment_id")
.notNull()
.references(() => comments.id, { onDelete: "cascade" }),
userId: uuid("user_id")
.notNull()
.references(() => users.uuid, { onDelete: "cascade" }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
pk: primaryKey({ columns: [t.commentId, t.userId] }),
}),
);

View File

@@ -1,35 +0,0 @@
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { contents } from "./content";
import { users } from "./users";
export const comments = pgTable(
"comments",
{
id: uuid("id").primaryKey().defaultRandom(),
contentId: uuid("content_id")
.notNull()
.references(() => contents.id, { onDelete: "cascade" }),
userId: uuid("user_id")
.notNull()
.references(() => users.uuid, { onDelete: "cascade" }),
parentId: uuid("parent_id").references(() => comments.id, {
onDelete: "cascade",
}),
text: text("text").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
deletedAt: timestamp("deleted_at", { withTimezone: true }),
},
(table) => ({
contentIdIdx: index("comments_content_id_idx").on(table.contentId),
userIdIdx: index("comments_user_id_idx").on(table.userId),
parentIdIdx: index("comments_parent_id_idx").on(table.parentId),
}),
);
export type CommentInDb = typeof comments.$inferSelect;
export type NewCommentInDb = typeof comments.$inferInsert;

View File

@@ -12,7 +12,7 @@ import { categories } from "./categories";
import { tags } from "./tags";
import { users } from "./users";
export const contentType = pgEnum("content_type", ["meme", "gif", "video"]);
export const contentType = pgEnum("content_type", ["meme", "gif"]);
export const contents = pgTable(
"contents",

View File

@@ -1,11 +1,8 @@
export * from "./api_keys";
export * from "./audit_logs";
export * from "./categories";
export * from "./comment_likes";
export * from "./comments";
export * from "./content";
export * from "./favorites";
export * from "./messages";
export * from "./pgp";
export * from "./rbac";
export * from "./reports";

View File

@@ -1,66 +0,0 @@
import {
index,
pgTable,
primaryKey,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
import { users } from "./users";
export const conversations = pgTable("conversations", {
id: uuid("id").primaryKey().defaultRandom(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
});
export const conversationParticipants = pgTable(
"conversation_participants",
{
conversationId: uuid("conversation_id")
.notNull()
.references(() => conversations.id, { onDelete: "cascade" }),
userId: uuid("user_id")
.notNull()
.references(() => users.uuid, { onDelete: "cascade" }),
joinedAt: timestamp("joined_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
pk: primaryKey({ columns: [t.conversationId, t.userId] }),
}),
);
export const messages = pgTable(
"messages",
{
id: uuid("id").primaryKey().defaultRandom(),
conversationId: uuid("conversation_id")
.notNull()
.references(() => conversations.id, { onDelete: "cascade" }),
senderId: uuid("sender_id")
.notNull()
.references(() => users.uuid, { onDelete: "cascade" }),
text: text("text").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
readAt: timestamp("read_at", { withTimezone: true }),
},
(table) => ({
conversationIdIdx: index("messages_conversation_id_idx").on(
table.conversationId,
),
senderIdIdx: index("messages_sender_id_idx").on(table.senderId),
}),
);
export type ConversationInDb = typeof conversations.$inferSelect;
export type NewConversationInDb = typeof conversations.$inferInsert;
export type MessageInDb = typeof messages.$inferSelect;
export type NewMessageInDb = typeof messages.$inferInsert;

View File

@@ -41,9 +41,7 @@ export const pgpEncrypted = customType<{ data: string; driverData: Buffer }>({
export function withAutomaticPgpDecrypt<T extends AnyPgColumn>(column: T): T {
const originalGetSQL = column.getSQL.bind(column);
column.getSQL = () =>
sql`pgp_sym_decrypt(${originalGetSQL()}, ${getPgpKey()})::text`.mapWith(
column,
);
sql`pgp_sym_decrypt(${originalGetSQL()}, ${getPgpKey()})`.mapWith(column);
return column;
}
@@ -61,7 +59,5 @@ export function pgpSymDecrypt(
column: AnyPgColumn,
key: string | SQL,
): SQL<string> {
return sql`pgp_sym_decrypt(${column}, ${key})::text`.mapWith(
column,
) as SQL<string>;
return sql`pgp_sym_decrypt(${column}, ${key})`.mapWith(column) as SQL<string>;
}

View File

@@ -36,8 +36,6 @@ export const users = pgTable(
// Sécurité
twoFactorSecret: pgpEncrypted("two_factor_secret"),
isTwoFactorEnabled: boolean("is_two_factor_enabled").notNull().default(false),
showOnlineStatus: boolean("show_online_status").notNull().default(true),
showReadReceipts: boolean("show_read_receipts").notNull().default(true),
// RGPD & Conformité
termsVersion: varchar("terms_version", { length: 16 }), // Version des CGU acceptées

View File

@@ -1,82 +0,0 @@
jest.mock("uuid", () => ({
v4: jest.fn(() => "mocked-uuid"),
}));
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
ml_kem768: {
keygen: jest.fn(),
encapsulate: jest.fn(),
decapsulate: jest.fn(),
},
}));
jest.mock("jose", () => ({
SignJWT: jest.fn().mockReturnValue({
setProtectedHeader: jest.fn().mockReturnThis(),
setIssuedAt: jest.fn().mockReturnThis(),
setExpirationTime: jest.fn().mockReturnThis(),
sign: jest.fn().mockResolvedValue("mocked-jwt"),
}),
jwtVerify: jest.fn(),
}));
import { Test, TestingModule } from "@nestjs/testing";
import { AuthGuard } from "../auth/guards/auth.guard";
import { AuthenticatedRequest } from "../common/interfaces/request.interface";
import { FavoritesController } from "./favorites.controller";
import { FavoritesService } from "./favorites.service";
describe("FavoritesController", () => {
let controller: FavoritesController;
let service: FavoritesService;
const mockFavoritesService = {
addFavorite: jest.fn(),
removeFavorite: jest.fn(),
getUserFavorites: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [FavoritesController],
providers: [{ provide: FavoritesService, useValue: mockFavoritesService }],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<FavoritesController>(FavoritesController);
service = module.get<FavoritesService>(FavoritesService);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("add", () => {
it("should call service.addFavorite", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.add(req, "content-1");
expect(service.addFavorite).toHaveBeenCalledWith("user-uuid", "content-1");
});
});
describe("remove", () => {
it("should call service.removeFavorite", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.remove(req, "content-1");
expect(service.removeFavorite).toHaveBeenCalledWith(
"user-uuid",
"content-1",
);
});
});
describe("list", () => {
it("should call service.getUserFavorites", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.list(req, 10, 0);
expect(service.getUserFavorites).toHaveBeenCalledWith("user-uuid", 10, 0);
});
});
});

View File

@@ -1,72 +0,0 @@
import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "../../database/database.service";
import { FavoritesRepository } from "./favorites.repository";
describe("FavoritesRepository", () => {
let repository: FavoritesRepository;
const mockDb = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
innerJoin: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
offset: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
returning: jest.fn().mockReturnThis(),
execute: jest.fn(),
};
const wrapWithThen = (obj: unknown) => {
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
Object.defineProperty(obj, "then", {
value: function (onFulfilled: (arg0: unknown) => void) {
const result = (this as Record<string, unknown>).execute();
return Promise.resolve(result).then(onFulfilled);
},
configurable: true,
});
return obj;
};
wrapWithThen(mockDb);
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
FavoritesRepository,
{ provide: DatabaseService, useValue: { db: mockDb } },
],
}).compile();
repository = module.get<FavoritesRepository>(FavoritesRepository);
});
it("should find content by id", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
const result = await repository.findContentById("1");
expect(result.id).toBe("1");
});
it("should add favorite", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([
{ userId: "u", contentId: "c" },
]);
await repository.add("u", "c");
expect(mockDb.insert).toHaveBeenCalled();
});
it("should remove favorite", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
await repository.remove("u", "c");
expect(mockDb.delete).toHaveBeenCalled();
});
it("should find by user id", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ content: { id: "c1" } }]);
const result = await repository.findByUserId("u1", 10, 0);
expect(result).toHaveLength(1);
expect(result[0].id).toBe("c1");
});
});

View File

@@ -1,4 +1,3 @@
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "./database/database.service";
import { HealthController } from "./health.controller";
@@ -10,10 +9,6 @@ describe("HealthController", () => {
execute: jest.fn().mockResolvedValue([]),
};
const mockCacheManager = {
set: jest.fn().mockResolvedValue(undefined),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [HealthController],
@@ -24,42 +19,24 @@ describe("HealthController", () => {
db: mockDb,
},
},
{
provide: CACHE_MANAGER,
useValue: mockCacheManager,
},
],
}).compile();
controller = module.get<HealthController>(HealthController);
});
it("should return ok if database and redis are connected", async () => {
it("should return ok if database is connected", async () => {
mockDb.execute.mockResolvedValue([]);
mockCacheManager.set.mockResolvedValue(undefined);
const result = await controller.check();
expect(result.status).toBe("ok");
expect(result.database).toBe("connected");
expect(result.redis).toBe("connected");
});
it("should return error if database is disconnected", async () => {
mockDb.execute.mockRejectedValue(new Error("DB Error"));
mockCacheManager.set.mockResolvedValue(undefined);
const result = await controller.check();
expect(result.status).toBe("error");
expect(result.database).toBe("disconnected");
expect(result.databaseError).toBe("DB Error");
expect(result.redis).toBe("connected");
});
it("should return error if redis is disconnected", async () => {
mockDb.execute.mockResolvedValue([]);
mockCacheManager.set.mockRejectedValue(new Error("Redis Error"));
const result = await controller.check();
expect(result.status).toBe("error");
expect(result.database).toBe("connected");
expect(result.redis).toBe("disconnected");
expect(result.redisError).toBe("Redis Error");
expect(result.message).toBe("DB Error");
});
});

View File

@@ -1,44 +1,28 @@
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Controller, Get, Inject } from "@nestjs/common";
import type { Cache } from "cache-manager";
import { Controller, Get } from "@nestjs/common";
import { sql } from "drizzle-orm";
import { DatabaseService } from "./database/database.service";
@Controller("health")
export class HealthController {
constructor(
private readonly databaseService: DatabaseService,
@Inject(CACHE_MANAGER) private cacheManager: Cache,
) {}
constructor(private readonly databaseService: DatabaseService) {}
@Get()
async check() {
const health: any = {
status: "ok",
timestamp: new Date().toISOString(),
};
try {
// Check database connection
await this.databaseService.db.execute(sql`SELECT 1`);
health.database = "connected";
return {
status: "ok",
database: "connected",
timestamp: new Date().toISOString(),
};
} catch (error) {
health.status = "error";
health.database = "disconnected";
health.databaseError = error.message;
return {
status: "error",
database: "disconnected",
message: error.message,
timestamp: new Date().toISOString(),
};
}
try {
// Check Redis connection via cache-manager
// We try to set a temporary key to verify the connection
await this.cacheManager.set("health-check", "ok", 1000);
health.redis = "connected";
} catch (error) {
health.status = "error";
health.redis = "disconnected";
health.redisError = error.message;
}
return health;
}
}

View File

@@ -1,12 +1,12 @@
import { Readable } from "node:stream";
import { NotFoundException } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import type { Response } from "express";
import { S3Service } from "../s3/s3.service";
import { MediaController } from "./media.controller";
describe("MediaController", () => {
let controller: MediaController;
let s3Service: S3Service;
const mockS3Service = {
getFileInfo: jest.fn(),
@@ -20,6 +20,7 @@ describe("MediaController", () => {
}).compile();
controller = module.get<MediaController>(MediaController);
s3Service = module.get<S3Service>(S3Service);
});
it("should be defined", () => {
@@ -27,13 +28,12 @@ describe("MediaController", () => {
});
describe("getFile", () => {
it("should stream the file and set headers with path containing slashes", async () => {
it("should stream the file and set headers", async () => {
const res = {
setHeader: jest.fn(),
} as unknown as Response;
} as any;
const stream = new Readable();
stream.pipe = jest.fn();
const key = "contents/user-id/test.webp";
mockS3Service.getFileInfo.mockResolvedValue({
size: 100,
@@ -41,22 +41,16 @@ describe("MediaController", () => {
});
mockS3Service.getFile.mockResolvedValue(stream);
await controller.getFile(key, res);
await controller.getFile("test.webp", res);
expect(mockS3Service.getFileInfo).toHaveBeenCalledWith(key);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/webp");
expect(res.setHeader).toHaveBeenCalledWith("Content-Length", 100);
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 () => {
mockS3Service.getFileInfo.mockRejectedValue(new Error("Not found"));
const res = {} as unknown as Response;
const res = {} as any;
await expect(controller.getFile("invalid", res)).rejects.toThrow(
NotFoundException,

View File

@@ -1,47 +1,26 @@
import {
Controller,
Get,
Logger,
NotFoundException,
Query,
Res,
} from "@nestjs/common";
import { Controller, Get, NotFoundException, Param, Res } from "@nestjs/common";
import type { Response } from "express";
import type { BucketItemStat } from "minio";
import { S3Service } from "../s3/s3.service";
@Controller("media")
export class MediaController {
private readonly logger = new Logger(MediaController.name);
constructor(private readonly s3Service: S3Service) {}
@Get()
async getFile(@Query("path") path: string, @Res() res: Response) {
if (!path) {
this.logger.warn("Tentative d'accès à un média sans paramètre 'path'");
throw new NotFoundException("Paramètre 'path' manquant");
}
@Get(":key(*)")
async getFile(@Param("key") key: string, @Res() res: Response) {
try {
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 stats = await this.s3Service.getFileInfo(key);
const stream = await this.s3Service.getFile(key);
const contentType: string =
stats.metaData?.["content-type"] ||
stats.metaData?.["Content-Type"] ||
"application/octet-stream";
res.setHeader("Content-Type", contentType);
res.setHeader(
"Content-Type",
stats.metaData["content-type"] || "application/octet-stream",
);
res.setHeader("Content-Length", stats.size);
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
stream.pipe(res);
} catch (error) {
this.logger.error(
`Erreur lors de la récupération du fichier ${path} : ${error.message}`,
);
} catch (_error) {
throw new NotFoundException("Fichier non trouvé");
}
}

View File

@@ -75,7 +75,7 @@ describe("MediaService", () => {
toFormat: jest.fn().mockReturnThis(),
videoCodec: jest.fn().mockReturnThis(),
audioCodec: jest.fn().mockReturnThis(),
addOutputOptions: jest.fn().mockReturnThis(),
outputOptions: jest.fn().mockReturnThis(),
on: jest.fn().mockImplementation(function (event, cb) {
if (event === "end") setTimeout(cb, 0);
return this;
@@ -96,37 +96,4 @@ describe("MediaService", () => {
expect(result.buffer).toEqual(Buffer.from("processed-video"));
});
});
describe("scanFile", () => {
it("should return false if clamav not initialized", async () => {
const result = await service.scanFile(Buffer.from(""), "test.txt");
expect(result.isInfected).toBe(false);
});
it("should handle virus detection", async () => {
// Mock private property to simulate initialized clamscan
(service as any).isClamAvInitialized = true;
(service as any).clamscan = {
scanStream: jest.fn().mockResolvedValue({
isInfected: true,
viruses: ["Eicar-Test-Signature"],
}),
};
const result = await service.scanFile(Buffer.from(""), "test.txt");
expect(result.isInfected).toBe(true);
expect(result.virusName).toBe("Eicar-Test-Signature");
});
it("should handle scan error", async () => {
(service as any).isClamAvInitialized = true;
(service as any).clamscan = {
scanStream: jest.fn().mockRejectedValue(new Error("Scan failed")),
};
await expect(
service.scanFile(Buffer.from(""), "test.txt"),
).rejects.toThrow();
});
});
});

View File

@@ -12,7 +12,7 @@ export class VideoProcessorStrategy implements IMediaProcessorStrategy {
private readonly logger = new Logger(VideoProcessorStrategy.name);
canHandle(mimeType: string): boolean {
return mimeType.startsWith("video/") || mimeType === "image/gif";
return mimeType.startsWith("video/");
}
async process(
@@ -37,13 +37,13 @@ export class VideoProcessorStrategy implements IMediaProcessorStrategy {
.toFormat("webm")
.videoCodec("libvpx-vp9")
.audioCodec("libopus")
.addOutputOptions("-crf", "30", "-b:v", "0");
.outputOptions("-crf 30", "-b:v 0");
} else {
command = command
.toFormat("mp4")
.videoCodec("libaom-av1")
.audioCodec("libopus")
.addOutputOptions("-crf", "34", "-b:v", "0", "-strict", "experimental");
.outputOptions("-crf 34", "-b:v 0", "-strict experimental");
}
command

View File

@@ -1,11 +0,0 @@
import { IsNotEmpty, IsString, IsUUID, MaxLength } from "class-validator";
export class CreateMessageDto {
@IsUUID()
recipientId!: string;
@IsString()
@IsNotEmpty()
@MaxLength(2000)
text!: string;
}

View File

@@ -1,53 +0,0 @@
import {
Body,
Controller,
Get,
Param,
Post,
Req,
UseGuards,
} from "@nestjs/common";
import { AuthGuard } from "../auth/guards/auth.guard";
import type { AuthenticatedRequest } from "../common/interfaces/request.interface";
import { CreateMessageDto } from "./dto/create-message.dto";
import { MessagesService } from "./messages.service";
@Controller("messages")
@UseGuards(AuthGuard)
export class MessagesController {
constructor(private readonly messagesService: MessagesService) {}
@Get("conversations")
getConversations(@Req() req: AuthenticatedRequest) {
return this.messagesService.getConversations(req.user.sub);
}
@Get("unread-count")
getUnreadCount(@Req() req: AuthenticatedRequest) {
return this.messagesService.getUnreadCount(req.user.sub);
}
@Get("conversations/with/:userId")
getConversationWithUser(
@Req() req: AuthenticatedRequest,
@Param("userId") targetUserId: string,
) {
return this.messagesService.getConversationWithUser(
req.user.sub,
targetUserId,
);
}
@Get("conversations/:id")
getMessages(
@Req() req: AuthenticatedRequest,
@Param("id") conversationId: string,
) {
return this.messagesService.getMessages(req.user.sub, conversationId);
}
@Post()
sendMessage(@Req() req: AuthenticatedRequest, @Body() dto: CreateMessageDto) {
return this.messagesService.sendMessage(req.user.sub, dto);
}
}

View File

@@ -1,14 +0,0 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { RealtimeModule } from "../realtime/realtime.module";
import { MessagesController } from "./messages.controller";
import { MessagesService } from "./messages.service";
import { MessagesRepository } from "./repositories/messages.repository";
@Module({
imports: [AuthModule, RealtimeModule],
controllers: [MessagesController],
providers: [MessagesService, MessagesRepository],
exports: [MessagesService],
})
export class MessagesModule {}

View File

@@ -1,105 +0,0 @@
import { ForbiddenException } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import { EventsGateway } from "../realtime/events.gateway";
import { UsersService } from "../users/users.service";
import { MessagesService } from "./messages.service";
import { MessagesRepository } from "./repositories/messages.repository";
describe("MessagesService", () => {
let service: MessagesService;
let _repository: MessagesRepository;
let _eventsGateway: EventsGateway;
const mockMessagesRepository = {
findConversationBetweenUsers: jest.fn(),
createConversation: jest.fn(),
addParticipant: jest.fn(),
createMessage: jest.fn(),
findAllConversations: jest.fn(),
isParticipant: jest.fn(),
getParticipants: jest.fn(),
findMessagesByConversationId: jest.fn(),
markAsRead: jest.fn(),
countUnreadMessages: jest.fn(),
};
const mockEventsGateway = {
sendToUser: jest.fn(),
};
const mockUsersService = {
findOne: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
MessagesService,
{ provide: MessagesRepository, useValue: mockMessagesRepository },
{ provide: EventsGateway, useValue: mockEventsGateway },
{ provide: UsersService, useValue: mockUsersService },
],
}).compile();
service = module.get<MessagesService>(MessagesService);
_repository = module.get<MessagesRepository>(MessagesRepository);
_eventsGateway = module.get<EventsGateway>(EventsGateway);
});
describe("sendMessage", () => {
it("should send message to existing conversation", async () => {
const senderId = "s1";
const dto = { recipientId: "r1", text: "hello" };
mockMessagesRepository.findConversationBetweenUsers.mockResolvedValue({
id: "conv1",
});
mockMessagesRepository.createMessage.mockResolvedValue({
id: "m1",
text: "hello",
});
const result = await service.sendMessage(senderId, dto);
expect(result.id).toBe("m1");
expect(mockEventsGateway.sendToUser).toHaveBeenCalledWith(
"r1",
"new_message",
expect.anything(),
);
});
it("should create new conversation if not exists", async () => {
const senderId = "s1";
const dto = { recipientId: "r1", text: "hello" };
mockMessagesRepository.findConversationBetweenUsers.mockResolvedValue(null);
mockMessagesRepository.createConversation.mockResolvedValue({
id: "new_conv",
});
mockMessagesRepository.createMessage.mockResolvedValue({ id: "m1" });
await service.sendMessage(senderId, dto);
expect(mockMessagesRepository.createConversation).toHaveBeenCalled();
expect(mockMessagesRepository.addParticipant).toHaveBeenCalledTimes(2);
});
});
describe("getMessages", () => {
it("should return messages if user is participant", async () => {
mockMessagesRepository.isParticipant.mockResolvedValue(true);
mockMessagesRepository.findMessagesByConversationId.mockResolvedValue([
{ id: "m1" },
]);
const result = await service.getMessages("u1", "conv1");
expect(result).toHaveLength(1);
});
it("should throw ForbiddenException if user is not participant", async () => {
mockMessagesRepository.isParticipant.mockResolvedValue(false);
await expect(service.getMessages("u1", "conv1")).rejects.toThrow(
ForbiddenException,
);
});
});
});

View File

@@ -1,119 +0,0 @@
import { ForbiddenException, Injectable } from "@nestjs/common";
import { EventsGateway } from "../realtime/events.gateway";
import { UsersService } from "../users/users.service";
import type { CreateMessageDto } from "./dto/create-message.dto";
import { MessagesRepository } from "./repositories/messages.repository";
@Injectable()
export class MessagesService {
constructor(
private readonly messagesRepository: MessagesRepository,
private readonly eventsGateway: EventsGateway,
private readonly usersService: UsersService,
) {}
async sendMessage(senderId: string, dto: CreateMessageDto) {
let conversation = await this.messagesRepository.findConversationBetweenUsers(
senderId,
dto.recipientId,
);
if (!conversation) {
const newConv = await this.messagesRepository.createConversation();
await this.messagesRepository.addParticipant(newConv.id, senderId);
await this.messagesRepository.addParticipant(newConv.id, dto.recipientId);
conversation = newConv;
}
const message = await this.messagesRepository.createMessage({
conversationId: conversation.id,
senderId,
text: dto.text,
});
// Notify recipient via WebSocket
this.eventsGateway.sendToUser(dto.recipientId, "new_message", {
conversationId: conversation.id,
message,
});
return message;
}
async getConversations(userId: string) {
return this.messagesRepository.findAllConversations(userId);
}
async getUnreadCount(userId: string) {
return this.messagesRepository.countUnreadMessages(userId);
}
async getConversationWithUser(userId: string, targetUserId: string) {
return this.messagesRepository.findConversationBetweenUsers(
userId,
targetUserId,
);
}
async getMessages(userId: string, conversationId: string) {
const isParticipant = await this.messagesRepository.isParticipant(
conversationId,
userId,
);
if (!isParticipant) {
throw new ForbiddenException("You are not part of this conversation");
}
// Récupérer les préférences de l'utilisateur actuel
const user = await this.usersService.findOne(userId);
// Marquer comme lus seulement si l'utilisateur l'autorise
if (user?.showReadReceipts) {
await this.messagesRepository.markAsRead(conversationId, userId);
// Notifier l'expéditeur que les messages ont été lus
const participants =
await this.messagesRepository.getParticipants(conversationId);
const otherParticipant = participants.find((p) => p.userId !== userId);
if (otherParticipant) {
this.eventsGateway.sendToUser(otherParticipant.userId, "messages_read", {
conversationId,
readerId: userId,
});
}
}
return this.messagesRepository.findMessagesByConversationId(conversationId);
}
async markAsRead(userId: string, conversationId: string) {
const isParticipant = await this.messagesRepository.isParticipant(
conversationId,
userId,
);
if (!isParticipant) {
throw new ForbiddenException("You are not part of this conversation");
}
const user = await this.usersService.findOne(userId);
if (!user?.showReadReceipts) return;
const result = await this.messagesRepository.markAsRead(
conversationId,
userId,
);
// Notifier l'autre participant
const participants =
await this.messagesRepository.getParticipants(conversationId);
const otherParticipant = participants.find((p) => p.userId !== userId);
if (otherParticipant) {
this.eventsGateway.sendToUser(otherParticipant.userId, "messages_read", {
conversationId,
readerId: userId,
});
}
return result;
}
}

View File

@@ -1,167 +0,0 @@
import { Injectable } from "@nestjs/common";
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import { DatabaseService } from "../../database/database.service";
import {
conversationParticipants,
conversations,
messages,
users,
} from "../../database/schemas";
@Injectable()
export class MessagesRepository {
constructor(private readonly databaseService: DatabaseService) {}
async findConversationBetweenUsers(userId1: string, userId2: string) {
const results = await this.databaseService.db
.select({ id: conversations.id })
.from(conversations)
.innerJoin(
conversationParticipants,
eq(conversations.id, conversationParticipants.conversationId),
)
.where(inArray(conversationParticipants.userId, [userId1, userId2]))
.groupBy(conversations.id)
.having(sql`count(${conversations.id}) = 2`);
return results[0];
}
async createConversation() {
const [conv] = await this.databaseService.db
.insert(conversations)
.values({})
.returning();
return conv;
}
async addParticipant(conversationId: string, userId: string) {
await this.databaseService.db
.insert(conversationParticipants)
.values({ conversationId, userId });
}
async createMessage(data: {
conversationId: string;
senderId: string;
text: string;
}) {
const [msg] = await this.databaseService.db
.insert(messages)
.values(data)
.returning();
// Update conversation updatedAt
await this.databaseService.db
.update(conversations)
.set({ updatedAt: new Date() })
.where(eq(conversations.id, data.conversationId));
return msg;
}
async findAllConversations(userId: string) {
// Sous-requête pour trouver les IDs des conversations de l'utilisateur
const userConvs = this.databaseService.db
.select({ id: conversationParticipants.conversationId })
.from(conversationParticipants)
.where(eq(conversationParticipants.userId, userId));
return this.databaseService.db
.select({
id: conversations.id,
updatedAt: conversations.updatedAt,
lastMessage: {
text: messages.text,
createdAt: messages.createdAt,
},
recipient: {
uuid: users.uuid,
username: users.username,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
},
})
.from(conversations)
.innerJoin(
conversationParticipants,
eq(conversations.id, conversationParticipants.conversationId),
)
.innerJoin(users, eq(conversationParticipants.userId, users.uuid))
.leftJoin(messages, eq(conversations.id, messages.conversationId))
.where(
and(
inArray(conversations.id, userConvs),
eq(conversationParticipants.userId, users.uuid),
sql`${users.uuid} != ${userId}`,
),
)
.orderBy(desc(conversations.updatedAt));
}
async findMessagesByConversationId(conversationId: string, limit = 50) {
return this.databaseService.db
.select({
id: messages.id,
text: messages.text,
createdAt: messages.createdAt,
senderId: messages.senderId,
readAt: messages.readAt,
})
.from(messages)
.where(eq(messages.conversationId, conversationId))
.orderBy(desc(messages.createdAt))
.limit(limit);
}
async isParticipant(conversationId: string, userId: string) {
const [participant] = await this.databaseService.db
.select()
.from(conversationParticipants)
.where(
and(
eq(conversationParticipants.conversationId, conversationId),
eq(conversationParticipants.userId, userId),
),
);
return !!participant;
}
async getParticipants(conversationId: string) {
return this.databaseService.db
.select({ userId: conversationParticipants.userId })
.from(conversationParticipants)
.where(eq(conversationParticipants.conversationId, conversationId));
}
async markAsRead(conversationId: string, userId: string) {
await this.databaseService.db
.update(messages)
.set({ readAt: new Date() })
.where(
and(
eq(messages.conversationId, conversationId),
sql`${messages.senderId} != ${userId}`,
sql`${messages.readAt} IS NULL`,
),
);
}
async countUnreadMessages(userId: string) {
const result = await this.databaseService.db
.select({ count: sql<number>`count(*)` })
.from(messages)
.innerJoin(
conversationParticipants,
eq(messages.conversationId, conversationParticipants.conversationId),
)
.where(
and(
eq(conversationParticipants.userId, userId),
sql`${messages.senderId} != ${userId}`,
sql`${messages.readAt} IS NULL`,
),
);
return Number(result[0]?.count || 0);
}
}

View File

@@ -1,60 +0,0 @@
import { ConfigService } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
import { JwtService } from "../crypto/services/jwt.service";
import { UsersService } from "../users/users.service";
import { EventsGateway } from "./events.gateway";
describe("EventsGateway", () => {
let gateway: EventsGateway;
let _jwtService: JwtService;
const mockJwtService = {
verifyJwt: jest.fn(),
};
const mockConfigService = {
get: jest.fn().mockReturnValue("secret-password-32-chars-long-!!!"),
};
const mockUsersService = {
findOne: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
EventsGateway,
{ provide: JwtService, useValue: mockJwtService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: UsersService, useValue: mockUsersService },
],
}).compile();
gateway = module.get<EventsGateway>(EventsGateway);
_jwtService = module.get<JwtService>(JwtService);
gateway.server = {
to: jest.fn().mockReturnThis(),
emit: jest.fn(),
} as any;
});
it("should be defined", () => {
expect(gateway).toBeDefined();
});
describe("sendToUser", () => {
it("should emit event to user room", () => {
const userId = "user123";
const event = "test_event";
const data = { foo: "bar" };
gateway.sendToUser(userId, event, data);
expect(gateway.server.to).toHaveBeenCalledWith(`user:${userId}`);
expect(gateway.server.to(`user:${userId}`).emit).toHaveBeenCalledWith(
event,
data,
);
});
});
});

View File

@@ -1,220 +0,0 @@
import { forwardRef, Inject, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import {
ConnectedSocket,
MessageBody,
OnGatewayConnection,
OnGatewayDisconnect,
OnGatewayInit,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from "@nestjs/websockets";
import { getIronSession } from "iron-session";
import { Server, Socket } from "socket.io";
import { getSessionOptions, SessionData } from "../auth/session.config";
import { JwtService } from "../crypto/services/jwt.service";
import { UsersService } from "../users/users.service";
@WebSocketGateway({
transports: ["websocket"],
cors: {
origin: (
origin: string,
callback: (err: Error | null, allow?: boolean) => void,
) => {
// Autoriser si pas d'origine (ex: app mobile ou serveur à serveur)
// ou si on est en développement local
if (
!origin ||
origin.includes("localhost") ||
origin.includes("127.0.0.1")
) {
callback(null, true);
return;
}
// En production, on peut restreindre via une variable d'environnement
const domainName = process.env.CORS_DOMAIN_NAME;
if (!domainName || domainName === "*") {
callback(null, true);
return;
}
const allowedOrigins = domainName.split(",").map((o) => o.trim());
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error("Not allowed by CORS"));
}
},
credentials: true,
methods: ["GET", "POST"],
},
})
export class EventsGateway
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
@WebSocketServer()
server!: Server;
private readonly logger = new Logger(EventsGateway.name);
private readonly onlineUsers = new Map<string, Set<string>>(); // userId -> Set of socketIds
constructor(
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
@Inject(forwardRef(() => UsersService))
private readonly usersService: UsersService,
) {}
afterInit(_server: Server) {
this.logger.log("WebSocket Gateway initialized");
}
async handleConnection(client: Socket) {
try {
// Simuler un objet Request/Response pour iron-session
const req: any = {
headers: client.handshake.headers,
};
const res: any = {
setHeader: () => {},
getHeader: () => {},
};
const session = await getIronSession<SessionData>(
req,
res,
getSessionOptions(this.configService.get("SESSION_PASSWORD") as string),
);
if (!session.accessToken) {
this.logger.warn(`Client ${client.id} unauthorized connection`);
// Permettre les connexions anonymes pour voir les commentaires en temps réel ?
// Pour l'instant on déconnecte car le système actuel semble exiger l'auth
client.disconnect();
return;
}
const payload = await this.jwtService.verifyJwt(session.accessToken);
if (!payload.sub) {
throw new Error("Invalid token payload: missing sub");
}
client.data.user = payload;
// Rejoindre une room personnelle pour les notifications
client.join(`user:${payload.sub}`);
// Gérer le statut en ligne
const userId = payload.sub as string;
if (!this.onlineUsers.has(userId)) {
this.onlineUsers.set(userId, new Set());
// Vérifier les préférences de l'utilisateur
const user = await this.usersService.findOne(userId);
if (user?.showOnlineStatus) {
this.broadcastStatus(userId, "online");
}
}
this.onlineUsers.get(userId)?.add(client.id);
this.logger.log(`Client connected: ${client.id} (User: ${payload.sub})`);
} catch (error) {
this.logger.error(`Connection error for client ${client.id}: ${error}`);
client.disconnect();
}
}
async handleDisconnect(client: Socket) {
const userId = client.data.user?.sub;
if (userId && this.onlineUsers.has(userId)) {
const sockets = this.onlineUsers.get(userId);
sockets?.delete(client.id);
if (sockets?.size === 0) {
this.onlineUsers.delete(userId);
const user = await this.usersService.findOne(userId);
if (user?.showOnlineStatus) {
this.broadcastStatus(userId, "offline");
}
}
}
this.logger.log(`Client disconnected: ${client.id}`);
}
broadcastStatus(userId: string, status: "online" | "offline") {
this.server.emit("user_status", { userId, status });
}
isUserOnline(userId: string): boolean {
return this.onlineUsers.has(userId);
}
@SubscribeMessage("join_content")
handleJoinContent(
@ConnectedSocket() client: Socket,
@MessageBody() contentId: string,
) {
client.join(`content:${contentId}`);
this.logger.log(`Client ${client.id} joined content room: ${contentId}`);
}
@SubscribeMessage("leave_content")
handleLeaveContent(
@ConnectedSocket() client: Socket,
@MessageBody() contentId: string,
) {
client.leave(`content:${contentId}`);
this.logger.log(`Client ${client.id} left content room: ${contentId}`);
}
@SubscribeMessage("typing")
async handleTyping(
@ConnectedSocket() client: Socket,
@MessageBody() data: { recipientId: string; isTyping: boolean },
) {
const userId = client.data.user?.sub;
if (!userId) return;
// Optionnel: vérifier si l'utilisateur autorise le statut en ligne avant d'émettre "typing"
// ou si on considère que typing est une interaction directe qui outrepasse le statut.
// Instagram affiche "Typing..." même si le statut en ligne est désactivé si on est dans le chat.
// Mais par souci de cohérence avec "showOnlineStatus", on peut le vérifier.
const user = await this.usersService.findOne(userId);
if (!user?.showOnlineStatus) return;
this.server.to(`user:${data.recipientId}`).emit("user_typing", {
userId,
isTyping: data.isTyping,
});
}
@SubscribeMessage("check_status")
async handleCheckStatus(
@ConnectedSocket() _client: Socket,
@MessageBody() userId: string,
) {
const isOnline = this.onlineUsers.has(userId);
if (!isOnline) return { userId, status: "offline" };
const user = await this.usersService.findOne(userId);
if (!user?.showOnlineStatus) return { userId, status: "offline" };
return {
userId,
status: "online",
};
}
// Méthode utilitaire pour envoyer des messages à un utilisateur spécifique
sendToUser(userId: string, event: string, data: any) {
this.server.to(`user:${userId}`).emit(event, data);
}
sendToContent(contentId: string, event: string, data: any) {
this.server.to(`content:${contentId}`).emit(event, data);
}
}

View File

@@ -1,10 +0,0 @@
import { Module } from "@nestjs/common";
import { CryptoModule } from "../crypto/crypto.module";
import { EventsGateway } from "./events.gateway";
@Module({
imports: [CryptoModule],
providers: [EventsGateway],
exports: [EventsGateway],
})
export class RealtimeModule {}

View File

@@ -1,82 +0,0 @@
jest.mock("uuid", () => ({
v4: jest.fn(() => "mocked-uuid"),
}));
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
ml_kem768: {
keygen: jest.fn(),
encapsulate: jest.fn(),
decapsulate: jest.fn(),
},
}));
jest.mock("jose", () => ({
SignJWT: jest.fn().mockReturnValue({
setProtectedHeader: jest.fn().mockReturnThis(),
setIssuedAt: jest.fn().mockReturnThis(),
setExpirationTime: jest.fn().mockReturnThis(),
sign: jest.fn().mockResolvedValue("mocked-jwt"),
}),
jwtVerify: jest.fn(),
}));
import { Test, TestingModule } from "@nestjs/testing";
import { AuthGuard } from "../auth/guards/auth.guard";
import { RolesGuard } from "../auth/guards/roles.guard";
import { AuthenticatedRequest } from "../common/interfaces/request.interface";
import { ReportsController } from "./reports.controller";
import { ReportsService } from "./reports.service";
describe("ReportsController", () => {
let controller: ReportsController;
let service: ReportsService;
const mockReportsService = {
create: jest.fn(),
findAll: jest.fn(),
updateStatus: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ReportsController],
providers: [{ provide: ReportsService, useValue: mockReportsService }],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<ReportsController>(ReportsController);
service = module.get<ReportsService>(ReportsService);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("create", () => {
it("should call service.create", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
const dto = { contentId: "1", reason: "spam" };
await controller.create(req, dto as any);
expect(service.create).toHaveBeenCalledWith("user-uuid", dto);
});
});
describe("findAll", () => {
it("should call service.findAll", async () => {
await controller.findAll(10, 0);
expect(service.findAll).toHaveBeenCalledWith(10, 0);
});
});
describe("updateStatus", () => {
it("should call service.updateStatus", async () => {
const dto = { status: "resolved" as any };
await controller.updateStatus("1", dto);
expect(service.updateStatus).toHaveBeenCalledWith("1", "resolved");
});
});
});

View File

@@ -1,73 +0,0 @@
import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "../../database/database.service";
import { ReportsRepository } from "./reports.repository";
describe("ReportsRepository", () => {
let repository: ReportsRepository;
const mockDb = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
offset: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
returning: jest.fn().mockReturnThis(),
execute: jest.fn(),
};
const wrapWithThen = (obj: unknown) => {
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
Object.defineProperty(obj, "then", {
value: function (onFulfilled: (arg0: unknown) => void) {
const result = (this as Record<string, unknown>).execute();
return Promise.resolve(result).then(onFulfilled);
},
configurable: true,
});
return obj;
};
wrapWithThen(mockDb);
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ReportsRepository,
{ provide: DatabaseService, useValue: { db: mockDb } },
],
}).compile();
repository = module.get<ReportsRepository>(ReportsRepository);
});
it("should create report", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
const result = await repository.create({ reporterId: "u", reason: "spam" });
expect(result.id).toBe("1");
});
it("should find all", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
const result = await repository.findAll(10, 0);
expect(result).toHaveLength(1);
});
it("should update status", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([
{ id: "1", status: "resolved" },
]);
const result = await repository.updateStatus("1", "resolved");
expect(result[0].status).toBe("resolved");
});
it("should purge obsolete", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
await repository.purgeObsolete(new Date());
expect(mockDb.delete).toHaveBeenCalled();
});
});

View File

@@ -7,7 +7,8 @@ jest.mock("minio");
describe("S3Service", () => {
let service: S3Service;
let configService: ConfigService;
let _configService: ConfigService;
// biome-ignore lint/suspicious/noExplicitAny: Fine for testing purposes
let minioClient: any;
beforeEach(async () => {
@@ -41,7 +42,7 @@ describe("S3Service", () => {
}).compile();
service = module.get<S3Service>(S3Service);
configService = module.get<ConfigService>(ConfigService);
_configService = module.get<ConfigService>(ConfigService);
});
it("should be defined", () => {
@@ -184,39 +185,35 @@ describe("S3Service", () => {
});
});
describe("getPublicUrl", () => {
it("should use API_URL if provided", () => {
(configService.get as jest.Mock).mockImplementation((key: string) => {
if (key === "API_URL") return "https://api.test.com";
return null;
});
const url = service.getPublicUrl("test.webp");
expect(url).toBe("https://api.test.com/media?path=test.webp");
describe("moveFile", () => {
it("should move file within default bucket", async () => {
const source = "source.txt";
const dest = "dest.txt";
await service.moveFile(source, dest);
expect(minioClient.copyObject).toHaveBeenCalledWith(
"memegoat",
dest,
"/memegoat/source.txt",
expect.any(Minio.CopyConditions),
);
expect(minioClient.removeObject).toHaveBeenCalledWith("memegoat", source);
});
it("should use DOMAIN_NAME and PORT for localhost", () => {
(configService.get as jest.Mock).mockImplementation(
(key: string, def: unknown) => {
if (key === "API_URL") return null;
if (key === "DOMAIN_NAME") return "localhost";
if (key === "PORT") return 3000;
return def;
},
);
const url = service.getPublicUrl("test.webp");
expect(url).toBe("http://localhost:3000/media?path=test.webp");
});
it("should move file between different buckets", async () => {
const source = "source.txt";
const dest = "dest.txt";
const sBucket = "source-bucket";
const dBucket = "dest-bucket";
await service.moveFile(source, dest, sBucket, dBucket);
it("should use api.DOMAIN_NAME for production", () => {
(configService.get as jest.Mock).mockImplementation(
(key: string, def: unknown) => {
if (key === "API_URL") return null;
if (key === "DOMAIN_NAME") return "memegoat.fr";
return def;
},
expect(minioClient.copyObject).toHaveBeenCalledWith(
dBucket,
dest,
`/${sBucket}/${source}`,
expect.any(Minio.CopyConditions),
);
const url = service.getPublicUrl("test.webp");
expect(url).toBe("https://api.memegoat.fr/media?path=test.webp");
expect(minioClient.removeObject).toHaveBeenCalledWith(sBucket, source);
});
});
});

View File

@@ -54,7 +54,6 @@ export class S3Service implements OnModuleInit, IStorageService {
...metaData,
"Content-Type": mimeType,
});
this.logger.log(`File uploaded successfully: ${fileName} to ${bucketName}`);
return fileName;
} catch (error) {
this.logger.error(`Error uploading file to ${bucketName}: ${error.message}`);
@@ -114,7 +113,6 @@ export class S3Service implements OnModuleInit, IStorageService {
async deleteFile(fileName: string, bucketName: string = this.bucketName) {
try {
await this.minioClient.removeObject(bucketName, fileName);
this.logger.log(`File deleted successfully: ${fileName} from ${bucketName}`);
} catch (error) {
this.logger.error(
`Error deleting file from ${bucketName}: ${error.message}`,
@@ -160,19 +158,17 @@ export class S3Service implements OnModuleInit, IStorageService {
getPublicUrl(storageKey: string): string {
const apiUrl = this.configService.get<string>("API_URL");
if (apiUrl) {
return `${apiUrl.replace(/\/$/, "")}/media/${storageKey}`;
}
const domain = this.configService.get<string>("DOMAIN_NAME", "localhost");
const port = this.configService.get<number>("PORT", 3000);
let baseUrl: string;
if (apiUrl) {
baseUrl = apiUrl.replace(/\/$/, "");
} else if (domain === "localhost" || domain === "127.0.0.1") {
baseUrl = `http://${domain}:${port}`;
} else {
baseUrl = `https://api.${domain}`;
if (domain === "localhost" || domain === "127.0.0.1") {
return `http://${domain}:${port}/media/${storageKey}`;
}
return `${baseUrl}/media?path=${storageKey}`;
return `https://api.${domain}/media/${storageKey}`;
}
}

View File

@@ -1,69 +0,0 @@
jest.mock("uuid", () => ({
v4: jest.fn(() => "mocked-uuid"),
}));
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
ml_kem768: {
keygen: jest.fn(),
encapsulate: jest.fn(),
decapsulate: jest.fn(),
},
}));
jest.mock("jose", () => ({
SignJWT: jest.fn().mockReturnValue({
setProtectedHeader: jest.fn().mockReturnThis(),
setIssuedAt: jest.fn().mockReturnThis(),
setExpirationTime: jest.fn().mockReturnThis(),
sign: jest.fn().mockResolvedValue("mocked-jwt"),
}),
jwtVerify: jest.fn(),
}));
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Test, TestingModule } from "@nestjs/testing";
import { TagsController } from "./tags.controller";
import { TagsService } from "./tags.service";
describe("TagsController", () => {
let controller: TagsController;
let service: TagsService;
const mockTagsService = {
findAll: jest.fn(),
};
const mockCacheManager = {
get: jest.fn(),
set: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [TagsController],
providers: [
{ provide: TagsService, useValue: mockTagsService },
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
],
}).compile();
controller = module.get<TagsController>(TagsController);
service = module.get<TagsService>(TagsService);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("findAll", () => {
it("should call service.findAll", async () => {
await controller.findAll(10, 0, "test", "popular");
expect(service.findAll).toHaveBeenCalledWith({
limit: 10,
offset: 0,
query: "test",
sortBy: "popular",
});
});
});
});

View File

@@ -1,4 +1,4 @@
import { IsBoolean, IsOptional, IsString, MaxLength } from "class-validator";
import { IsOptional, IsString, MaxLength } from "class-validator";
export class UpdateUserDto {
@IsOptional()
@@ -14,20 +14,4 @@ export class UpdateUserDto {
@IsOptional()
@IsString()
avatarUrl?: string;
@IsOptional()
@IsString()
status?: "active" | "verification" | "suspended" | "pending" | "deleted";
@IsOptional()
@IsString()
role?: string;
@IsOptional()
@IsBoolean()
showOnlineStatus?: boolean;
@IsOptional()
@IsBoolean()
showReadReceipts?: boolean;
}

View File

@@ -1,150 +0,0 @@
import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "../../database/database.service";
import { UsersRepository } from "./users.repository";
describe("UsersRepository", () => {
let repository: UsersRepository;
let _databaseService: DatabaseService;
const mockDb = {
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
returning: jest.fn().mockResolvedValue([{ uuid: "u1" }]),
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
offset: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
transaction: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersRepository,
{
provide: DatabaseService,
useValue: { db: mockDb },
},
],
}).compile();
repository = module.get<UsersRepository>(UsersRepository);
_databaseService = module.get<DatabaseService>(DatabaseService);
jest.clearAllMocks();
});
it("should be defined", () => {
expect(repository).toBeDefined();
});
describe("create", () => {
it("should insert a user", async () => {
const data = {
username: "u",
email: "e",
passwordHash: "p",
emailHash: "eh",
};
await repository.create(data);
expect(mockDb.insert).toHaveBeenCalled();
expect(mockDb.values).toHaveBeenCalledWith(data);
});
});
describe("findByEmailHash", () => {
it("should select user by email hash", async () => {
mockDb.limit.mockResolvedValueOnce([{ uuid: "u1" }]);
const result = await repository.findByEmailHash("hash");
expect(result.uuid).toBe("u1");
expect(mockDb.select).toHaveBeenCalled();
expect(mockDb.where).toHaveBeenCalled();
});
});
describe("findOneWithPrivateData", () => {
it("should select user with private data", async () => {
mockDb.limit.mockResolvedValueOnce([{ uuid: "u1" }]);
const result = await repository.findOneWithPrivateData("u1");
expect(result.uuid).toBe("u1");
});
});
describe("countAll", () => {
it("should return count", async () => {
mockDb.from.mockResolvedValueOnce([{ count: 5 }]);
const result = await repository.countAll();
expect(result).toBe(5);
});
});
describe("findAll", () => {
it("should select users with limit and offset", async () => {
mockDb.offset.mockResolvedValueOnce([{ uuid: "u1" }]);
const result = await repository.findAll(10, 0);
expect(result[0].uuid).toBe("u1");
expect(mockDb.limit).toHaveBeenCalledWith(10);
expect(mockDb.offset).toHaveBeenCalledWith(0);
});
});
describe("findByUsername", () => {
it("should find by username", async () => {
mockDb.limit.mockResolvedValueOnce([{ uuid: "u1" }]);
const result = await repository.findByUsername("u");
expect(result.uuid).toBe("u1");
});
});
describe("update", () => {
it("should update user", async () => {
mockDb.returning.mockResolvedValueOnce([{ uuid: "u1" }]);
await repository.update("u1", { displayName: "New" });
expect(mockDb.update).toHaveBeenCalled();
expect(mockDb.set).toHaveBeenCalled();
});
});
describe("getTwoFactorSecret", () => {
it("should return secret", async () => {
mockDb.limit.mockResolvedValueOnce([{ secret: "s" }]);
const result = await repository.getTwoFactorSecret("u1");
expect(result).toBe("s");
});
});
describe("getUserContents", () => {
it("should return contents", async () => {
mockDb.where.mockResolvedValueOnce([{ id: "c1" }]);
const result = await repository.getUserContents("u1");
expect(result[0].id).toBe("c1");
});
});
describe("softDeleteUserAndContents", () => {
it("should run transaction", async () => {
const mockTx = {
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
returning: jest.fn().mockResolvedValue([{ uuid: "u1" }]),
};
mockDb.transaction.mockImplementation(async (cb) => cb(mockTx));
const result = await repository.softDeleteUserAndContents("u1");
expect(result[0].uuid).toBe("u1");
expect(mockTx.update).toHaveBeenCalledTimes(2);
});
});
describe("purgeDeleted", () => {
it("should delete old deleted users", async () => {
mockDb.returning.mockResolvedValueOnce([{ uuid: "u1" }]);
const _result = await repository.purgeDeleted(new Date());
expect(mockDb.delete).toHaveBeenCalled();
});
});
});

View File

@@ -1,5 +1,5 @@
import { Injectable } from "@nestjs/common";
import { and, eq, ilike, lte, or, sql } from "drizzle-orm";
import { and, eq, lte, sql } from "drizzle-orm";
import { DatabaseService } from "../../database/database.service";
import { contents, favorites, users } from "../../database/schemas";
@@ -47,8 +47,6 @@ export class UsersRepository {
bio: users.bio,
status: users.status,
isTwoFactorEnabled: users.isTwoFactorEnabled,
showOnlineStatus: users.showOnlineStatus,
showReadReceipts: users.showReadReceipts,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
})
@@ -66,7 +64,7 @@ export class UsersRepository {
}
async findAll(limit: number, offset: number) {
const result = await this.databaseService.db
return await this.databaseService.db
.select({
uuid: users.uuid,
username: users.username,
@@ -79,8 +77,6 @@ export class UsersRepository {
.from(users)
.limit(limit)
.offset(offset);
return result;
}
async findByUsername(username: string) {
@@ -99,24 +95,6 @@ export class UsersRepository {
return result[0] || null;
}
async search(query: string) {
return this.databaseService.db
.select({
uuid: users.uuid,
username: users.username,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
})
.from(users)
.where(
or(
ilike(users.username, `%${query}%`),
ilike(users.displayName, `%${query}%`),
),
)
.limit(10);
}
async findOne(uuid: string) {
const result = await this.databaseService.db
.select()

View File

@@ -1,192 +0,0 @@
jest.mock("uuid", () => ({
v4: jest.fn(() => "mocked-uuid"),
}));
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
ml_kem768: {
keygen: jest.fn(),
encapsulate: jest.fn(),
decapsulate: jest.fn(),
},
}));
jest.mock("jose", () => ({
SignJWT: jest.fn().mockReturnValue({
setProtectedHeader: jest.fn().mockReturnThis(),
setIssuedAt: jest.fn().mockReturnThis(),
setExpirationTime: jest.fn().mockReturnThis(),
sign: jest.fn().mockResolvedValue("mocked-jwt"),
}),
jwtVerify: jest.fn(),
}));
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Test, TestingModule } from "@nestjs/testing";
import { AuthService } from "../auth/auth.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { RolesGuard } from "../auth/guards/roles.guard";
import { AuthenticatedRequest } from "../common/interfaces/request.interface";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";
describe("UsersController", () => {
let controller: UsersController;
let usersService: UsersService;
let authService: AuthService;
const mockUsersService = {
findAll: jest.fn(),
findPublicProfile: jest.fn(),
findOneWithPrivateData: jest.fn(),
exportUserData: jest.fn(),
update: jest.fn(),
updateAvatar: jest.fn(),
updateConsent: jest.fn(),
remove: jest.fn(),
};
const mockAuthService = {
generateTwoFactorSecret: jest.fn(),
enableTwoFactor: jest.fn(),
disableTwoFactor: jest.fn(),
};
const mockCacheManager = {
get: jest.fn(),
set: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [UsersController],
providers: [
{ provide: UsersService, useValue: mockUsersService },
{ provide: AuthService, useValue: mockAuthService },
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<UsersController>(UsersController);
usersService = module.get<UsersService>(UsersService);
authService = module.get<AuthService>(AuthService);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("findAll", () => {
it("should call usersService.findAll", async () => {
await controller.findAll(10, 0);
expect(usersService.findAll).toHaveBeenCalledWith(10, 0);
});
});
describe("findPublicProfile", () => {
it("should call usersService.findPublicProfile", async () => {
await controller.findPublicProfile("testuser");
expect(usersService.findPublicProfile).toHaveBeenCalledWith("testuser");
});
});
describe("findMe", () => {
it("should call usersService.findOneWithPrivateData", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.findMe(req);
expect(usersService.findOneWithPrivateData).toHaveBeenCalledWith(
"user-uuid",
);
});
});
describe("exportMe", () => {
it("should call usersService.exportUserData", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.exportMe(req);
expect(usersService.exportUserData).toHaveBeenCalledWith("user-uuid");
});
});
describe("updateMe", () => {
it("should call usersService.update", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
const dto = { displayName: "New Name" };
await controller.updateMe(req, dto);
expect(usersService.update).toHaveBeenCalledWith("user-uuid", dto);
});
});
describe("updateAvatar", () => {
it("should call usersService.updateAvatar", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
const file = {} as Express.Multer.File;
await controller.updateAvatar(req, file);
expect(usersService.updateAvatar).toHaveBeenCalledWith("user-uuid", file);
});
});
describe("updateConsent", () => {
it("should call usersService.updateConsent", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
const dto = { termsVersion: "1.0", privacyVersion: "1.0" };
await controller.updateConsent(req, dto);
expect(usersService.updateConsent).toHaveBeenCalledWith(
"user-uuid",
"1.0",
"1.0",
);
});
});
describe("removeMe", () => {
it("should call usersService.remove", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.removeMe(req);
expect(usersService.remove).toHaveBeenCalledWith("user-uuid");
});
});
describe("removeAdmin", () => {
it("should call usersService.remove", async () => {
await controller.removeAdmin("target-uuid");
expect(usersService.remove).toHaveBeenCalledWith("target-uuid");
});
});
describe("setup2fa", () => {
it("should call authService.generateTwoFactorSecret", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.setup2fa(req);
expect(authService.generateTwoFactorSecret).toHaveBeenCalledWith(
"user-uuid",
);
});
});
describe("enable2fa", () => {
it("should call authService.enableTwoFactor", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.enable2fa(req, "token123");
expect(authService.enableTwoFactor).toHaveBeenCalledWith(
"user-uuid",
"token123",
);
});
});
describe("disable2fa", () => {
it("should call authService.disableTwoFactor", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.disable2fa(req, "token123");
expect(authService.disableTwoFactor).toHaveBeenCalledWith(
"user-uuid",
"token123",
);
});
});
});

View File

@@ -54,12 +54,6 @@ export class UsersController {
return this.usersService.findPublicProfile(username);
}
@Get("search")
@UseGuards(AuthGuard)
search(@Query("q") query: string) {
return this.usersService.search(query);
}
// Gestion de son propre compte
@Get("me")
@UseGuards(AuthGuard)
@@ -118,16 +112,6 @@ export class UsersController {
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)
@Post("me/2fa/setup")
@UseGuards(AuthGuard)

View File

@@ -1,19 +1,13 @@
import { forwardRef, Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { MediaModule } from "../media/media.module";
import { RealtimeModule } from "../realtime/realtime.module";
import { S3Module } from "../s3/s3.module";
import { UsersRepository } from "./repositories/users.repository";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";
@Module({
imports: [
forwardRef(() => AuthModule),
MediaModule,
S3Module,
forwardRef(() => RealtimeModule),
],
imports: [forwardRef(() => AuthModule), MediaModule, S3Module],
controllers: [UsersController],
providers: [UsersService, UsersRepository],
exports: [UsersService, UsersRepository],

View File

@@ -20,7 +20,6 @@ import { ConfigService } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
import { RbacService } from "../auth/rbac.service";
import { MediaService } from "../media/media.service";
import { EventsGateway } from "../realtime/events.gateway";
import { S3Service } from "../s3/s3.service";
import { UsersRepository } from "./repositories/users.repository";
import { UsersService } from "./users.service";
@@ -50,7 +49,6 @@ describe("UsersService", () => {
const mockRbacService = {
getUserRoles: jest.fn(),
assignRoleToUser: jest.fn(),
};
const mockMediaService = {
@@ -67,11 +65,6 @@ describe("UsersService", () => {
get: jest.fn(),
};
const mockEventsGateway = {
isUserOnline: jest.fn(),
broadcastStatus: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
@@ -84,7 +77,6 @@ describe("UsersService", () => {
{ provide: MediaService, useValue: mockMediaService },
{ provide: S3Service, useValue: mockS3Service },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: EventsGateway, useValue: mockEventsGateway },
],
}).compile();
@@ -116,7 +108,6 @@ describe("UsersService", () => {
describe("findOne", () => {
it("should find a user", async () => {
mockUsersRepository.findOne.mockResolvedValue({ uuid: "uuid1" });
mockRbacService.getUserRoles.mockResolvedValue([]);
const result = await service.findOne("uuid1");
expect(result.uuid).toBe("uuid1");
});
@@ -137,113 +128,4 @@ describe("UsersService", () => {
expect(result[0].displayName).toBe("New");
});
});
describe("clearUserCache", () => {
it("should delete cache", async () => {
await service.clearUserCache("u1");
expect(mockCacheManager.del).toHaveBeenCalledWith("users/profile/u1");
});
});
describe("findByEmailHash", () => {
it("should call repository.findByEmailHash", async () => {
mockUsersRepository.findByEmailHash.mockResolvedValue({ uuid: "u1" });
mockRbacService.getUserRoles.mockResolvedValue([]);
const result = await service.findByEmailHash("hash");
expect(result.uuid).toBe("u1");
expect(mockUsersRepository.findByEmailHash).toHaveBeenCalledWith("hash");
});
});
describe("findOneWithPrivateData", () => {
it("should return user with roles", async () => {
mockUsersRepository.findOneWithPrivateData.mockResolvedValue({ uuid: "u1" });
mockRbacService.getUserRoles.mockResolvedValue(["admin"]);
const result = await service.findOneWithPrivateData("u1");
expect(result.roles).toEqual(["admin"]);
});
});
describe("findAll", () => {
it("should return all users", async () => {
mockUsersRepository.findAll.mockResolvedValue([{ uuid: "u1" }]);
mockUsersRepository.countAll.mockResolvedValue(1);
const result = await service.findAll(10, 0);
expect(result.totalCount).toBe(1);
expect(result.data[0].uuid).toBe("u1");
});
});
describe("findPublicProfile", () => {
it("should return public profile", async () => {
mockUsersRepository.findByUsername.mockResolvedValue({
uuid: "u1",
username: "u1",
});
const result = await service.findPublicProfile("u1");
expect(result.username).toBe("u1");
});
});
describe("updateConsent", () => {
it("should update consent", async () => {
await service.updateConsent("u1", "v1", "v2");
expect(mockUsersRepository.update).toHaveBeenCalledWith("u1", {
termsVersion: "v1",
privacyVersion: "v2",
gdprAcceptedAt: expect.any(Date),
});
});
});
describe("setTwoFactorSecret", () => {
it("should set 2fa secret", async () => {
await service.setTwoFactorSecret("u1", "secret");
expect(mockUsersRepository.update).toHaveBeenCalledWith("u1", {
twoFactorSecret: "secret",
});
});
});
describe("toggleTwoFactor", () => {
it("should toggle 2fa", async () => {
await service.toggleTwoFactor("u1", true);
expect(mockUsersRepository.update).toHaveBeenCalledWith("u1", {
isTwoFactorEnabled: true,
});
});
});
describe("getTwoFactorSecret", () => {
it("should return 2fa secret", async () => {
mockUsersRepository.getTwoFactorSecret.mockResolvedValue("secret");
const result = await service.getTwoFactorSecret("u1");
expect(result).toBe("secret");
});
});
describe("exportUserData", () => {
it("should return all user data", async () => {
mockUsersRepository.findOneWithPrivateData.mockResolvedValue({ uuid: "u1" });
mockUsersRepository.getUserContents.mockResolvedValue([]);
mockUsersRepository.getUserFavorites.mockResolvedValue([]);
const result = await service.exportUserData("u1");
expect(result.profile).toBeDefined();
expect(result.contents).toBeDefined();
expect(result.favorites).toBeDefined();
});
});
describe("remove", () => {
it("should soft delete user", async () => {
await service.remove("u1");
expect(mockUsersRepository.softDeleteUserAndContents).toHaveBeenCalledWith(
"u1",
);
});
});
});

View File

@@ -12,7 +12,6 @@ import { RbacService } from "../auth/rbac.service";
import type { IMediaService } from "../common/interfaces/media.interface";
import type { IStorageService } from "../common/interfaces/storage.interface";
import { MediaService } from "../media/media.service";
import { EventsGateway } from "../realtime/events.gateway";
import { S3Service } from "../s3/s3.service";
import { UpdateUserDto } from "./dto/update-user.dto";
import { UsersRepository } from "./repositories/users.repository";
@@ -28,8 +27,6 @@ export class UsersService {
private readonly rbacService: RbacService,
@Inject(MediaService) private readonly mediaService: IMediaService,
@Inject(S3Service) private readonly s3Service: IStorageService,
@Inject(forwardRef(() => EventsGateway))
private readonly eventsGateway: EventsGateway,
) {}
private async clearUserCache(username?: string) {
@@ -48,19 +45,7 @@ export class UsersService {
}
async findByEmailHash(emailHash: string) {
const user = await this.usersRepository.findByEmailHash(emailHash);
if (!user) return null;
const roles = await this.rbacService.getUserRoles(user.uuid);
return {
...user,
role: roles.includes("admin")
? "admin"
: roles.includes("moderator")
? "moderator"
: "user",
roles,
};
return await this.usersRepository.findByEmailHash(emailHash);
}
async findOneWithPrivateData(uuid: string) {
@@ -109,63 +94,16 @@ export class UsersService {
};
}
async search(query: string) {
const users = await this.usersRepository.search(query);
return users.map((user) => ({
...user,
avatarUrl: user.avatarUrl
? this.s3Service.getPublicUrl(user.avatarUrl)
: null,
}));
}
async findOne(uuid: string) {
const user = await this.usersRepository.findOne(uuid);
if (!user) return null;
const roles = await this.rbacService.getUserRoles(user.uuid);
return {
...user,
role: roles.includes("admin")
? "admin"
: roles.includes("moderator")
? "moderator"
: "user",
roles,
};
return await this.usersRepository.findOne(uuid);
}
async update(uuid: string, data: UpdateUserDto) {
this.logger.log(`Updating user profile for ${uuid}`);
const { role, ...userData } = data;
// On récupère l'utilisateur actuel avant mise à jour pour comparer les préférences
const oldUser = await this.usersRepository.findOne(uuid);
const result = await this.usersRepository.update(uuid, userData);
if (role) {
await this.rbacService.assignRoleToUser(uuid, role);
}
const result = await this.usersRepository.update(uuid, data);
if (result[0]) {
await this.clearUserCache(result[0].username);
// Gérer le changement de préférence de statut en ligne
if (
data.showOnlineStatus !== undefined &&
data.showOnlineStatus !== oldUser?.showOnlineStatus
) {
const isOnline = this.eventsGateway.isUserOnline(uuid);
if (isOnline) {
if (data.showOnlineStatus) {
this.eventsGateway.broadcastStatus(uuid, "online");
} else {
this.eventsGateway.broadcastStatus(uuid, "offline");
}
}
}
}
return result;
}
@@ -205,7 +143,6 @@ export class UsersService {
// 3. Upload vers S3
const key = `avatars/${uuid}/${Date.now()}-${uuidv4()}.${processed.extension}`;
await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType);
this.logger.log(`Avatar uploaded successfully to S3: ${key}`);
// 4. Mise à jour de la base de données
const user = await this.update(uuid, { avatarUrl: key });

View File

@@ -9,8 +9,6 @@ services:
POSTGRES_DB: ${POSTGRES_DB:-app}
networks:
- nw_memegoat
ports:
- "127.0.0.1:5432:5432" # not exposed to WAN, LAN only for administration checkup
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
@@ -37,7 +35,6 @@ services:
restart: always
networks:
- nw_memegoat
- nw_caddy
#ports:
# - "9000:9000"
# - "9001:9001"
@@ -131,8 +128,6 @@ services:
environment:
NODE_ENV: production
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-https://api.memegoat.fr}
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-https://memegoat.fr}
NEXT_PUBLIC_CONTACT_EMAIL: ${MAIL_FROM:-noreply@memegoat.fr}
depends_on:
- backend

View File

@@ -1,4 +1,4 @@
# syntax=docker/dockerfile:1
# syntax=docker.io/docker/dockerfile:1
FROM node:22-alpine AS base
ENV PNPM_HOME="/pnpm"
@@ -11,20 +11,11 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY backend/package.json ./backend/
COPY frontend/package.json ./frontend/
COPY documentation/package.json ./documentation/
# Montage du cache pnpm
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile --force
RUN pnpm install --no-frozen-lockfile
COPY . .
# Deuxième passe avec cache pour les scripts/liens
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile --force
# Build avec cache Next.js
RUN --mount=type=cache,id=next-docs-cache,target=/usr/src/app/documentation/.next/cache \
pnpm run --filter @memegoat/documentation build
# On réinstalle après COPY pour s'assurer que tous les scripts de cycle de vie et les liens sont corrects
RUN pnpm install --no-frozen-lockfile
RUN pnpm run --filter @memegoat/documentation build
FROM node:22-alpine AS runner
WORKDIR /app

View File

@@ -18,21 +18,15 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
Inscrit un nouvel utilisateur.
**Corps de la requête (JSON) :**
- `username` (string, max: 32) : Nom d'utilisateur unique.
- `username` (string) : Nom d'utilisateur unique.
- `email` (string) : Adresse email valide.
- `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.
- `password` (string) : Mot de passe (min. 8 caractères).
```json
{
"username": "goat_user",
"email": "user@memegoat.fr",
"password": "strong-password",
"displayName": "Le Bouc"
"password": "strong-password"
}
```
</Accordion>
@@ -44,25 +38,23 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
- `email` (string)
- `password` (string)
**Réponses :**
- `200 OK` : Connexion réussie.
```json
{
"message": "User logged in successfully",
"userId": "uuid-v4"
}
```
- `200 OK` (2FA requise) :
```json
{
"message": "2FA required",
"requires2FA": true,
"userId": "uuid-v4"
}
```
- `401 Unauthorized` : Identifiants invalides.
**Réponse (Succès) :**
```json
{
"message": "User logged in successfully",
"userId": "uuid-v4"
}
```
*Note: L'access_token et le refresh_token sont stockés dans un cookie HttpOnly chiffré.*
**Réponse (2FA requise) :**
```json
{
"message": "2FA required",
"requires2FA": true,
"userId": "uuid-v4"
}
```
</Accordion>
<Accordion title="POST /auth/verify-2fa">
@@ -71,41 +63,15 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
**Corps de la requête :**
- `userId` (uuid) : ID de l'utilisateur.
- `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 title="POST /auth/refresh">
Obtient un nouvel `access_token` à partir du `refresh_token` stocké dans la session.
Met à jour automatiquement le cookie de session.
**Réponses :**
- `200 OK` : Token rafraîchi.
- `401 Unauthorized` : Refresh token absent ou invalide.
</Accordion>
<Accordion title="POST /auth/logout">
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é.
Invalide la session actuelle.
</Accordion>
</Accordions>
@@ -114,62 +80,16 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
<Accordions>
<Accordion title="GET /users/me">
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 title="GET /users/public/:username">
Récupère le profil public d'un utilisateur par son nom d'utilisateur. Mise en cache pendant 1 minute.
**Réponses :**
- `200 OK` : Profil public (id, username, displayName, bio, avatarUrl, createdAt).
- `404 Not Found` : Utilisateur non trouvé.
</Accordion>
<Accordion title="GET /users/me/export">
Extrait l'intégralité des données de l'utilisateur au format JSON (Conformité RGPD).
Contient le profil, les contenus créés et les favoris.
**Réponses :**
- `200 OK` : Archive JSON des données.
- `401 Unauthorized` : Non authentifié.
Contient le profil, les contenus et les favoris.
</Accordion>
<Accordion title="PATCH /users/me">
Met à jour les informations du profil.
**Corps de la requête :**
- `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 title="POST /users/me/avatar">
Met à jour l'avatar de l'utilisateur via upload de fichier.
**Type :** `multipart/form-data`
**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 title="PATCH /users/me/consent">
Met à jour les consentements légaux de l'utilisateur (CGU/RGPD).
**Corps de la requête :**
- `termsVersion` (string, max: 16)
- `privacyVersion` (string, max: 16)
**Réponses :**
- `200 OK` : Consentements enregistrés.
- `displayName` (string)
</Accordion>
<Accordion title="DELETE /users/me">
@@ -177,388 +97,95 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
<Callout type="warn">
Les données sont définitivement purgées après un délai légal de 30 jours.
</Callout>
**Réponses :**
- `200 OK` : Suppression planifiée.
</Accordion>
<Accordion title="POST /users/me/2fa/setup">
Génère un secret et un QR Code pour la configuration de la 2FA.
**Réponses :**
- `201 Created` :
```json
{
"secret": "JBSWY3DPEHPK3PXP",
"qrCodeDataUrl": "data:image/png;base64,..."
}
```
<Accordion title="Gestion 2FA">
- `POST /users/me/2fa/setup` : Génère un secret et QR Code.
- `POST /users/me/2fa/enable` : Active après vérification du jeton.
- `POST /users/me/2fa/disable` : Désactive avec jeton.
</Accordion>
<Accordion title="POST /users/me/2fa/enable">
Active la 2FA après vérification du jeton TOTP.
**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 title="Administration (GET /users/admin)">
Liste tous les utilisateurs. Réservé aux administrateurs.
**Params :** `limit`, `offset`.
</Accordion>
</Accordions>
### 🖼️ Contenus (`/contents`)
<Accordions>
<Accordion title="GET /contents/explore">
Recherche et filtre les contenus. Cet endpoint est mis en cache pendant 1 minute.
<Accordion title="GET /contents/explore | /trends | /recent">
Recherche et filtre les contenus. Ces endpoints sont mis en cache (Redis + Navigateur).
**Query Params :**
- `limit` (number) : Défaut 10.
- `offset` (number) : Défaut 0.
- `sort` : `trend` | `recent`
- `tag` (string) : Filtrer par tag (nom).
- `category` (slug ou uuid) : Filtrer par catégorie.
- `author` (username) : Filtrer par auteur.
- `query` (string) : Recherche textuelle dans le titre.
- `favoritesOnly` (boolean) : Ne montrer que les favoris de l'utilisateur (nécessite auth).
- `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.
- `sort` : `trend` | `recent` (uniquement sur `/explore`)
- `tag` (string)
- `category` (slug ou id)
- `author` (username)
- `query` (titre)
- `favoritesOnly` (bool)
</Accordion>
<Accordion title="GET /contents/:idOrSlug">
Récupère un contenu par son ID ou son Slug. Cache de 1 heure.
Récupère un contenu par son ID ou son Slug.
**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**.
**Réponses :**
- `200 OK` : Objet Contenu ou Rendu HTML (Bots).
- `404 Not Found` : Contenu inexistant.
</Accordion>
<Accordion title="POST /contents">
Crée une entrée de contenu à partir d'une ressource déjà uploadée ou externe.
**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é.
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.
</Accordion>
<Accordion title="POST /contents/upload">
Upload un fichier et crée le contenu associé en une seule étape.
Upload un fichier avec traitement automatique.
**Type :** `multipart/form-data`
**Champs :**
- `file` (binary) : png, jpeg, webp, webm, gif.
- `type` : `meme` | `gif`
- `title` (string)
- `categoryId` (uuid, optional)
- `tags` (string[], optional)
**Réponses :**
- `201 Created` : Upload réussi et contenu créé.
- `400 Bad Request` : Fichier non supporté ou données invalides.
- `title` : string
- `categoryId`? : uuid
- `tags`? : string[]
</Accordion>
<Accordion title="POST /contents/upload-url">
Génère une URL présignée pour un upload direct vers S3.
**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 title="POST /contents/:id/view">
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 title="POST /contents/:id/view | /use">
Incrémente les statistiques de vue ou d'utilisation.
</Accordion>
<Accordion title="DELETE /contents/:id">
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 title="DELETE /contents/:id/admin">
Supprime définitivement un contenu. **Réservé aux administrateurs.**
**Réponses :**
- `200 OK` : Contenu supprimé définitivement.
</Accordion>
</Accordions>
### 📂 Catégories (`/categories`)
### 📂 Catégories, ⭐ Favoris, 🚩 Signalements
<Accordions>
<Accordion title="GET /categories">
Liste toutes les catégories de mèmes disponibles. Cache de 1 heure.
**Réponses :**
- `200 OK` : Liste d'objets catégorie.
<Accordion title="Catégories (/categories)">
- `GET /categories` : Liste toutes les catégories.
- `POST /categories` : Création (Admin uniquement).
</Accordion>
<Accordion title="GET /categories/:id">
Récupère les détails d'une catégorie spécifique.
**Réponses :**
- `200 OK` : Objet catégorie.
- `404 Not Found` : Catégorie non trouvée.
<Accordion title="Favoris (/favorites)">
- `GET /favorites` : Liste les favoris de l'utilisateur.
- `POST /favorites/:contentId` : Ajoute un favori.
- `DELETE /favorites/:contentId` : Retire un favori.
</Accordion>
<Accordion title="POST /categories">
Crée une nouvelle catégorie. **Admin uniquement.**
**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 title="Signalements (/reports)">
- `POST /reports` : Signale un contenu ou un tag.
- `GET /reports` : Liste (Modérateurs).
- `PATCH /reports/:id/status` : Gère le workflow.
</Accordion>
</Accordions>
### ⭐ Favoris (`/favorites`)
### 🔑 Clés API & 🏷️ Tags
<Accordions>
<Accordion title="GET /favorites">
Liste les favoris de l'utilisateur connecté.
**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 title="Clés API (/api-keys)">
- `POST /api-keys` : Génère une clé `{ name, expiresAt? }`.
- `GET /api-keys` : Liste les clés actives.
- `DELETE /api-keys/:id` : Révoque une clé.
</Accordion>
<Accordion title="POST /favorites/:contentId">
Ajoute un contenu aux favoris de l'utilisateur.
**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>
</Accordions>
### 🚩 Signalements (`/reports`)
<Accordions>
<Accordion title="POST /reports">
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 title="GET /reports">
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 title="PATCH /reports/:id/status">
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 title="Tags (/tags)">
- `GET /tags` : Recherche de tags populaires ou récents.
**Params :** `query`, `sort`, `limit`.
</Accordion>
</Accordions>

View File

@@ -20,13 +20,6 @@ Le système utilise plusieurs méthodes d'authentification sécurisées pour ré
<Card title="Double Authentification" description="Support TOTP natif avec secret chiffré PGP pour une sécurité maximale." />
</Cards>
### Stockage & Médias (S3)
### Webhooks / Services Externes
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?path=...`.
2. **URLs Présignées** : Pour l'upload sécurisé direct depuis le client (via `/contents/upload-url`).
### Notifications (Mail)
Le système intègre un service d'envoi d'emails (SMTP) pour les notifications critiques et la gestion des comptes.
Liste des intégrations tierces.

View File

@@ -35,13 +35,10 @@ erDiagram
string username
string email
string display_name
string avatar_url
string bio
string status
}
CONTENT {
string title
string slug
string type
string storage_key
}
@@ -85,8 +82,6 @@ erDiagram
bytea email
varchar email_hash
varchar display_name
varchar avatar_url
varchar bio
varchar password_hash
user_status status
bytea two_factor_secret
@@ -105,7 +100,6 @@ erDiagram
uuid category_id FK
content_type type
varchar title
varchar slug
varchar storage_key
varchar mime_type
integer file_size
@@ -239,8 +233,6 @@ erDiagram
varchar email_hash "UNIQUE, INDEXED"
varchar username "UNIQUE, NOT NULL"
varchar password_hash "NOT NULL"
varchar avatar_url "NULLABLE"
varchar bio "NULLABLE"
bytea two_factor_secret "ENCRYPTED"
boolean is_two_factor_enabled "DEFAULT false"
timestamp gdpr_accepted_at "NULLABLE"
@@ -249,7 +241,6 @@ erDiagram
contents {
uuid id "DEFAULT gen_random_uuid()"
uuid user_id "REFERENCES users(uuid)"
varchar slug "UNIQUE, NOT NULL"
varchar storage_key "UNIQUE, NOT NULL"
integer file_size "NOT NULL"
timestamp deleted_at "SOFT DELETE"

Some files were not shown because too many files have changed in this diff Show More