Compare commits
1 Commits
v1.10.1
...
a1c48bb792
| Author | SHA1 | Date | |
|---|---|---|---|
| a1c48bb792 |
@@ -42,7 +42,6 @@ DOMAIN_NAME=localhost
|
|||||||
|
|
||||||
ENABLE_CORS=false
|
ENABLE_CORS=false
|
||||||
CORS_DOMAIN_NAME=localhost
|
CORS_DOMAIN_NAME=localhost
|
||||||
SENTRY_DSN=
|
|
||||||
|
|
||||||
# Media Limits (in KB)
|
# Media Limits (in KB)
|
||||||
MAX_IMAGE_SIZE_KB=512
|
MAX_IMAGE_SIZE_KB=512
|
||||||
|
|||||||
22
.gitea/workflows/backend-tests.yml
Normal file
22
.gitea/workflows/backend-tests.yml
Normal 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
|
||||||
@@ -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 }}
|
|
||||||
87
.gitea/workflows/deploy.yml
Normal file
87
.gitea/workflows/deploy.yml
Normal 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
31
.gitea/workflows/lint.yml
Normal 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.
26
README.md
26
README.md
@@ -59,28 +59,12 @@ Pour approfondir vos connaissances techniques sur le projet :
|
|||||||
|
|
||||||
## Comment l'utiliser ?
|
## Comment l'utiliser ?
|
||||||
|
|
||||||
### Déploiement en Production
|
### Installation locale
|
||||||
|
|
||||||
Le projet est prêt pour la production via Docker Compose.
|
1. Clonez le dépôt.
|
||||||
|
2. Installez les dépendances avec `pnpm install`.
|
||||||
1. **Prérequis** : Docker et Docker Compose installés.
|
3. Configurez les variables d'environnement (voir `.env.example`).
|
||||||
2. **Variables d'environnement** : Copiez `.env.example` en `.env.prod` et ajustez les valeurs (clés secrètes, hosts, Sentry DSN, etc.).
|
4. Lancez les services via Docker ou manuellement.
|
||||||
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).
|
|
||||||
|
|
||||||
### Clés API
|
### Clés API
|
||||||
|
|
||||||
|
|||||||
50
ROADMAP.md
50
ROADMAP.md
@@ -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
|
|
||||||
756
backend.plantuml
756
backend.plantuml
@@ -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
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TYPE "public"."content_type" ADD VALUE 'video';
|
|
||||||
@@ -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");
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE "users" ADD COLUMN "show_online_status" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
|
||||||
ALTER TABLE "users" ADD COLUMN "show_read_receipts" boolean DEFAULT true NOT NULL;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE "users" ALTER COLUMN "password_hash" SET DATA TYPE varchar(255);
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -50,34 +50,6 @@
|
|||||||
"when": 1768423315172,
|
"when": 1768423315172,
|
||||||
"tag": "0006_friendly_adam_warlock",
|
"tag": "0006_friendly_adam_warlock",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 7,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1769605995410,
|
|
||||||
"tag": "0007_melodic_synch",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 8,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1769696731978,
|
|
||||||
"tag": "0008_bitter_darwin",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 9,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1769717126917,
|
|
||||||
"tag": "0009_add_privacy_settings",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 10,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1769718997591,
|
|
||||||
"tag": "0010_update_password_hash_length",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,28 +1,18 @@
|
|||||||
# syntax=docker/dockerfile:1
|
FROM node:22-slim AS base
|
||||||
FROM node:22-alpine AS base
|
|
||||||
ENV PNPM_HOME="/pnpm"
|
ENV PNPM_HOME="/pnpm"
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
RUN apk add --no-cache ffmpeg
|
|
||||||
|
|
||||||
FROM base AS build
|
FROM base AS build
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||||
COPY backend/package.json ./backend/
|
COPY backend/package.json ./backend/
|
||||||
COPY frontend/package.json ./frontend/
|
COPY frontend/package.json ./frontend/
|
||||||
COPY documentation/package.json ./documentation/
|
COPY documentation/package.json ./documentation/
|
||||||
|
RUN pnpm install --no-frozen-lockfile
|
||||||
# Utilisation du cache pour pnpm et installation figée
|
|
||||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
|
||||||
pnpm install --frozen-lockfile --force
|
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
# On réinstalle après COPY pour s'assurer que tous les scripts de cycle de vie et les liens sont corrects
|
||||||
# Deuxième passe avec cache pour les scripts/liens
|
RUN pnpm install --no-frozen-lockfile
|
||||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
|
||||||
pnpm install --frozen-lockfile --force
|
|
||||||
|
|
||||||
RUN pnpm run --filter @memegoat/backend build
|
RUN pnpm run --filter @memegoat/backend build
|
||||||
RUN pnpm deploy --filter=@memegoat/backend --prod --legacy /app
|
RUN pnpm deploy --filter=@memegoat/backend --prod --legacy /app
|
||||||
RUN cp -r backend/dist /app/dist
|
RUN cp -r backend/dist /app/dist
|
||||||
|
|||||||
@@ -24,8 +24,7 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
"noUnknownAtRules": "off",
|
"noUnknownAtRules": "off"
|
||||||
"noExplicitAny": "off"
|
|
||||||
},
|
},
|
||||||
"style": {
|
"style": {
|
||||||
"useImportType": "off"
|
"useImportType": "off"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@memegoat/backend",
|
"name": "@memegoat/backend",
|
||||||
"version": "1.10.1",
|
"version": "0.0.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"lint": "biome check",
|
"lint": "biome check",
|
||||||
"lint:write": "biome check --write --unsafe",
|
"lint:write": "biome check --write",
|
||||||
"format": "biome format --write",
|
"format": "biome format --write",
|
||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
@@ -36,10 +36,8 @@
|
|||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
"@nestjs/mapped-types": "^2.1.0",
|
"@nestjs/mapped-types": "^2.1.0",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/platform-socket.io": "^11.1.12",
|
|
||||||
"@nestjs/schedule": "^6.1.0",
|
"@nestjs/schedule": "^6.1.0",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@nestjs/websockets": "^11.1.12",
|
|
||||||
"@noble/post-quantum": "^0.5.4",
|
"@noble/post-quantum": "^0.5.4",
|
||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"@sentry/nestjs": "^10.32.1",
|
"@sentry/nestjs": "^10.32.1",
|
||||||
@@ -50,7 +48,6 @@
|
|||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.3",
|
"class-validator": "^0.14.3",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"drizzle-kit": "^0.31.8",
|
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"fluent-ffmpeg": "^2.1.3",
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
@@ -64,12 +61,23 @@
|
|||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"socket.io": "^4.8.3",
|
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5",
|
||||||
|
"drizzle-kit": "^0.31.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^11.0.0",
|
"@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/schematics": "^11.0.0",
|
||||||
"@nestjs/testing": "^11.0.1",
|
"@nestjs/testing": "^11.0.1",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
@@ -81,21 +89,9 @@
|
|||||||
"@types/pg": "^8.16.0",
|
"@types/pg": "^8.16.0",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/sharp": "^0.32.0",
|
"@types/sharp": "^0.32.0",
|
||||||
"@types/socket.io": "^3.0.2",
|
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"@types/uuid": "^11.0.0",
|
"@types/uuid": "^11.0.0",
|
||||||
"drizzle-kit": "^0.31.8",
|
"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"
|
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": [
|
"moduleFileExtensions": [
|
||||||
@@ -111,7 +107,7 @@
|
|||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"node_modules/(?!(.pnpm/)?(jose|@noble|uuid))"
|
"node_modules/(?!(.pnpm/)?(jose|@noble|uuid)/)"
|
||||||
],
|
],
|
||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)sx?$": "ts-jest"
|
"^.+\\.(t|j)sx?$": "ts-jest"
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CacheModule } from "@nestjs/cache-manager";
|
import { CacheModule } from "@nestjs/cache-manager";
|
||||||
import { Logger, MiddlewareConsumer, Module, NestModule } from "@nestjs/common";
|
import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common";
|
||||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||||
import { ScheduleModule } from "@nestjs/schedule";
|
import { ScheduleModule } from "@nestjs/schedule";
|
||||||
import { ThrottlerModule } from "@nestjs/throttler";
|
import { ThrottlerModule } from "@nestjs/throttler";
|
||||||
@@ -10,10 +10,8 @@ import { AppController } from "./app.controller";
|
|||||||
import { AppService } from "./app.service";
|
import { AppService } from "./app.service";
|
||||||
import { AuthModule } from "./auth/auth.module";
|
import { AuthModule } from "./auth/auth.module";
|
||||||
import { CategoriesModule } from "./categories/categories.module";
|
import { CategoriesModule } from "./categories/categories.module";
|
||||||
import { CommentsModule } from "./comments/comments.module";
|
|
||||||
import { CommonModule } from "./common/common.module";
|
import { CommonModule } from "./common/common.module";
|
||||||
import { CrawlerDetectionMiddleware } from "./common/middlewares/crawler-detection.middleware";
|
import { CrawlerDetectionMiddleware } from "./common/middlewares/crawler-detection.middleware";
|
||||||
import { HTTPLoggerMiddleware } from "./common/middlewares/http-logger.middleware";
|
|
||||||
import { validateEnv } from "./config/env.schema";
|
import { validateEnv } from "./config/env.schema";
|
||||||
import { ContentsModule } from "./contents/contents.module";
|
import { ContentsModule } from "./contents/contents.module";
|
||||||
import { CryptoModule } from "./crypto/crypto.module";
|
import { CryptoModule } from "./crypto/crypto.module";
|
||||||
@@ -22,8 +20,6 @@ import { FavoritesModule } from "./favorites/favorites.module";
|
|||||||
import { HealthController } from "./health.controller";
|
import { HealthController } from "./health.controller";
|
||||||
import { MailModule } from "./mail/mail.module";
|
import { MailModule } from "./mail/mail.module";
|
||||||
import { MediaModule } from "./media/media.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 { ReportsModule } from "./reports/reports.module";
|
||||||
import { S3Module } from "./s3/s3.module";
|
import { S3Module } from "./s3/s3.module";
|
||||||
import { SessionsModule } from "./sessions/sessions.module";
|
import { SessionsModule } from "./sessions/sessions.module";
|
||||||
@@ -40,15 +36,12 @@ import { UsersModule } from "./users/users.module";
|
|||||||
UsersModule,
|
UsersModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
CategoriesModule,
|
CategoriesModule,
|
||||||
CommentsModule,
|
|
||||||
ContentsModule,
|
ContentsModule,
|
||||||
FavoritesModule,
|
FavoritesModule,
|
||||||
TagsModule,
|
TagsModule,
|
||||||
MediaModule,
|
MediaModule,
|
||||||
MessagesModule,
|
|
||||||
SessionsModule,
|
SessionsModule,
|
||||||
ReportsModule,
|
ReportsModule,
|
||||||
RealtimeModule,
|
|
||||||
ApiKeysModule,
|
ApiKeysModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
@@ -70,24 +63,12 @@ import { UsersModule } from "./users/users.module";
|
|||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: async (config: ConfigService) => {
|
useFactory: async (config: ConfigService) => ({
|
||||||
const logger = new Logger("RedisCache");
|
store: await redisStore({
|
||||||
return {
|
url: `redis://${config.get("REDIS_HOST")}:${config.get("REDIS_PORT")}`,
|
||||||
store: await redisStore({
|
}),
|
||||||
url: `redis://${config.get("REDIS_HOST")}:${config.get("REDIS_PORT")}`,
|
ttl: 600, // 10 minutes
|
||||||
socket: {
|
}),
|
||||||
reconnectStrategy: (retries) => {
|
|
||||||
const delay = Math.min(retries * 50, 2000);
|
|
||||||
logger.warn(
|
|
||||||
`Redis connection lost. Retrying in ${delay}ms (attempt ${retries})`,
|
|
||||||
);
|
|
||||||
return delay;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
ttl: 600, // 10 minutes
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AppController, HealthController],
|
controllers: [AppController, HealthController],
|
||||||
@@ -95,8 +76,6 @@ import { UsersModule } from "./users/users.module";
|
|||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
configure(consumer: MiddlewareConsumer) {
|
configure(consumer: MiddlewareConsumer) {
|
||||||
consumer
|
consumer.apply(CrawlerDetectionMiddleware).forRoutes("*");
|
||||||
.apply(HTTPLoggerMiddleware, CrawlerDetectionMiddleware)
|
|
||||||
.forRoutes("*");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,19 +1,9 @@
|
|||||||
import {
|
import { Body, Controller, Headers, Post, Req, Res } from "@nestjs/common";
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Headers,
|
|
||||||
Post,
|
|
||||||
Query,
|
|
||||||
Req,
|
|
||||||
Res,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Throttle } from "@nestjs/throttler";
|
import { Throttle } from "@nestjs/throttler";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { getIronSession } from "iron-session";
|
import { getIronSession } from "iron-session";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
import { BootstrapService } from "./bootstrap.service";
|
|
||||||
import { LoginDto } from "./dto/login.dto";
|
import { LoginDto } from "./dto/login.dto";
|
||||||
import { RegisterDto } from "./dto/register.dto";
|
import { RegisterDto } from "./dto/register.dto";
|
||||||
import { Verify2faDto } from "./dto/verify-2fa.dto";
|
import { Verify2faDto } from "./dto/verify-2fa.dto";
|
||||||
@@ -23,7 +13,6 @@ import { getSessionOptions, SessionData } from "./session.config";
|
|||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
private readonly bootstrapService: BootstrapService,
|
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -131,12 +120,4 @@ export class AuthController {
|
|||||||
session.destroy();
|
session.destroy();
|
||||||
return res.json({ message: "User logged out" });
|
return res.json({ message: "User logged out" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("bootstrap-admin")
|
|
||||||
async bootstrapAdmin(
|
|
||||||
@Query("token") token: string,
|
|
||||||
@Query("username") username: string,
|
|
||||||
) {
|
|
||||||
return this.bootstrapService.consumeToken(token, username);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { SessionsModule } from "../sessions/sessions.module";
|
|||||||
import { UsersModule } from "../users/users.module";
|
import { UsersModule } from "../users/users.module";
|
||||||
import { AuthController } from "./auth.controller";
|
import { AuthController } from "./auth.controller";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
import { BootstrapService } from "./bootstrap.service";
|
|
||||||
import { AuthGuard } from "./guards/auth.guard";
|
import { AuthGuard } from "./guards/auth.guard";
|
||||||
import { OptionalAuthGuard } from "./guards/optional-auth.guard";
|
import { OptionalAuthGuard } from "./guards/optional-auth.guard";
|
||||||
import { RolesGuard } from "./guards/roles.guard";
|
import { RolesGuard } from "./guards/roles.guard";
|
||||||
@@ -16,7 +15,6 @@ import { RbacRepository } from "./repositories/rbac.repository";
|
|||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
RbacService,
|
RbacService,
|
||||||
BootstrapService,
|
|
||||||
RbacRepository,
|
RbacRepository,
|
||||||
AuthGuard,
|
AuthGuard,
|
||||||
OptionalAuthGuard,
|
OptionalAuthGuard,
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ describe("AuthService", () => {
|
|||||||
const dto = {
|
const dto = {
|
||||||
username: "test",
|
username: "test",
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
password: "Password1!",
|
password: "password",
|
||||||
};
|
};
|
||||||
mockHashingService.hashPassword.mockResolvedValue("hashed-password");
|
mockHashingService.hashPassword.mockResolvedValue("hashed-password");
|
||||||
mockHashingService.hashEmail.mockResolvedValue("hashed-email");
|
mockHashingService.hashEmail.mockResolvedValue("hashed-email");
|
||||||
@@ -165,7 +165,7 @@ describe("AuthService", () => {
|
|||||||
|
|
||||||
describe("login", () => {
|
describe("login", () => {
|
||||||
it("should login a user", async () => {
|
it("should login a user", async () => {
|
||||||
const dto = { email: "test@example.com", password: "Password1!" };
|
const dto = { email: "test@example.com", password: "password" };
|
||||||
const user = {
|
const user = {
|
||||||
uuid: "user-id",
|
uuid: "user-id",
|
||||||
username: "test",
|
username: "test",
|
||||||
|
|||||||
@@ -103,13 +103,13 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async login(dto: LoginDto, userAgent?: string, ip?: string) {
|
async login(dto: LoginDto, userAgent?: string, ip?: string) {
|
||||||
const emailHash = await this.hashingService.hashEmail(dto.email);
|
this.logger.log(`Login attempt for email: ${dto.email}`);
|
||||||
this.logger.log(`Login attempt for email hash: ${emailHash}`);
|
const { email, password } = dto;
|
||||||
const { password } = dto;
|
|
||||||
|
const emailHash = await this.hashingService.hashEmail(email);
|
||||||
const user = await this.usersService.findByEmailHash(emailHash);
|
const user = await this.usersService.findByEmailHash(emailHash);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
this.logger.warn(`Login failed: user not found for email hash`);
|
|
||||||
throw new UnauthorizedException("Invalid credentials");
|
throw new UnauthorizedException("Invalid credentials");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,12 +119,10 @@ export class AuthService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!isPasswordValid) {
|
if (!isPasswordValid) {
|
||||||
this.logger.warn(`Login failed: invalid password for user ${user.uuid}`);
|
|
||||||
throw new UnauthorizedException("Invalid credentials");
|
throw new UnauthorizedException("Invalid credentials");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.isTwoFactorEnabled) {
|
if (user.isTwoFactorEnabled) {
|
||||||
this.logger.log(`2FA required for user ${user.uuid}`);
|
|
||||||
return {
|
return {
|
||||||
message: "2FA required",
|
message: "2FA required",
|
||||||
requires2FA: true,
|
requires2FA: true,
|
||||||
@@ -135,7 +133,6 @@ export class AuthService {
|
|||||||
const accessToken = await this.jwtService.generateJwt({
|
const accessToken = await this.jwtService.generateJwt({
|
||||||
sub: user.uuid,
|
sub: user.uuid,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
role: user.role,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const session = await this.sessionsService.createSession(
|
const session = await this.sessionsService.createSession(
|
||||||
@@ -144,7 +141,6 @@ export class AuthService {
|
|||||||
ip,
|
ip,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`User ${user.uuid} logged in successfully`);
|
|
||||||
return {
|
return {
|
||||||
message: "User logged in successfully",
|
message: "User logged in successfully",
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
@@ -169,16 +165,12 @@ export class AuthService {
|
|||||||
|
|
||||||
const isValid = authenticator.verify({ token, secret });
|
const isValid = authenticator.verify({ token, secret });
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
this.logger.warn(
|
|
||||||
`2FA verification failed for user ${userId}: invalid token`,
|
|
||||||
);
|
|
||||||
throw new UnauthorizedException("Invalid 2FA token");
|
throw new UnauthorizedException("Invalid 2FA token");
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessToken = await this.jwtService.generateJwt({
|
const accessToken = await this.jwtService.generateJwt({
|
||||||
sub: user.uuid,
|
sub: user.uuid,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
role: user.role,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const session = await this.sessionsService.createSession(
|
const session = await this.sessionsService.createSession(
|
||||||
@@ -187,7 +179,6 @@ export class AuthService {
|
|||||||
ip,
|
ip,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`User ${userId} logged in successfully via 2FA`);
|
|
||||||
return {
|
return {
|
||||||
message: "User logged in successfully (2FA)",
|
message: "User logged in successfully (2FA)",
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
@@ -206,7 +197,6 @@ export class AuthService {
|
|||||||
const accessToken = await this.jwtService.generateJwt({
|
const accessToken = await this.jwtService.generateJwt({
|
||||||
sub: user.uuid,
|
sub: user.uuid,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
role: user.role,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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` };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
IsEmail,
|
IsEmail,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsString,
|
IsString,
|
||||||
Matches,
|
|
||||||
MaxLength,
|
MaxLength,
|
||||||
MinLength,
|
MinLength,
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
@@ -11,10 +10,6 @@ export class RegisterDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@MaxLength(32)
|
@MaxLength(32)
|
||||||
@Matches(/^[a-z0-9_]+$/, {
|
|
||||||
message:
|
|
||||||
"username must contain only lowercase letters, numbers, and underscores",
|
|
||||||
})
|
|
||||||
username!: string;
|
username!: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -26,15 +21,5 @@ export class RegisterDto {
|
|||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(8)
|
@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;
|
password!: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -9,8 +9,6 @@ describe("RbacService", () => {
|
|||||||
const mockRbacRepository = {
|
const mockRbacRepository = {
|
||||||
findRolesByUserId: jest.fn(),
|
findRolesByUserId: jest.fn(),
|
||||||
findPermissionsByUserId: jest.fn(),
|
findPermissionsByUserId: jest.fn(),
|
||||||
countRoles: jest.fn(),
|
|
||||||
createRole: jest.fn(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -60,35 +58,4 @@ describe("RbacService", () => {
|
|||||||
expect(repository.findPermissionsByUserId).toHaveBeenCalledWith(userId);
|
expect(repository.findPermissionsByUserId).toHaveBeenCalledWith(userId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("seedRoles", () => {
|
|
||||||
it("should be called on application bootstrap", async () => {
|
|
||||||
const seedRolesSpy = jest.spyOn(service, "seedRoles");
|
|
||||||
await service.onApplicationBootstrap();
|
|
||||||
expect(seedRolesSpy).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should seed roles if none exist", async () => {
|
|
||||||
mockRbacRepository.countRoles.mockResolvedValue(0);
|
|
||||||
|
|
||||||
await service.seedRoles();
|
|
||||||
|
|
||||||
expect(repository.countRoles).toHaveBeenCalled();
|
|
||||||
expect(repository.createRole).toHaveBeenCalledTimes(3);
|
|
||||||
expect(repository.createRole).toHaveBeenCalledWith(
|
|
||||||
"Administrator",
|
|
||||||
"admin",
|
|
||||||
"Full system access",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not seed roles if some already exist", async () => {
|
|
||||||
mockRbacRepository.countRoles.mockResolvedValue(3);
|
|
||||||
|
|
||||||
await service.seedRoles();
|
|
||||||
|
|
||||||
expect(repository.countRoles).toHaveBeenCalled();
|
|
||||||
expect(repository.createRole).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,53 +1,10 @@
|
|||||||
import { Injectable, Logger, OnApplicationBootstrap } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { RbacRepository } from "./repositories/rbac.repository";
|
import { RbacRepository } from "./repositories/rbac.repository";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RbacService implements OnApplicationBootstrap {
|
export class RbacService {
|
||||||
private readonly logger = new Logger(RbacService.name);
|
|
||||||
|
|
||||||
constructor(private readonly rbacRepository: RbacRepository) {}
|
constructor(private readonly rbacRepository: RbacRepository) {}
|
||||||
|
|
||||||
async onApplicationBootstrap() {
|
|
||||||
this.logger.log("RbacService initialized, checking roles...");
|
|
||||||
await this.seedRoles();
|
|
||||||
}
|
|
||||||
|
|
||||||
async seedRoles() {
|
|
||||||
try {
|
|
||||||
const count = await this.rbacRepository.countRoles();
|
|
||||||
if (count === 0) {
|
|
||||||
this.logger.log("No roles found, seeding default roles...");
|
|
||||||
const defaultRoles = [
|
|
||||||
{
|
|
||||||
name: "Administrator",
|
|
||||||
slug: "admin",
|
|
||||||
description: "Full system access",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Moderator",
|
|
||||||
slug: "moderator",
|
|
||||||
description: "Access to moderation tools",
|
|
||||||
},
|
|
||||||
{ name: "User", slug: "user", description: "Standard user access" },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const role of defaultRoles) {
|
|
||||||
await this.rbacRepository.createRole(
|
|
||||||
role.name,
|
|
||||||
role.slug,
|
|
||||||
role.description,
|
|
||||||
);
|
|
||||||
this.logger.log(`Created role: ${role.slug}`);
|
|
||||||
}
|
|
||||||
this.logger.log("Default roles seeded successfully.");
|
|
||||||
} else {
|
|
||||||
this.logger.log(`${count} roles already exist, skipping seeding.`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error("Error during roles seeding:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUserRoles(userId: string) {
|
async getUserRoles(userId: string) {
|
||||||
return this.rbacRepository.findRolesByUserId(userId);
|
return this.rbacRepository.findRolesByUserId(userId);
|
||||||
}
|
}
|
||||||
@@ -55,12 +12,4 @@ export class RbacService implements OnApplicationBootstrap {
|
|||||||
async getUserPermissions(userId: string) {
|
async getUserPermissions(userId: string) {
|
||||||
return this.rbacRepository.findPermissionsByUserId(userId);
|
return this.rbacRepository.findPermissionsByUserId(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async countAdmins() {
|
|
||||||
return this.rbacRepository.countAdmins();
|
|
||||||
}
|
|
||||||
|
|
||||||
async assignRoleToUser(userId: string, roleSlug: string) {
|
|
||||||
return this.rbacRepository.assignRole(userId, roleSlug);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,52 +39,4 @@ export class RbacRepository {
|
|||||||
|
|
||||||
return Array.from(new Set(result.map((p) => p.slug)));
|
return Array.from(new Set(result.map((p) => p.slug)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async countRoles(): Promise<number> {
|
|
||||||
const result = await this.databaseService.db
|
|
||||||
.select({ count: roles.id })
|
|
||||||
.from(roles);
|
|
||||||
return result.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
async countAdmins(): Promise<number> {
|
|
||||||
const result = await this.databaseService.db
|
|
||||||
.select({ count: usersToRoles.userId })
|
|
||||||
.from(usersToRoles)
|
|
||||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
|
||||||
.where(eq(roles.slug, "admin"));
|
|
||||||
return result.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createRole(name: string, slug: string, description?: string) {
|
|
||||||
return this.databaseService.db
|
|
||||||
.insert(roles)
|
|
||||||
.values({
|
|
||||||
name,
|
|
||||||
slug,
|
|
||||||
description,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
}
|
|
||||||
|
|
||||||
async assignRole(userId: string, roleSlug: string) {
|
|
||||||
const role = await this.databaseService.db
|
|
||||||
.select()
|
|
||||||
.from(roles)
|
|
||||||
.where(eq(roles.slug, roleSlug))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!role[0]) {
|
|
||||||
throw new Error(`Role with slug ${roleSlug} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.databaseService.db
|
|
||||||
.insert(usersToRoles)
|
|
||||||
.values({
|
|
||||||
userId,
|
|
||||||
roleId: role[0].id,
|
|
||||||
})
|
|
||||||
.onConflictDoNothing()
|
|
||||||
.returning();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -15,12 +15,8 @@ export class CategoriesService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async clearCategoriesCache() {
|
private async clearCategoriesCache() {
|
||||||
try {
|
this.logger.log("Clearing categories cache");
|
||||||
this.logger.log("Clearing categories cache");
|
await this.cacheManager.del("categories/all");
|
||||||
await this.cacheManager.del("categories/all");
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Error clearing categories cache: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll() {
|
async findAll() {
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {}
|
|
||||||
@@ -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",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { ArgumentsHost, HttpException, HttpStatus } from "@nestjs/common";
|
|
||||||
import { Test, TestingModule } from "@nestjs/testing";
|
|
||||||
import * as Sentry from "@sentry/nestjs";
|
|
||||||
import { AllExceptionsFilter } from "./http-exception.filter";
|
|
||||||
|
|
||||||
jest.mock("@sentry/nestjs", () => ({
|
|
||||||
captureException: jest.fn(),
|
|
||||||
withScope: jest.fn((callback) => {
|
|
||||||
const scope = {
|
|
||||||
setUser: jest.fn(),
|
|
||||||
setTag: jest.fn(),
|
|
||||||
setExtra: jest.fn(),
|
|
||||||
};
|
|
||||||
callback(scope);
|
|
||||||
return scope;
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("AllExceptionsFilter", () => {
|
|
||||||
let filter: AllExceptionsFilter;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
providers: [AllExceptionsFilter],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
filter = module.get<AllExceptionsFilter>(AllExceptionsFilter);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should hash the IP address and send it to Sentry for 500 errors", () => {
|
|
||||||
const mockResponse = {
|
|
||||||
status: jest.fn().mockReturnThis(),
|
|
||||||
json: jest.fn().mockReturnThis(),
|
|
||||||
};
|
|
||||||
const mockRequest = {
|
|
||||||
url: "/test",
|
|
||||||
method: "GET",
|
|
||||||
ip: "127.0.0.1",
|
|
||||||
user: { sub: "user-123" },
|
|
||||||
};
|
|
||||||
const mockArgumentsHost = {
|
|
||||||
switchToHttp: () => ({
|
|
||||||
getResponse: () => mockResponse,
|
|
||||||
getRequest: () => mockRequest,
|
|
||||||
}),
|
|
||||||
} as ArgumentsHost;
|
|
||||||
|
|
||||||
const exception = new Error("Internal Server Error");
|
|
||||||
|
|
||||||
filter.catch(exception, mockArgumentsHost);
|
|
||||||
|
|
||||||
expect(mockResponse.status).toHaveBeenCalledWith(
|
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
);
|
|
||||||
expect(Sentry.withScope).toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Vérifier que captureException a été appelé (via withScope)
|
|
||||||
expect(Sentry.captureException).toHaveBeenCalledWith(exception);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should include hashed IP in logs", () => {
|
|
||||||
const loggerSpy = jest.spyOn((filter as any).logger, "warn");
|
|
||||||
const mockResponse = {
|
|
||||||
status: jest.fn().mockReturnThis(),
|
|
||||||
json: jest.fn().mockReturnThis(),
|
|
||||||
};
|
|
||||||
const mockRequest = {
|
|
||||||
url: "/test",
|
|
||||||
method: "GET",
|
|
||||||
ip: "1.2.3.4",
|
|
||||||
};
|
|
||||||
const mockArgumentsHost = {
|
|
||||||
switchToHttp: () => ({
|
|
||||||
getResponse: () => mockResponse,
|
|
||||||
getRequest: () => mockRequest,
|
|
||||||
}),
|
|
||||||
} as ArgumentsHost;
|
|
||||||
|
|
||||||
const exception = new HttpException("Bad Request", HttpStatus.BAD_REQUEST);
|
|
||||||
|
|
||||||
filter.catch(exception, mockArgumentsHost);
|
|
||||||
|
|
||||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
|
|
||||||
|
|
||||||
// L'IP 1.2.3.4 hachée en SHA256 contient un hash de 64 caractères
|
|
||||||
const logCall = loggerSpy.mock.calls[0][0];
|
|
||||||
expect(logCall).toMatch(/[a-f0-9]{64}/);
|
|
||||||
expect(logCall).not.toContain("1.2.3.4");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { createHash } from "node:crypto";
|
|
||||||
import {
|
import {
|
||||||
ArgumentsHost,
|
ArgumentsHost,
|
||||||
Catch,
|
Catch,
|
||||||
@@ -10,14 +9,6 @@ import {
|
|||||||
import * as Sentry from "@sentry/nestjs";
|
import * as Sentry from "@sentry/nestjs";
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
|
|
||||||
interface RequestWithUser extends Request {
|
|
||||||
user?: {
|
|
||||||
sub?: string;
|
|
||||||
username?: string;
|
|
||||||
id?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@Catch()
|
@Catch()
|
||||||
export class AllExceptionsFilter implements ExceptionFilter {
|
export class AllExceptionsFilter implements ExceptionFilter {
|
||||||
private readonly logger = new Logger("ExceptionFilter");
|
private readonly logger = new Logger("ExceptionFilter");
|
||||||
@@ -25,7 +16,7 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
|||||||
catch(exception: unknown, host: ArgumentsHost) {
|
catch(exception: unknown, host: ArgumentsHost) {
|
||||||
const ctx = host.switchToHttp();
|
const ctx = host.switchToHttp();
|
||||||
const response = ctx.getResponse<Response>();
|
const response = ctx.getResponse<Response>();
|
||||||
const request = ctx.getRequest<RequestWithUser>();
|
const request = ctx.getRequest<Request>();
|
||||||
|
|
||||||
const status =
|
const status =
|
||||||
exception instanceof HttpException
|
exception instanceof HttpException
|
||||||
@@ -37,14 +28,6 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
|||||||
? exception.getResponse()
|
? exception.getResponse()
|
||||||
: "Internal server error";
|
: "Internal server error";
|
||||||
|
|
||||||
const userId = request.user?.sub || request.user?.id;
|
|
||||||
const userPart = userId ? `[User: ${userId}] ` : "";
|
|
||||||
|
|
||||||
const ip = request.ip || "unknown";
|
|
||||||
const hashedIp = createHash("sha256")
|
|
||||||
.update(ip as string)
|
|
||||||
.digest("hex");
|
|
||||||
|
|
||||||
const errorResponse = {
|
const errorResponse = {
|
||||||
statusCode: status,
|
statusCode: status,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@@ -57,20 +40,14 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (status === HttpStatus.INTERNAL_SERVER_ERROR) {
|
if (status === HttpStatus.INTERNAL_SERVER_ERROR) {
|
||||||
Sentry.withScope((scope) => {
|
Sentry.captureException(exception);
|
||||||
scope.setUser({
|
|
||||||
id: userId,
|
|
||||||
ip_address: hashedIp,
|
|
||||||
});
|
|
||||||
Sentry.captureException(exception);
|
|
||||||
});
|
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`${userPart}${hashedIp} ${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 : "",
|
exception instanceof Error ? exception.stack : "",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`${userPart}${hashedIp} ${request.method} ${request.url} - Status: ${status} - Message: ${JSON.stringify(message)}`,
|
`${request.method} ${request.url} - Status: ${status} - Message: ${JSON.stringify(message)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,5 @@ export interface AuthenticatedRequest extends Request {
|
|||||||
user: {
|
user: {
|
||||||
sub: string;
|
sub: string;
|
||||||
username: string;
|
username: string;
|
||||||
role: string;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,4 @@ export interface IStorageService {
|
|||||||
sourceBucketName?: string,
|
sourceBucketName?: string,
|
||||||
destinationBucketName?: string,
|
destinationBucketName?: string,
|
||||||
): Promise<string>;
|
): Promise<string>;
|
||||||
|
|
||||||
getPublicUrl(storageKey: string): string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { Injectable, Logger, NestMiddleware } from "@nestjs/common";
|
||||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
|
||||||
import { Inject, Injectable, Logger, NestMiddleware } from "@nestjs/common";
|
|
||||||
import type { Cache } from "cache-manager";
|
|
||||||
import type { NextFunction, Request, Response } from "express";
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CrawlerDetectionMiddleware implements NestMiddleware {
|
export class CrawlerDetectionMiddleware implements NestMiddleware {
|
||||||
private readonly logger = new Logger("CrawlerDetection");
|
private readonly logger = new Logger("CrawlerDetection");
|
||||||
|
|
||||||
constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}
|
|
||||||
|
|
||||||
private readonly SUSPICIOUS_PATTERNS = [
|
private readonly SUSPICIOUS_PATTERNS = [
|
||||||
/\.env/,
|
/\.env/,
|
||||||
/wp-admin/,
|
/wp-admin/,
|
||||||
@@ -29,7 +24,7 @@ export class CrawlerDetectionMiddleware implements NestMiddleware {
|
|||||||
/db\./,
|
/db\./,
|
||||||
/backup\./,
|
/backup\./,
|
||||||
/cgi-bin/,
|
/cgi-bin/,
|
||||||
/\.well-known\/security\.txt/,
|
/\.well-known\/security\.txt/, // Bien que légitime, souvent scanné
|
||||||
];
|
];
|
||||||
|
|
||||||
private readonly BOT_USER_AGENTS = [
|
private readonly BOT_USER_AGENTS = [
|
||||||
@@ -45,32 +40,11 @@ export class CrawlerDetectionMiddleware implements NestMiddleware {
|
|||||||
/masscan/i,
|
/masscan/i,
|
||||||
];
|
];
|
||||||
|
|
||||||
async use(req: Request, res: Response, next: NextFunction) {
|
use(req: Request, res: Response, next: NextFunction) {
|
||||||
const { method, url, ip } = req;
|
const { method, url, ip } = req;
|
||||||
const userAgent = req.get("user-agent") || "unknown";
|
const userAgent = req.get("user-agent") || "unknown";
|
||||||
|
|
||||||
const hashedIp = createHash("sha256")
|
res.on("finish", () => {
|
||||||
.update(ip as string)
|
|
||||||
.digest("hex");
|
|
||||||
|
|
||||||
// Vérifier si l'IP est bannie
|
|
||||||
try {
|
|
||||||
const isBanned = await this.cacheManager.get(`banned_ip:${ip}`);
|
|
||||||
if (isBanned) {
|
|
||||||
this.logger.warn(`Banned IP attempt: ${hashedIp} -> ${method} ${url}`);
|
|
||||||
res.status(403).json({
|
|
||||||
message: "Access denied: Your IP has been temporarily banned.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(
|
|
||||||
`Error checking ban status for IP ${hashedIp}: ${error.message}`,
|
|
||||||
);
|
|
||||||
// On continue même en cas d'erreur Redis pour ne pas bloquer les utilisateurs légitimes
|
|
||||||
}
|
|
||||||
|
|
||||||
res.on("finish", async () => {
|
|
||||||
if (res.statusCode === 404) {
|
if (res.statusCode === 404) {
|
||||||
const isSuspiciousPath = this.SUSPICIOUS_PATTERNS.some((pattern) =>
|
const isSuspiciousPath = this.SUSPICIOUS_PATTERNS.some((pattern) =>
|
||||||
pattern.test(url),
|
pattern.test(url),
|
||||||
@@ -81,15 +55,9 @@ export class CrawlerDetectionMiddleware implements NestMiddleware {
|
|||||||
|
|
||||||
if (isSuspiciousPath || isBotUserAgent) {
|
if (isSuspiciousPath || isBotUserAgent) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Potential crawler detected: [${hashedIp}] ${method} ${url} - User-Agent: ${userAgent}`,
|
`Potential crawler detected: [${ip}] ${method} ${url} - User-Agent: ${userAgent}`,
|
||||||
);
|
);
|
||||||
|
// Ici, on pourrait ajouter une logique pour bannir l'IP temporairement via Redis
|
||||||
// Bannir l'IP pour 24h via Redis
|
|
||||||
try {
|
|
||||||
await this.cacheManager.set(`banned_ip:${ip}`, true, 86400000);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Error banning IP ${hashedIp}: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -33,7 +33,6 @@ export const envSchema = z.object({
|
|||||||
MAIL_FROM: z.string().email(),
|
MAIL_FROM: z.string().email(),
|
||||||
|
|
||||||
DOMAIN_NAME: z.string(),
|
DOMAIN_NAME: z.string(),
|
||||||
API_URL: z.string().url().optional(),
|
|
||||||
|
|
||||||
// Sentry
|
// Sentry
|
||||||
SENTRY_DSN: z.string().optional(),
|
SENTRY_DSN: z.string().optional(),
|
||||||
@@ -48,7 +47,6 @@ export const envSchema = z.object({
|
|||||||
// Media Limits
|
// Media Limits
|
||||||
MAX_IMAGE_SIZE_KB: z.coerce.number().default(512),
|
MAX_IMAGE_SIZE_KB: z.coerce.number().default(512),
|
||||||
MAX_GIF_SIZE_KB: z.coerce.number().default(1024),
|
MAX_GIF_SIZE_KB: z.coerce.number().default(1024),
|
||||||
MAX_VIDEO_SIZE_KB: z.coerce.number().default(10240),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Env = z.infer<typeof envSchema>;
|
export type Env = z.infer<typeof envSchema>;
|
||||||
|
|||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
ParseBoolPipe,
|
ParseBoolPipe,
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
Patch,
|
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Req,
|
Req,
|
||||||
@@ -174,16 +173,6 @@ export class ContentsController {
|
|||||||
return this.contentsService.incrementUsage(id);
|
return this.contentsService.incrementUsage(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(":id")
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
update(
|
|
||||||
@Param("id") id: string,
|
|
||||||
@Req() req: AuthenticatedRequest,
|
|
||||||
@Body() updateContentDto: any,
|
|
||||||
) {
|
|
||||||
return this.contentsService.update(id, req.user.sub, updateContentDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete(":id")
|
@Delete(":id")
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
remove(@Param("id") id: string, @Req() req: AuthenticatedRequest) {
|
remove(@Param("id") id: string, @Req() req: AuthenticatedRequest) {
|
||||||
@@ -196,11 +185,4 @@ export class ContentsController {
|
|||||||
removeAdmin(@Param("id") id: string) {
|
removeAdmin(@Param("id") id: string) {
|
||||||
return this.contentsService.removeAdmin(id);
|
return this.contentsService.removeAdmin(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(":id/admin")
|
|
||||||
@UseGuards(AuthGuard, RolesGuard)
|
|
||||||
@Roles("admin")
|
|
||||||
updateAdmin(@Param("id") id: string, @Body() updateContentDto: any) {
|
|
||||||
return this.contentsService.updateAdmin(id, updateContentDto);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { AuthModule } from "../auth/auth.module";
|
import { AuthModule } from "../auth/auth.module";
|
||||||
import { MediaModule } from "../media/media.module";
|
import { MediaModule } from "../media/media.module";
|
||||||
import { RealtimeModule } from "../realtime/realtime.module";
|
|
||||||
import { S3Module } from "../s3/s3.module";
|
import { S3Module } from "../s3/s3.module";
|
||||||
import { ContentsController } from "./contents.controller";
|
import { ContentsController } from "./contents.controller";
|
||||||
import { ContentsService } from "./contents.service";
|
import { ContentsService } from "./contents.service";
|
||||||
import { ContentsRepository } from "./repositories/contents.repository";
|
import { ContentsRepository } from "./repositories/contents.repository";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [S3Module, AuthModule, MediaModule, RealtimeModule],
|
imports: [S3Module, AuthModule, MediaModule],
|
||||||
controllers: [ContentsController],
|
controllers: [ContentsController],
|
||||||
providers: [ContentsService, ContentsRepository],
|
providers: [ContentsService, ContentsRepository],
|
||||||
exports: [ContentsRepository],
|
exports: [ContentsRepository],
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { BadRequestException } from "@nestjs/common";
|
|||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Test, TestingModule } from "@nestjs/testing";
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import { MediaService } from "../media/media.service";
|
import { MediaService } from "../media/media.service";
|
||||||
import { EventsGateway } from "../realtime/events.gateway";
|
|
||||||
import { S3Service } from "../s3/s3.service";
|
import { S3Service } from "../s3/s3.service";
|
||||||
import { ContentsService } from "./contents.service";
|
import { ContentsService } from "./contents.service";
|
||||||
import { ContentsRepository } from "./repositories/contents.repository";
|
import { ContentsRepository } from "./repositories/contents.repository";
|
||||||
@@ -24,7 +23,6 @@ describe("ContentsService", () => {
|
|||||||
incrementViews: jest.fn(),
|
incrementViews: jest.fn(),
|
||||||
incrementUsage: jest.fn(),
|
incrementUsage: jest.fn(),
|
||||||
softDelete: jest.fn(),
|
softDelete: jest.fn(),
|
||||||
softDeleteAdmin: jest.fn(),
|
|
||||||
findOne: jest.fn(),
|
findOne: jest.fn(),
|
||||||
findBySlug: jest.fn(),
|
findBySlug: jest.fn(),
|
||||||
};
|
};
|
||||||
@@ -32,7 +30,6 @@ describe("ContentsService", () => {
|
|||||||
const mockS3Service = {
|
const mockS3Service = {
|
||||||
getUploadUrl: jest.fn(),
|
getUploadUrl: jest.fn(),
|
||||||
uploadFile: jest.fn(),
|
uploadFile: jest.fn(),
|
||||||
getPublicUrl: jest.fn(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockMediaService = {
|
const mockMediaService = {
|
||||||
@@ -50,10 +47,6 @@ describe("ContentsService", () => {
|
|||||||
del: jest.fn(),
|
del: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockEventsGateway = {
|
|
||||||
sendToUser: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|
||||||
@@ -65,7 +58,6 @@ describe("ContentsService", () => {
|
|||||||
{ provide: MediaService, useValue: mockMediaService },
|
{ provide: MediaService, useValue: mockMediaService },
|
||||||
{ provide: ConfigService, useValue: mockConfigService },
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
|
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
|
||||||
{ provide: EventsGateway, useValue: mockEventsGateway },
|
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@@ -154,81 +146,4 @@ describe("ContentsService", () => {
|
|||||||
expect(result[0].views).toBe(1);
|
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import type {
|
|||||||
} from "../common/interfaces/media.interface";
|
} from "../common/interfaces/media.interface";
|
||||||
import type { IStorageService } from "../common/interfaces/storage.interface";
|
import type { IStorageService } from "../common/interfaces/storage.interface";
|
||||||
import { MediaService } from "../media/media.service";
|
import { MediaService } from "../media/media.service";
|
||||||
import { EventsGateway } from "../realtime/events.gateway";
|
|
||||||
import { S3Service } from "../s3/s3.service";
|
import { S3Service } from "../s3/s3.service";
|
||||||
import { CreateContentDto } from "./dto/create-content.dto";
|
import { CreateContentDto } from "./dto/create-content.dto";
|
||||||
import { UploadContentDto } from "./dto/upload-content.dto";
|
import { UploadContentDto } from "./dto/upload-content.dto";
|
||||||
@@ -30,16 +29,11 @@ export class ContentsService {
|
|||||||
@Inject(MediaService) private readonly mediaService: IMediaService,
|
@Inject(MediaService) private readonly mediaService: IMediaService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
@Inject(CACHE_MANAGER) private cacheManager: Cache,
|
@Inject(CACHE_MANAGER) private cacheManager: Cache,
|
||||||
private readonly eventsGateway: EventsGateway,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async clearContentsCache() {
|
private async clearContentsCache() {
|
||||||
try {
|
this.logger.log("Clearing contents cache");
|
||||||
this.logger.log("Clearing contents cache");
|
await this.cacheManager.clear();
|
||||||
await this.cacheManager.del("contents/all");
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Error clearing contents cache: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUploadUrl(userId: string, fileName: string) {
|
async getUploadUrl(userId: string, fileName: string) {
|
||||||
@@ -54,11 +48,6 @@ export class ContentsService {
|
|||||||
data: UploadContentDto,
|
data: UploadContentDto,
|
||||||
) {
|
) {
|
||||||
this.logger.log(`Uploading and processing file for user ${userId}`);
|
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
|
// 0. Validation du format et de la taille
|
||||||
const allowedMimeTypes = [
|
const allowedMimeTypes = [
|
||||||
"image/png",
|
"image/png",
|
||||||
@@ -66,117 +55,59 @@ export class ContentsService {
|
|||||||
"image/webp",
|
"image/webp",
|
||||||
"image/gif",
|
"image/gif",
|
||||||
"video/webm",
|
"video/webm",
|
||||||
"video/mp4",
|
|
||||||
"video/quicktime",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!allowedMimeTypes.includes(file.mimetype)) {
|
if (!allowedMimeTypes.includes(file.mimetype)) {
|
||||||
this.eventsGateway.sendToUser(userId, "upload_progress", {
|
|
||||||
status: "error",
|
|
||||||
message: "Format de fichier non supporté",
|
|
||||||
});
|
|
||||||
throw new BadRequestException(
|
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
|
const isGif = file.mimetype === "image/gif";
|
||||||
let contentType: "meme" | "gif" | "video" = "meme";
|
const maxSizeKb = isGif
|
||||||
if (file.mimetype === "image/gif") {
|
? this.configService.get<number>("MAX_GIF_SIZE_KB", 1024)
|
||||||
contentType = "gif";
|
: this.configService.get<number>("MAX_IMAGE_SIZE_KB", 512);
|
||||||
} 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.size > maxSizeKb * 1024) {
|
if (file.size > maxSizeKb * 1024) {
|
||||||
this.eventsGateway.sendToUser(userId, "upload_progress", {
|
|
||||||
status: "error",
|
|
||||||
message: "Fichier trop volumineux",
|
|
||||||
});
|
|
||||||
throw new BadRequestException(
|
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
|
// 1. Scan Antivirus
|
||||||
this.eventsGateway.sendToUser(userId, "upload_progress", {
|
|
||||||
status: "scanning",
|
|
||||||
progress: 20,
|
|
||||||
});
|
|
||||||
const scanResult = await this.mediaService.scanFile(
|
const scanResult = await this.mediaService.scanFile(
|
||||||
file.buffer,
|
file.buffer,
|
||||||
file.originalname,
|
file.originalname,
|
||||||
);
|
);
|
||||||
if (scanResult.isInfected) {
|
if (scanResult.isInfected) {
|
||||||
this.eventsGateway.sendToUser(userId, "upload_progress", {
|
|
||||||
status: "error",
|
|
||||||
message: "Fichier infecté",
|
|
||||||
});
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Le fichier est infecté par ${scanResult.virusName}`,
|
`Le fichier est infecté par ${scanResult.virusName}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Transcodage
|
// 2. Transcodage
|
||||||
this.eventsGateway.sendToUser(userId, "upload_progress", {
|
|
||||||
status: "processing",
|
|
||||||
progress: 40,
|
|
||||||
});
|
|
||||||
let processed: MediaProcessingResult;
|
let processed: MediaProcessingResult;
|
||||||
if (file.mimetype.startsWith("image/") && file.mimetype !== "image/gif") {
|
if (file.mimetype.startsWith("image/")) {
|
||||||
// Image -> WebP (format moderne, bien supporté)
|
// Image ou GIF -> WebP (format moderne, bien supporté)
|
||||||
processed = await this.mediaService.processImage(file.buffer, "webp");
|
processed = await this.mediaService.processImage(file.buffer, "webp");
|
||||||
} else if (
|
} else if (file.mimetype.startsWith("video/")) {
|
||||||
file.mimetype.startsWith("video/") ||
|
// Vidéo -> WebM
|
||||||
file.mimetype === "image/gif"
|
|
||||||
) {
|
|
||||||
// Vidéo ou GIF -> WebM
|
|
||||||
processed = await this.mediaService.processVideo(file.buffer, "webm");
|
processed = await this.mediaService.processVideo(file.buffer, "webm");
|
||||||
} else {
|
} else {
|
||||||
throw new BadRequestException("Format de fichier non supporté");
|
throw new BadRequestException("Format de fichier non supporté");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Upload vers S3
|
// 3. Upload vers S3
|
||||||
this.eventsGateway.sendToUser(userId, "upload_progress", {
|
|
||||||
status: "uploading_s3",
|
|
||||||
progress: 70,
|
|
||||||
});
|
|
||||||
const key = `contents/${userId}/${Date.now()}-${uuidv4()}.${processed.extension}`;
|
const key = `contents/${userId}/${Date.now()}-${uuidv4()}.${processed.extension}`;
|
||||||
await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType);
|
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
|
// 4. Création en base de données
|
||||||
this.eventsGateway.sendToUser(userId, "upload_progress", {
|
return await this.create(userId, {
|
||||||
status: "saving",
|
|
||||||
progress: 90,
|
|
||||||
});
|
|
||||||
const content = await this.create(userId, {
|
|
||||||
...data,
|
...data,
|
||||||
type: contentType, // Utiliser le type autodéterminé
|
|
||||||
storageKey: key,
|
storageKey: key,
|
||||||
mimeType: processed.mimeType,
|
mimeType: processed.mimeType,
|
||||||
fileSize: processed.size,
|
fileSize: processed.size,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.eventsGateway.sendToUser(userId, "upload_progress", {
|
|
||||||
status: "completed",
|
|
||||||
progress: 100,
|
|
||||||
contentId: content.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return content;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(options: {
|
async findAll(options: {
|
||||||
@@ -197,11 +128,11 @@ export class ContentsService {
|
|||||||
|
|
||||||
const processedData = data.map((content) => ({
|
const processedData = data.map((content) => ({
|
||||||
...content,
|
...content,
|
||||||
url: this.s3Service.getPublicUrl(content.storageKey),
|
url: this.getFileUrl(content.storageKey),
|
||||||
author: {
|
author: {
|
||||||
...content.author,
|
...content.author,
|
||||||
avatarUrl: content.author?.avatarUrl
|
avatarUrl: content.author?.avatarUrl
|
||||||
? this.s3Service.getPublicUrl(content.author.avatarUrl)
|
? this.getFileUrl(content.author.avatarUrl)
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -252,53 +183,24 @@ export class ContentsService {
|
|||||||
return deleted;
|
return deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateAdmin(id: string, data: any) {
|
|
||||||
this.logger.log(`Updating content ${id} by admin`);
|
|
||||||
const updated = await this.contentsRepository.update(id, data);
|
|
||||||
|
|
||||||
if (updated) {
|
|
||||||
await this.clearContentsCache();
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(id: string, userId: string, data: any) {
|
|
||||||
this.logger.log(`Updating content ${id} for user ${userId}`);
|
|
||||||
|
|
||||||
// Vérifier que le contenu appartient à l'utilisateur
|
|
||||||
const existing = await this.contentsRepository.findOne(id, userId);
|
|
||||||
if (!existing || existing.userId !== userId) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
"Contenu non trouvé ou vous n'avez pas la permission de le modifier.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await this.contentsRepository.update(id, data);
|
|
||||||
|
|
||||||
if (updated) {
|
|
||||||
await this.clearContentsCache();
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findOne(idOrSlug: string, userId?: string) {
|
async findOne(idOrSlug: string, userId?: string) {
|
||||||
const content = await this.contentsRepository.findOne(idOrSlug, userId);
|
const content = await this.contentsRepository.findOne(idOrSlug, userId);
|
||||||
if (!content) return null;
|
if (!content) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...content,
|
...content,
|
||||||
url: this.s3Service.getPublicUrl(content.storageKey),
|
url: this.getFileUrl(content.storageKey),
|
||||||
author: {
|
author: {
|
||||||
...content.author,
|
...content.author,
|
||||||
avatarUrl: content.author?.avatarUrl
|
avatarUrl: content.author?.avatarUrl
|
||||||
? this.s3Service.getPublicUrl(content.author.avatarUrl)
|
? this.getFileUrl(content.author.avatarUrl)
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
generateBotHtml(content: { title: string; storageKey: string }): string {
|
generateBotHtml(content: { title: string; storageKey: string }): string {
|
||||||
const imageUrl = this.s3Service.getPublicUrl(content.storageKey);
|
const imageUrl = this.getFileUrl(content.storageKey);
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -319,6 +221,19 @@ export class ContentsService {
|
|||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getFileUrl(storageKey: string): string {
|
||||||
|
const endpoint = this.configService.get("S3_ENDPOINT");
|
||||||
|
const port = this.configService.get("S3_PORT");
|
||||||
|
const protocol =
|
||||||
|
this.configService.get("S3_USE_SSL") === true ? "https" : "http";
|
||||||
|
const bucket = this.configService.get("S3_BUCKET_NAME");
|
||||||
|
|
||||||
|
if (endpoint === "localhost" || endpoint === "127.0.0.1") {
|
||||||
|
return `${protocol}://${endpoint}:${port}/${bucket}/${storageKey}`;
|
||||||
|
}
|
||||||
|
return `${protocol}://${endpoint}/${bucket}/${storageKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
private generateSlug(text: string): string {
|
private generateSlug(text: string): string {
|
||||||
return text
|
return text
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
|
|||||||
@@ -12,12 +12,11 @@ import {
|
|||||||
export enum ContentType {
|
export enum ContentType {
|
||||||
MEME = "meme",
|
MEME = "meme",
|
||||||
GIF = "gif",
|
GIF = "gif",
|
||||||
VIDEO = "video",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CreateContentDto {
|
export class CreateContentDto {
|
||||||
@IsEnum(ContentType)
|
@IsEnum(ContentType)
|
||||||
type!: "meme" | "gif" | "video";
|
type!: "meme" | "gif";
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { ContentType } from "./create-content.dto";
|
|||||||
|
|
||||||
export class UploadContentDto {
|
export class UploadContentDto {
|
||||||
@IsEnum(ContentType)
|
@IsEnum(ContentType)
|
||||||
type!: "meme" | "gif" | "video";
|
type!: "meme" | "gif";
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
|||||||
@@ -404,15 +404,6 @@ export class ContentsRepository {
|
|||||||
return deleted;
|
return deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, data: Partial<typeof contents.$inferInsert>) {
|
|
||||||
const [updated] = await this.databaseService.db
|
|
||||||
.update(contents)
|
|
||||||
.set({ ...data, updatedAt: new Date() })
|
|
||||||
.where(eq(contents.id, id))
|
|
||||||
.returning();
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findBySlug(slug: string) {
|
async findBySlug(slug: string) {
|
||||||
const [result] = await this.databaseService.db
|
const [result] = await this.databaseService.db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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] }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
@@ -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;
|
|
||||||
@@ -12,7 +12,7 @@ import { categories } from "./categories";
|
|||||||
import { tags } from "./tags";
|
import { tags } from "./tags";
|
||||||
import { users } from "./users";
|
import { users } from "./users";
|
||||||
|
|
||||||
export const contentType = pgEnum("content_type", ["meme", "gif", "video"]);
|
export const contentType = pgEnum("content_type", ["meme", "gif"]);
|
||||||
|
|
||||||
export const contents = pgTable(
|
export const contents = pgTable(
|
||||||
"contents",
|
"contents",
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
export * from "./api_keys";
|
export * from "./api_keys";
|
||||||
export * from "./audit_logs";
|
export * from "./audit_logs";
|
||||||
export * from "./categories";
|
export * from "./categories";
|
||||||
export * from "./comment_likes";
|
|
||||||
export * from "./comments";
|
|
||||||
export * from "./content";
|
export * from "./content";
|
||||||
export * from "./favorites";
|
export * from "./favorites";
|
||||||
export * from "./messages";
|
|
||||||
export * from "./pgp";
|
export * from "./pgp";
|
||||||
export * from "./rbac";
|
export * from "./rbac";
|
||||||
export * from "./reports";
|
export * from "./reports";
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -21,19 +21,14 @@ const getPgpKey = () => process.env.PGP_ENCRYPTION_KEY || "default-pgp-key";
|
|||||||
* withAutomaticPgpDecrypt(users.email);
|
* withAutomaticPgpDecrypt(users.email);
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export const pgpEncrypted = customType<{
|
export const pgpEncrypted = customType<{ data: string; driverData: Buffer }>({
|
||||||
data: string | null;
|
|
||||||
driverData: Buffer | string | null | SQL;
|
|
||||||
}>({
|
|
||||||
dataType() {
|
dataType() {
|
||||||
return "bytea";
|
return "bytea";
|
||||||
},
|
},
|
||||||
toDriver(value: string | null): SQL | null {
|
toDriver(value: string): SQL {
|
||||||
if (value === null) return null;
|
|
||||||
return sql`pgp_sym_encrypt(${value}, ${getPgpKey()})`;
|
return sql`pgp_sym_encrypt(${value}, ${getPgpKey()})`;
|
||||||
},
|
},
|
||||||
fromDriver(value: Buffer | string | null | any): string | null {
|
fromDriver(value: Buffer | string): string {
|
||||||
if (value === null || value === undefined) return null;
|
|
||||||
if (typeof value === "string") return value;
|
if (typeof value === "string") return value;
|
||||||
return value.toString();
|
return value.toString();
|
||||||
},
|
},
|
||||||
@@ -46,9 +41,7 @@ export const pgpEncrypted = customType<{
|
|||||||
export function withAutomaticPgpDecrypt<T extends AnyPgColumn>(column: T): T {
|
export function withAutomaticPgpDecrypt<T extends AnyPgColumn>(column: T): T {
|
||||||
const originalGetSQL = column.getSQL.bind(column);
|
const originalGetSQL = column.getSQL.bind(column);
|
||||||
column.getSQL = () =>
|
column.getSQL = () =>
|
||||||
sql`pgp_sym_decrypt(${originalGetSQL()}, ${getPgpKey()})::text`.mapWith(
|
sql`pgp_sym_decrypt(${originalGetSQL()}, ${getPgpKey()})`.mapWith(column);
|
||||||
column,
|
|
||||||
);
|
|
||||||
return column;
|
return column;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +59,5 @@ export function pgpSymDecrypt(
|
|||||||
column: AnyPgColumn,
|
column: AnyPgColumn,
|
||||||
key: string | SQL,
|
key: string | SQL,
|
||||||
): SQL<string> {
|
): SQL<string> {
|
||||||
return sql`pgp_sym_decrypt(${column}, ${key})::text`.mapWith(
|
return sql`pgp_sym_decrypt(${column}, ${key})`.mapWith(column) as SQL<string>;
|
||||||
column,
|
|
||||||
) as SQL<string>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,15 +29,13 @@ export const users = pgTable(
|
|||||||
displayName: varchar("display_name", { length: 32 }),
|
displayName: varchar("display_name", { length: 32 }),
|
||||||
|
|
||||||
username: varchar("username", { length: 32 }).notNull().unique(),
|
username: varchar("username", { length: 32 }).notNull().unique(),
|
||||||
passwordHash: varchar("password_hash", { length: 255 }).notNull(),
|
passwordHash: varchar("password_hash", { length: 100 }).notNull(),
|
||||||
avatarUrl: varchar("avatar_url", { length: 512 }),
|
avatarUrl: varchar("avatar_url", { length: 512 }),
|
||||||
bio: varchar("bio", { length: 255 }),
|
bio: varchar("bio", { length: 255 }),
|
||||||
|
|
||||||
// Sécurité
|
// Sécurité
|
||||||
twoFactorSecret: pgpEncrypted("two_factor_secret"),
|
twoFactorSecret: pgpEncrypted("two_factor_secret"),
|
||||||
isTwoFactorEnabled: boolean("is_two_factor_enabled").notNull().default(false),
|
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é
|
// RGPD & Conformité
|
||||||
termsVersion: varchar("terms_version", { length: 16 }), // Version des CGU acceptées
|
termsVersion: varchar("terms_version", { length: 16 }), // Version des CGU acceptées
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
|
||||||
import { Test, TestingModule } from "@nestjs/testing";
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import { DatabaseService } from "./database/database.service";
|
import { DatabaseService } from "./database/database.service";
|
||||||
import { HealthController } from "./health.controller";
|
import { HealthController } from "./health.controller";
|
||||||
@@ -10,10 +9,6 @@ describe("HealthController", () => {
|
|||||||
execute: jest.fn().mockResolvedValue([]),
|
execute: jest.fn().mockResolvedValue([]),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockCacheManager = {
|
|
||||||
set: jest.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
@@ -24,42 +19,24 @@ describe("HealthController", () => {
|
|||||||
db: mockDb,
|
db: mockDb,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
provide: CACHE_MANAGER,
|
|
||||||
useValue: mockCacheManager,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
controller = module.get<HealthController>(HealthController);
|
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([]);
|
mockDb.execute.mockResolvedValue([]);
|
||||||
mockCacheManager.set.mockResolvedValue(undefined);
|
|
||||||
const result = await controller.check();
|
const result = await controller.check();
|
||||||
expect(result.status).toBe("ok");
|
expect(result.status).toBe("ok");
|
||||||
expect(result.database).toBe("connected");
|
expect(result.database).toBe("connected");
|
||||||
expect(result.redis).toBe("connected");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return error if database is disconnected", async () => {
|
it("should return error if database is disconnected", async () => {
|
||||||
mockDb.execute.mockRejectedValue(new Error("DB Error"));
|
mockDb.execute.mockRejectedValue(new Error("DB Error"));
|
||||||
mockCacheManager.set.mockResolvedValue(undefined);
|
|
||||||
const result = await controller.check();
|
const result = await controller.check();
|
||||||
expect(result.status).toBe("error");
|
expect(result.status).toBe("error");
|
||||||
expect(result.database).toBe("disconnected");
|
expect(result.database).toBe("disconnected");
|
||||||
expect(result.databaseError).toBe("DB Error");
|
expect(result.message).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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,44 +1,28 @@
|
|||||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
import { Controller, Get } from "@nestjs/common";
|
||||||
import { Controller, Get, Inject } from "@nestjs/common";
|
|
||||||
import type { Cache } from "cache-manager";
|
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { DatabaseService } from "./database/database.service";
|
import { DatabaseService } from "./database/database.service";
|
||||||
|
|
||||||
@Controller("health")
|
@Controller("health")
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
constructor(
|
constructor(private readonly databaseService: DatabaseService) {}
|
||||||
private readonly databaseService: DatabaseService,
|
|
||||||
@Inject(CACHE_MANAGER) private cacheManager: Cache,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async check() {
|
async check() {
|
||||||
const health: any = {
|
|
||||||
status: "ok",
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check database connection
|
// Check database connection
|
||||||
await this.databaseService.db.execute(sql`SELECT 1`);
|
await this.databaseService.db.execute(sql`SELECT 1`);
|
||||||
health.database = "connected";
|
return {
|
||||||
|
status: "ok",
|
||||||
|
database: "connected",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
health.status = "error";
|
return {
|
||||||
health.database = "disconnected";
|
status: "error",
|
||||||
health.databaseError = error.message;
|
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { createHash } from "node:crypto";
|
|
||||||
import { Logger, ValidationPipe } from "@nestjs/common";
|
import { Logger, ValidationPipe } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { NestFactory } from "@nestjs/core";
|
import { NestFactory } from "@nestjs/core";
|
||||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
|
||||||
import * as Sentry from "@sentry/nestjs";
|
import * as Sentry from "@sentry/nestjs";
|
||||||
import { nodeProfilingIntegration } from "@sentry/profiling-node";
|
import { nodeProfilingIntegration } from "@sentry/profiling-node";
|
||||||
import helmet from "helmet";
|
import helmet from "helmet";
|
||||||
@@ -10,13 +8,10 @@ import { AppModule } from "./app.module";
|
|||||||
import { AllExceptionsFilter } from "./common/filters/http-exception.filter";
|
import { AllExceptionsFilter } from "./common/filters/http-exception.filter";
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
const configService = app.get(ConfigService);
|
const configService = app.get(ConfigService);
|
||||||
const logger = new Logger("Bootstrap");
|
const logger = new Logger("Bootstrap");
|
||||||
|
|
||||||
// Activer trust proxy pour récupérer l'IP réelle derrière un reverse proxy
|
|
||||||
app.set("trust proxy", true);
|
|
||||||
|
|
||||||
const sentryDsn = configService.get<string>("SENTRY_DSN");
|
const sentryDsn = configService.get<string>("SENTRY_DSN");
|
||||||
if (sentryDsn) {
|
if (sentryDsn) {
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
@@ -25,15 +20,6 @@ async function bootstrap() {
|
|||||||
tracesSampleRate: 1.0,
|
tracesSampleRate: 1.0,
|
||||||
profilesSampleRate: 1.0,
|
profilesSampleRate: 1.0,
|
||||||
sendDefaultPii: false, // RGPD
|
sendDefaultPii: false, // RGPD
|
||||||
beforeSend(event) {
|
|
||||||
// Hachage de l'IP utilisateur pour Sentry si elle est présente
|
|
||||||
if (event.user?.ip_address) {
|
|
||||||
event.user.ip_address = createHash("sha256")
|
|
||||||
.update(event.user.ip_address)
|
|
||||||
.digest("hex");
|
|
||||||
}
|
|
||||||
return event;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
const mockS3Service = {
|
|
||||||
getFileInfo: jest.fn(),
|
|
||||||
getFile: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
controllers: [MediaController],
|
|
||||||
providers: [{ provide: S3Service, useValue: mockS3Service }],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
controller = module.get<MediaController>(MediaController);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should be defined", () => {
|
|
||||||
expect(controller).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getFile", () => {
|
|
||||||
it("should stream the file and set headers with path containing slashes", async () => {
|
|
||||||
const res = {
|
|
||||||
setHeader: jest.fn(),
|
|
||||||
} as unknown as Response;
|
|
||||||
const stream = new Readable();
|
|
||||||
stream.pipe = jest.fn();
|
|
||||||
const key = "contents/user-id/test.webp";
|
|
||||||
|
|
||||||
mockS3Service.getFileInfo.mockResolvedValue({
|
|
||||||
size: 100,
|
|
||||||
metaData: { "content-type": "image/webp" },
|
|
||||||
});
|
|
||||||
mockS3Service.getFile.mockResolvedValue(stream);
|
|
||||||
|
|
||||||
await controller.getFile(key, 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;
|
|
||||||
|
|
||||||
await expect(controller.getFile("invalid", res)).rejects.toThrow(
|
|
||||||
NotFoundException,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Logger,
|
|
||||||
NotFoundException,
|
|
||||||
Query,
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
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 contentType: string =
|
|
||||||
stats.metaData?.["content-type"] ||
|
|
||||||
stats.metaData?.["Content-Type"] ||
|
|
||||||
"application/octet-stream";
|
|
||||||
|
|
||||||
res.setHeader("Content-Type", contentType);
|
|
||||||
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}`,
|
|
||||||
);
|
|
||||||
throw new NotFoundException("Fichier non trouvé");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,9 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { S3Module } from "../s3/s3.module";
|
|
||||||
import { MediaController } from "./media.controller";
|
|
||||||
import { MediaService } from "./media.service";
|
import { MediaService } from "./media.service";
|
||||||
import { ImageProcessorStrategy } from "./strategies/image-processor.strategy";
|
import { ImageProcessorStrategy } from "./strategies/image-processor.strategy";
|
||||||
import { VideoProcessorStrategy } from "./strategies/video-processor.strategy";
|
import { VideoProcessorStrategy } from "./strategies/video-processor.strategy";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [S3Module],
|
|
||||||
controllers: [MediaController],
|
|
||||||
providers: [MediaService, ImageProcessorStrategy, VideoProcessorStrategy],
|
providers: [MediaService, ImageProcessorStrategy, VideoProcessorStrategy],
|
||||||
exports: [MediaService],
|
exports: [MediaService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ describe("MediaService", () => {
|
|||||||
toFormat: jest.fn().mockReturnThis(),
|
toFormat: jest.fn().mockReturnThis(),
|
||||||
videoCodec: jest.fn().mockReturnThis(),
|
videoCodec: jest.fn().mockReturnThis(),
|
||||||
audioCodec: jest.fn().mockReturnThis(),
|
audioCodec: jest.fn().mockReturnThis(),
|
||||||
addOutputOptions: jest.fn().mockReturnThis(),
|
outputOptions: jest.fn().mockReturnThis(),
|
||||||
on: jest.fn().mockImplementation(function (event, cb) {
|
on: jest.fn().mockImplementation(function (event, cb) {
|
||||||
if (event === "end") setTimeout(cb, 0);
|
if (event === "end") setTimeout(cb, 0);
|
||||||
return this;
|
return this;
|
||||||
@@ -96,37 +96,4 @@ describe("MediaService", () => {
|
|||||||
expect(result.buffer).toEqual(Buffer.from("processed-video"));
|
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export class VideoProcessorStrategy implements IMediaProcessorStrategy {
|
|||||||
private readonly logger = new Logger(VideoProcessorStrategy.name);
|
private readonly logger = new Logger(VideoProcessorStrategy.name);
|
||||||
|
|
||||||
canHandle(mimeType: string): boolean {
|
canHandle(mimeType: string): boolean {
|
||||||
return mimeType.startsWith("video/") || mimeType === "image/gif";
|
return mimeType.startsWith("video/");
|
||||||
}
|
}
|
||||||
|
|
||||||
async process(
|
async process(
|
||||||
@@ -37,13 +37,13 @@ export class VideoProcessorStrategy implements IMediaProcessorStrategy {
|
|||||||
.toFormat("webm")
|
.toFormat("webm")
|
||||||
.videoCodec("libvpx-vp9")
|
.videoCodec("libvpx-vp9")
|
||||||
.audioCodec("libopus")
|
.audioCodec("libopus")
|
||||||
.addOutputOptions("-crf", "30", "-b:v", "0");
|
.outputOptions("-crf 30", "-b:v 0");
|
||||||
} else {
|
} else {
|
||||||
command = command
|
command = command
|
||||||
.toFormat("mp4")
|
.toFormat("mp4")
|
||||||
.videoCodec("libaom-av1")
|
.videoCodec("libaom-av1")
|
||||||
.audioCodec("libopus")
|
.audioCodec("libopus")
|
||||||
.addOutputOptions("-crf", "34", "-b:v", "0", "-strict", "experimental");
|
.outputOptions("-crf 34", "-b:v 0", "-strict experimental");
|
||||||
}
|
}
|
||||||
|
|
||||||
command
|
command
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import { IsNotEmpty, IsString, IsUUID, MaxLength } from "class-validator";
|
|
||||||
|
|
||||||
export class CreateMessageDto {
|
|
||||||
@IsUUID()
|
|
||||||
recipientId!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
@MaxLength(2000)
|
|
||||||
text!: string;
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { forwardRef, Module } from "@nestjs/common";
|
|
||||||
import { AuthModule } from "../auth/auth.module";
|
|
||||||
import { RealtimeModule } from "../realtime/realtime.module";
|
|
||||||
import { UsersModule } from "../users/users.module";
|
|
||||||
import { MessagesController } from "./messages.controller";
|
|
||||||
import { MessagesService } from "./messages.service";
|
|
||||||
import { MessagesRepository } from "./repositories/messages.repository";
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [AuthModule, RealtimeModule, forwardRef(() => UsersModule)],
|
|
||||||
controllers: [MessagesController],
|
|
||||||
providers: [MessagesService, MessagesRepository],
|
|
||||||
exports: [MessagesService],
|
|
||||||
})
|
|
||||||
export class MessagesModule {}
|
|
||||||
@@ -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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import {
|
|
||||||
ForbiddenException,
|
|
||||||
forwardRef,
|
|
||||||
Inject,
|
|
||||||
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,
|
|
||||||
@Inject(forwardRef(() => UsersService))
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { forwardRef, Module } from "@nestjs/common";
|
|
||||||
import { ConfigModule } from "@nestjs/config";
|
|
||||||
import { CryptoModule } from "../crypto/crypto.module";
|
|
||||||
import { UsersModule } from "../users/users.module";
|
|
||||||
import { EventsGateway } from "./events.gateway";
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [CryptoModule, ConfigModule, forwardRef(() => UsersModule)],
|
|
||||||
providers: [EventsGateway],
|
|
||||||
exports: [EventsGateway],
|
|
||||||
})
|
|
||||||
export class RealtimeModule {}
|
|
||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -7,7 +7,8 @@ jest.mock("minio");
|
|||||||
|
|
||||||
describe("S3Service", () => {
|
describe("S3Service", () => {
|
||||||
let service: S3Service;
|
let service: S3Service;
|
||||||
let configService: ConfigService;
|
let _configService: ConfigService;
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: Fine for testing purposes
|
||||||
let minioClient: any;
|
let minioClient: any;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -41,7 +42,7 @@ describe("S3Service", () => {
|
|||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<S3Service>(S3Service);
|
service = module.get<S3Service>(S3Service);
|
||||||
configService = module.get<ConfigService>(ConfigService);
|
_configService = module.get<ConfigService>(ConfigService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be defined", () => {
|
it("should be defined", () => {
|
||||||
@@ -184,39 +185,35 @@ describe("S3Service", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getPublicUrl", () => {
|
describe("moveFile", () => {
|
||||||
it("should use API_URL if provided", () => {
|
it("should move file within default bucket", async () => {
|
||||||
(configService.get as jest.Mock).mockImplementation((key: string) => {
|
const source = "source.txt";
|
||||||
if (key === "API_URL") return "https://api.test.com";
|
const dest = "dest.txt";
|
||||||
return null;
|
await service.moveFile(source, dest);
|
||||||
});
|
|
||||||
const url = service.getPublicUrl("test.webp");
|
expect(minioClient.copyObject).toHaveBeenCalledWith(
|
||||||
expect(url).toBe("https://api.test.com/media?path=test.webp");
|
"memegoat",
|
||||||
|
dest,
|
||||||
|
"/memegoat/source.txt",
|
||||||
|
expect.any(Minio.CopyConditions),
|
||||||
|
);
|
||||||
|
expect(minioClient.removeObject).toHaveBeenCalledWith("memegoat", source);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use DOMAIN_NAME and PORT for localhost", () => {
|
it("should move file between different buckets", async () => {
|
||||||
(configService.get as jest.Mock).mockImplementation(
|
const source = "source.txt";
|
||||||
(key: string, def: unknown) => {
|
const dest = "dest.txt";
|
||||||
if (key === "API_URL") return null;
|
const sBucket = "source-bucket";
|
||||||
if (key === "DOMAIN_NAME") return "localhost";
|
const dBucket = "dest-bucket";
|
||||||
if (key === "PORT") return 3000;
|
await service.moveFile(source, dest, sBucket, dBucket);
|
||||||
return def;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const url = service.getPublicUrl("test.webp");
|
|
||||||
expect(url).toBe("http://localhost:3000/media?path=test.webp");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should use api.DOMAIN_NAME for production", () => {
|
expect(minioClient.copyObject).toHaveBeenCalledWith(
|
||||||
(configService.get as jest.Mock).mockImplementation(
|
dBucket,
|
||||||
(key: string, def: unknown) => {
|
dest,
|
||||||
if (key === "API_URL") return null;
|
`/${sBucket}/${source}`,
|
||||||
if (key === "DOMAIN_NAME") return "memegoat.fr";
|
expect.any(Minio.CopyConditions),
|
||||||
return def;
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
const url = service.getPublicUrl("test.webp");
|
expect(minioClient.removeObject).toHaveBeenCalledWith(sBucket, source);
|
||||||
expect(url).toBe("https://api.memegoat.fr/media?path=test.webp");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ export class S3Service implements OnModuleInit, IStorageService {
|
|||||||
...metaData,
|
...metaData,
|
||||||
"Content-Type": mimeType,
|
"Content-Type": mimeType,
|
||||||
});
|
});
|
||||||
this.logger.log(`File uploaded successfully: ${fileName} to ${bucketName}`);
|
|
||||||
return fileName;
|
return fileName;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Error uploading file to ${bucketName}: ${error.message}`);
|
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) {
|
async deleteFile(fileName: string, bucketName: string = this.bucketName) {
|
||||||
try {
|
try {
|
||||||
await this.minioClient.removeObject(bucketName, fileName);
|
await this.minioClient.removeObject(bucketName, fileName);
|
||||||
this.logger.log(`File deleted successfully: ${fileName} from ${bucketName}`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Error deleting file from ${bucketName}: ${error.message}`,
|
`Error deleting file from ${bucketName}: ${error.message}`,
|
||||||
@@ -157,22 +155,4 @@ export class S3Service implements OnModuleInit, IStorageService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getPublicUrl(storageKey: string): string {
|
|
||||||
const apiUrl = this.configService.get<string>("API_URL");
|
|
||||||
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}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${baseUrl}/media?path=${storageKey}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsBoolean, IsOptional, IsString, MaxLength } from "class-validator";
|
import { IsOptional, IsString, MaxLength } from "class-validator";
|
||||||
|
|
||||||
export class UpdateUserDto {
|
export class UpdateUserDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -14,20 +14,4 @@ export class UpdateUserDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
status?: "active" | "verification" | "suspended" | "pending" | "deleted";
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
role?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
showOnlineStatus?: boolean;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
showReadReceipts?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
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 { DatabaseService } from "../../database/database.service";
|
||||||
import { contents, favorites, users } from "../../database/schemas";
|
import { contents, favorites, users } from "../../database/schemas";
|
||||||
|
|
||||||
@@ -47,8 +47,6 @@ export class UsersRepository {
|
|||||||
bio: users.bio,
|
bio: users.bio,
|
||||||
status: users.status,
|
status: users.status,
|
||||||
isTwoFactorEnabled: users.isTwoFactorEnabled,
|
isTwoFactorEnabled: users.isTwoFactorEnabled,
|
||||||
showOnlineStatus: users.showOnlineStatus,
|
|
||||||
showReadReceipts: users.showReadReceipts,
|
|
||||||
createdAt: users.createdAt,
|
createdAt: users.createdAt,
|
||||||
updatedAt: users.updatedAt,
|
updatedAt: users.updatedAt,
|
||||||
})
|
})
|
||||||
@@ -66,7 +64,7 @@ export class UsersRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async findAll(limit: number, offset: number) {
|
async findAll(limit: number, offset: number) {
|
||||||
const result = await this.databaseService.db
|
return await this.databaseService.db
|
||||||
.select({
|
.select({
|
||||||
uuid: users.uuid,
|
uuid: users.uuid,
|
||||||
username: users.username,
|
username: users.username,
|
||||||
@@ -79,8 +77,6 @@ export class UsersRepository {
|
|||||||
.from(users)
|
.from(users)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset);
|
.offset(offset);
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByUsername(username: string) {
|
async findByUsername(username: string) {
|
||||||
@@ -99,24 +95,6 @@ export class UsersRepository {
|
|||||||
return result[0] || null;
|
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) {
|
async findOne(uuid: string) {
|
||||||
const result = await this.databaseService.db
|
const result = await this.databaseService.db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user