Compare commits
307 Commits
e953b2aa44
...
v1.5.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
878c35cbcd
|
||
|
|
8cf0036248
|
||
|
|
c389024f59
|
||
|
|
bbdbe58af5
|
||
|
|
5951e41eb5
|
||
|
|
7442236e8d
|
||
|
|
3ef7292287
|
||
|
|
f1a571196d
|
||
|
|
f4cd20a010
|
||
|
|
988eacc281
|
||
|
|
329a150ff8
|
||
|
|
4372f75025
|
||
|
|
4fa163b542
|
||
|
|
7f0749808e
|
||
|
|
bcbc93d6a3
|
||
|
|
89587d6abc
|
||
|
|
3347d693ce
|
||
|
|
5048b4813c
|
||
|
|
906f615428
|
||
|
|
fc4efd1e24
|
||
|
|
6bc6a8f68c
|
||
|
|
e69156407e
|
||
|
|
7dce7ec286
|
||
|
|
029bbe9bb9
|
||
|
|
c3f57db1e5
|
||
|
|
939448d15c
|
||
|
|
4e61b0de9a
|
||
|
|
73556894f8
|
||
|
|
96a9d6e7a7
|
||
|
|
058830bb60
|
||
|
|
02d612e026
|
||
|
|
498f85d24e
|
||
|
|
10cc5a6d8d
|
||
|
|
7503707ef1
|
||
|
|
8778508ced
|
||
|
|
b968d1e6f8
|
||
|
|
0382b21a65
|
||
|
|
764c4c07c8
|
||
|
|
68b5071f6d
|
||
|
|
f5c90b0ae4
|
||
|
|
c8820a71b6
|
||
|
|
9b714716f6
|
||
|
|
3a5550d6eb
|
||
|
|
07cdb741b3
|
||
|
|
02796e4e1f
|
||
|
|
951b38db67
|
||
|
|
a90aba2748
|
||
|
|
3f0b1e5119
|
||
|
|
aff8acebf8
|
||
|
|
a721b4041c
|
||
|
|
f4a1a2f4df
|
||
|
|
0548c418c7
|
||
|
|
dd0a9e620b
|
||
|
|
7e7b19fe9f
|
||
|
|
57bc51290b
|
||
|
|
d613a89e63
|
||
|
|
67a10ad7d8
|
||
|
|
82e98f4fce
|
||
|
|
70a4249e41
|
||
|
|
de7d41f4a1
|
||
|
|
2da1142866
|
||
|
|
4e8e441d98
|
||
|
|
0e83de70e3
|
||
|
|
8169ef719a
|
||
|
|
7637499a97
|
||
|
|
c03ad8c221
|
||
|
|
8483927823
|
||
|
|
e7b79013fd
|
||
|
|
b6b37ebc6b
|
||
|
|
d647a585c8
|
||
|
|
6a2abf115f
|
||
|
|
ded2d3220d
|
||
|
|
162d53630d
|
||
|
|
0e8a2e3986
|
||
|
|
5cc77ae5b0
|
||
|
|
3b9b73bc4b
|
||
|
|
a6e34c511e
|
||
|
|
13650b6a39
|
||
|
|
dbe90ae47b
|
||
|
|
d0c78cb206
|
||
|
|
1c38434b6e
|
||
|
|
1666aaadf2
|
||
|
|
6ac429f111
|
||
|
|
872087dc44
|
||
|
|
f8eaad3f81
|
||
|
|
5f176def8c
|
||
|
|
9ef6bbfd96
|
||
|
|
61b25f7b9e
|
||
|
|
d0286d51ff
|
||
|
|
2291cc8afb
|
||
|
|
bad2caef08
|
||
|
|
ac4568a0f0
|
||
|
|
a11a332eaa
|
||
|
|
02c00e8aae
|
||
|
|
2886e50a0c
|
||
|
|
59a5cc941e
|
||
|
|
78db4b1c34
|
||
|
|
b177bee75c
|
||
|
|
0cd6509273
|
||
|
|
05a56ff87d
|
||
|
|
3fa11474c1
|
||
|
|
4c12c5c5cb
|
||
|
|
48dbdbfdcc
|
||
|
|
002a6b912a
|
||
|
|
733ffbff31
|
||
|
|
4700526dd2
|
||
|
|
2450977e61
|
||
|
|
afc18b555a
|
||
|
|
9699127739
|
||
|
|
938d8bde7b
|
||
|
|
65c7096f46
|
||
|
|
57c00ad4d1
|
||
|
|
39618f7708
|
||
|
|
e84e4a5a9d
|
||
|
|
e74973a9d0
|
||
|
|
9233c1bf89
|
||
|
|
88c7f45a2c
|
||
|
|
9af72156f5
|
||
|
|
597a4d615e
|
||
|
|
2df45af305
|
||
|
|
863a4bf528
|
||
|
|
9a1cdb05a4
|
||
|
|
28caf92f9a
|
||
|
|
8b2728dc5a
|
||
|
|
3bbbbc307f
|
||
|
|
f080919563
|
||
|
|
edc1ab2438
|
||
|
|
01b66d6f2f
|
||
|
|
9a70dd02bb
|
||
|
|
e285a4e634
|
||
|
|
f247a01ac7
|
||
|
|
bb640cd8f9
|
||
|
|
c1118e9f25
|
||
|
|
eae1f84b92
|
||
|
|
8d27532dc0
|
||
|
|
f79507730e
|
||
|
|
7048c2731e
|
||
|
|
d74fd15036
|
||
|
|
86a697c392
|
||
|
|
38adbb6e77
|
||
| 594a387712 | |||
|
|
4ca15b578d
|
||
| 2912231769 | |||
|
|
db17994bb5
|
||
|
|
f57e028178
|
||
|
|
e84aa8a8db
|
||
|
|
c6b23de481
|
||
|
|
0611ef715c
|
||
|
|
0a1391674f
|
||
|
|
2fedaca502
|
||
|
|
a6837ff7fb
|
||
|
|
74b61004e7
|
||
|
|
760343da76
|
||
|
|
14f8b8b63d
|
||
|
|
50a186da1d
|
||
|
|
3908989b39
|
||
|
|
02d70f27ea
|
||
|
|
65f8860cc0
|
||
|
|
0e9edd4bfc
|
||
|
|
6ce58d1639
|
||
|
|
47d6fcb6a0
|
||
|
|
d7c2a965a0
|
||
|
|
fb7ddde42e
|
||
|
|
026aebaee3
|
||
|
|
a30113e8e2
|
||
| f10c444957 | |||
|
|
975e29dea1
|
||
|
|
a4ce48a91c
|
||
|
|
ff6fc1c6b3
|
||
|
|
5671ba60a6
|
||
|
|
5f2672021e
|
||
| 17c2cea366 | |||
| 5665fcd98f | |||
| cb6d87eafd | |||
| 48ebc7dc36 | |||
| dbfd14b57a | |||
| 570576435c | |||
| 7c3f4050c5 | |||
| c19d86a0cb | |||
| 6d2e1ead05 | |||
| 6756cf6bc7 | |||
| 6aaf53c90b | |||
| ccec39bfa0 | |||
| a06fdbf21e | |||
| de537e5947 | |||
|
|
0cb361afb8
|
||
| 9097a3e9b5 | |||
|
|
24eb99093c
|
||
|
|
75ac95cadb
|
||
|
|
35abd0496e
|
||
|
|
03e5915fcc
|
||
|
|
77ac960411
|
||
|
|
8425ffe4fc
|
||
|
|
b81835661c
|
||
|
|
fbc231dc9a
|
||
|
|
37a23390d5
|
||
|
|
bd9dd140ab
|
||
|
|
5b6e0143b6
|
||
|
|
214bf077e5
|
||
|
|
bb9ae058db
|
||
|
|
0b07320974
|
||
|
|
0c045e8d3c
|
||
|
|
8ffeaeba05
|
||
|
|
9e37272bff
|
||
|
|
7cb5ff487d
|
||
|
|
0cef694f2b
|
||
|
|
5c4badb837
|
||
|
|
b53c51b825
|
||
|
|
76de69fc64
|
||
|
|
ec8eb8d43a
|
||
|
|
514bd354bf
|
||
|
|
9c45bf11e4
|
||
|
|
5a22ad7480
|
||
|
|
9654553940
|
||
|
|
a5a8626f5d
|
||
|
|
64adc80062
|
||
|
|
702868dec2
|
||
|
|
399bdab86c
|
||
|
|
cc2823db7d
|
||
|
|
6254c136d1
|
||
|
|
3828f170e2
|
||
|
|
ec771eb074
|
||
|
|
77263aead9
|
||
|
|
ab74dc3b30
|
||
|
|
acd53eff6a
|
||
|
|
91e23c2c02
|
||
|
|
f508e8ee6d
|
||
|
|
3c02bd6023
|
||
|
|
6e823743fc
|
||
|
|
99a350aa05
|
||
|
|
8b51b84d44
|
||
|
|
0af6f6b52a
|
||
|
|
382e39ebd0
|
||
|
|
65b7cba6b1
|
||
|
|
f7d85108e1
|
||
|
|
d5775a821e
|
||
|
|
add7cab7df
|
||
|
|
da5f18bf92
|
||
|
|
a0836c8392
|
||
|
|
9963046e41
|
||
|
|
dde1bf522f
|
||
|
|
dd875fe1ea
|
||
|
|
92ea36545a
|
||
|
|
912394477b
|
||
|
|
fe309bc1e3
|
||
|
|
342e9b99da
|
||
|
|
e210f1f95f
|
||
|
|
2218768adb
|
||
|
|
705f1ad6e0
|
||
|
|
42805e371e
|
||
|
|
9406ed9350
|
||
|
|
9ab737b8c7
|
||
|
|
b3035eb2ab
|
||
|
|
a6fdbdb06d
|
||
|
|
48b233eae4
|
||
|
|
89bd9d65e7
|
||
|
|
8cf1699717
|
||
| 93b86a6b7a | |||
| 3363ef52ef | |||
| 06d2a65567 | |||
| fd32a14221 | |||
| e3f9197abb | |||
| cee4d41ef0 | |||
|
|
187c51f932
|
||
|
|
c1bc68e3e3
|
||
|
|
810acd8ed4
|
||
|
|
adceada1b6
|
||
|
|
dfba0c0adb
|
||
|
|
6074917bfb
|
||
|
|
86543eeb4f
|
||
|
|
38e97741e0
|
||
|
|
bfce5b2964
|
||
|
|
b22129c4dd
|
||
|
|
cadc497dec
|
||
|
|
0b84e0aecc
|
||
|
|
ac5cb96f97
|
||
|
|
2389d2c2c6
|
||
|
|
694031c05b
|
||
|
|
cbf7bfcb0a
|
||
|
|
9fb890699a
|
||
|
|
9439c004e2
|
||
|
|
27954daf64
|
||
|
|
7001082fb2
|
||
|
|
04ca5090df
|
||
|
|
19ceac1303
|
||
|
|
381ca24501
|
||
|
|
eefe2906ed
|
||
|
|
8ee0491c96
|
||
|
|
73aea94d88
|
||
|
|
7761e26d32
|
||
|
|
6c4f1694ba
|
||
|
|
0a84ad1595
|
||
|
|
43b4334971
|
||
|
|
07f905d7c9
|
||
|
|
72f3bb7723
|
||
|
|
fd7409fe09
|
||
|
|
e8617b8042
|
||
|
|
824cdbe2b0
|
||
|
|
7941779451
|
||
|
|
f8a27f868c
|
||
|
|
cabefe3186
|
||
|
|
4d776c5c16
|
||
|
|
91179199f7
|
||
|
|
c1acc9f16b
|
||
|
|
a40bf9a18e
|
||
|
|
fd0b82ab99
|
||
|
|
1720cd6c36
|
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.git
|
||||
.gitignore
|
||||
.next
|
||||
dist
|
||||
.env
|
||||
*.log
|
||||
48
.env.example
Normal file
48
.env.example
Normal file
@@ -0,0 +1,48 @@
|
||||
# Global
|
||||
NODE_ENV=development
|
||||
|
||||
# Backend
|
||||
BACKEND_PORT=3001
|
||||
|
||||
# Frontend
|
||||
FRONTEND_PORT=3000
|
||||
|
||||
# Database (PostgreSQL)
|
||||
POSTGRES_HOST=db
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=app
|
||||
POSTGRES_USER=app
|
||||
POSTGRES_PASSWORD=app
|
||||
|
||||
# Redis
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
|
||||
# Storage (S3/MinIO)
|
||||
S3_ENDPOINT=s3
|
||||
S3_PORT=9000
|
||||
S3_ACCESS_KEY=minioadmin
|
||||
S3_SECRET_KEY=minioadmin
|
||||
S3_BUCKET_NAME=memegoat
|
||||
|
||||
# Security
|
||||
JWT_SECRET=super-secret-jwt-key-change-me-in-prod
|
||||
ENCRYPTION_KEY=01234567890123456789012345678901
|
||||
PGP_ENCRYPTION_KEY=super-secret-pgp-key
|
||||
SESSION_PASSWORD=super-secret-session-password-32-chars
|
||||
|
||||
# Mail
|
||||
MAIL_HOST=mail
|
||||
MAIL_PORT=1025
|
||||
MAIL_SECURE=false
|
||||
MAIL_USER=
|
||||
MAIL_PASS=
|
||||
MAIL_FROM=noreply@memegoat.local
|
||||
DOMAIN_NAME=localhost
|
||||
|
||||
ENABLE_CORS=false
|
||||
CORS_DOMAIN_NAME=localhost
|
||||
|
||||
# Media Limits (in KB)
|
||||
MAX_IMAGE_SIZE_KB=512
|
||||
MAX_GIF_SIZE_KB=1024
|
||||
108
.gitea/workflows/ci.yml
Normal file
108
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,108 @@
|
||||
# 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 }}
|
||||
@@ -1,25 +0,0 @@
|
||||
name: Backend Lint
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'backend/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'backend/**'
|
||||
|
||||
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: Run lint
|
||||
run: pnpm -F @memegoat/backend lint
|
||||
@@ -1,25 +0,0 @@
|
||||
name: Frontend Lint
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
|
||||
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: Run lint
|
||||
run: pnpm -F @memegoat/frontend lint
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
.pnpm-store
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
75
README.md
75
README.md
@@ -8,13 +8,15 @@
|
||||
<div align="center">
|
||||
<a href="https://git.yidhra.fr/Mathis/memegoat/src/branch/dev/LICENSE">
|
||||
<img src="https://img.shields.io/badge/License-AGPL3.0-green" alt="License">
|
||||
</a>
|
||||
<a href="https://git.yidhra.fr/Mathis/memegoat/commits">
|
||||
<img src="https://img.shields.io/badge/Status-Ongoing-blue" alt="Commits">
|
||||
</a>
|
||||
<a href="https://memegoat.fr?ref=git">
|
||||
<a href="https://memegoat.fr">
|
||||
<img src="https://img.shields.io/badge/Visit-memegoat.fr-orange" alt="Visit memegoat.fr">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p align="center">
|
||||
<a href="#">
|
||||
@@ -28,63 +30,64 @@
|
||||
|
||||
# 🐐 Memegoat
|
||||
|
||||
Lorem ipsum dolor sit amet
|
||||
Memegoat est une plateforme moderne de partage et de création de mèmes, conçue avec une architecture robuste et sécurisée.
|
||||
|
||||
_This repository is in development, and we’re still integrating core feature into the mono repo. It's not fully ready for self-hosted deployment yet, but you can run it locally._
|
||||
_Ce dépôt est en cours de développement. Nous intégrons actuellement les fonctionnalités clés dans le monorepo. Il n'est pas encore totalement prêt pour un déploiement auto-hébergé simplifié, mais vous pouvez le lancer localement._
|
||||
|
||||
## What is Memegoat ?
|
||||
## Qu'est-ce que Memegoat ?
|
||||
|
||||
[Firecrawl](https://memegoat.fr?ref=git) Lorem ipsum dolor sit amet. Check out our [documentation](https://docs.memegoat.fr).
|
||||
[Memegoat](https://memegoat.fr) est votre destination ultime pour découvrir, créer et partager les meilleurs mèmes du web. Notre plateforme se concentre sur la performance, la sécurité des données et une expérience utilisateur fluide.
|
||||
|
||||
Lorem ipsum dolor sit amet
|
||||
Retrouvez notre documentation complète sur : [docs.memegoat.fr](https://docs.memegoat.fr)
|
||||
|
||||
_Pst. hey, you, join our stargazers :)_
|
||||
## Architecture & Stack Technique
|
||||
|
||||
## How to use it?
|
||||
Le projet est structuré en monorepo :
|
||||
|
||||
Lorem ipsum dolor sit amet. You can also self host if you'd like.
|
||||
- **Frontend** : Next.js avec Tailwind CSS et Shadcn/ui.
|
||||
- **Backend** : NestJS (TypeScript) avec PostgreSQL.
|
||||
- **Base de données** : Drizzle ORM avec chiffrement natif PGP pour les données sensibles.
|
||||
- **Infrastructure** : Docker, Caddy (Reverse Proxy & TLS), stockage compatible S3.
|
||||
|
||||
Check out the following resources to get started:
|
||||
- **API**: [Documentation](#)
|
||||
- **Data Model**: [MLD/LDM](#), [MCD/CDM](#), [MPD/PDM](#)
|
||||
- **Technical choices**: [The stack](#), [Security choices](#), [Docker](#)
|
||||
## Documentation Rapide
|
||||
|
||||
To run locally, refer to guide [here](#).
|
||||
Pour approfondir vos connaissances techniques sur le projet :
|
||||
- **[Modèle de Données](https://docs.memegoat.fr/docs/database)** : MCD, MLD et MPD.
|
||||
- **[Sécurité](https://docs.memegoat.fr/docs/security)** : Chiffrement PGP, Argon2id, RBAC.
|
||||
- **[Conformité RGPD](https://docs.memegoat.fr/docs/compliance)** : Mesures techniques et droits des utilisateurs.
|
||||
- **[API & Intégrations](https://docs.memegoat.fr/docs/api)** : Authentification par sessions, clés API et 2FA.
|
||||
|
||||
### API Key
|
||||
## Comment l'utiliser ?
|
||||
|
||||
To use the API, you need to sign up on [Memegoat](https://memegoat.fr) and get an API key.
|
||||
### Installation locale
|
||||
|
||||
### Features
|
||||
1. Clonez le dépôt.
|
||||
2. Installez les dépendances avec `pnpm install`.
|
||||
3. Configurez les variables d'environnement (voir `.env.example`).
|
||||
4. Lancez les services via Docker ou manuellement.
|
||||
|
||||
- [**Blank**](#anchor): lorem ipsum
|
||||
### Clés API
|
||||
|
||||
### Powerful Capabilities
|
||||
- **The hard stuff**: proxies, anti-bot mechanisms, dynamic content (js-rendered), output parsing, orchestration
|
||||
-
|
||||
### anchor
|
||||
Pour utiliser l'API, vous pouvez générer des clés API sécurisées directement depuis votre profil sur [memegoat.fr](https://memegoat.fr).
|
||||
|
||||
lorem ipsum
|
||||
## Fonctionnalités Clés
|
||||
|
||||
## Contributing
|
||||
- **Sécurité Avancée** : Chiffrement des données personnelles au repos et hachage aveugle pour la recherche.
|
||||
- **RGPD by Design** : Mécanismes de Soft Delete, purge automatique et hachage des IPs.
|
||||
- **Multi-Authentification** : Support des sessions JWT, des clés API et de la double authentification (2FA).
|
||||
- **Gestion de Contenu** : Support des mèmes et GIFs avec système de tags et signalements.
|
||||
- **Traitement Médias Sécurisé** : Scan antivirus (ClamAV) systématique et transcodage haute performance (WebP, WebM, AVIF, AV1).
|
||||
|
||||
We love contributions! Please read our [contributing guide](CONTRIBUTING.md) before submitting a pull request. If you'd like to self-host, refer to the [self-hosting guide](SELF_HOST.md).
|
||||
## Contribution
|
||||
|
||||
## License Disclaimer
|
||||
Les contributions sont les bienvenues ! Veuillez consulter notre guide de contribution avant de soumettre une pull request.
|
||||
|
||||
This project is primarily licensed under the GNU Affero General Public License v3.0 (AGPL-3.0), as specified in the LICENSE file in the root directory of this repository. However, certain components of this project are licensed under the MIT License. Refer to the LICENSE files in these specific directories for details.
|
||||
|
||||
Please note:
|
||||
|
||||
- The AGPL-3.0 license applies to all parts of the project unless otherwise specified.
|
||||
- The SDKs and some UI components are licensed under the MIT License. Refer to the LICENSE files in these specific directories for details.
|
||||
- When using or contributing to this project, ensure you comply with the appropriate license terms for the specific component you are working with.
|
||||
|
||||
For more details on the licensing of specific components, please refer to the LICENSE files in the respective directories or contact the project maintainers.
|
||||
## Licence
|
||||
|
||||
Ce projet est principalement sous licence **GNU Affero General Public License v3.0 (AGPL-3.0)**. Certains composants, comme les SDKs, peuvent être sous licence MIT. Veuillez vous référer aux fichiers `LICENSE` dans les répertoires respectifs pour plus de détails.
|
||||
|
||||
<p align="right" style="font-size: 14px; color: #555; margin-top: 20px;">
|
||||
<a href="#readme-top" style="text-decoration: none; color: #007bff; font-weight: bold;">
|
||||
↑ Back to Top ↑
|
||||
↑ Retour en haut ↑
|
||||
</a>
|
||||
</p>
|
||||
50
ROADMAP.md
Normal file
50
ROADMAP.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 🐐 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
Normal file
756
backend.plantuml
Normal file
@@ -0,0 +1,756 @@
|
||||
@startuml
|
||||
|
||||
!theme plain
|
||||
top to bottom direction
|
||||
skinparam linetype ortho
|
||||
|
||||
class AdminController {
|
||||
constructor(adminService: AdminService):
|
||||
getStats(): Promise<{users: number, contents: numbe…
|
||||
}
|
||||
class AdminModule
|
||||
class AdminService {
|
||||
constructor(usersRepository: UsersRepository, contentsRepository: ContentsRepository, categoriesRepository: CategoriesRepository):
|
||||
getStats(): Promise<{users: number, contents: numbe…
|
||||
}
|
||||
class AllExceptionsFilter {
|
||||
logger: Logger
|
||||
catch(exception: unknown, host: ArgumentsHost): void
|
||||
}
|
||||
class ApiKeysController {
|
||||
constructor(apiKeysService: ApiKeysService):
|
||||
create(req: AuthenticatedRequest, createApiKeyDto: CreateApiKeyDto): Promise<{name: string, key: string, exp…
|
||||
findAll(req: AuthenticatedRequest): Promise<any>
|
||||
revoke(req: AuthenticatedRequest, id: string): Promise<any>
|
||||
}
|
||||
class ApiKeysModule
|
||||
class ApiKeysRepository {
|
||||
constructor(databaseService: DatabaseService):
|
||||
create(data: {userId: string; name: string; prefix: string; keyHash: string; expiresAt?: Date}): Promise<any>
|
||||
findAll(userId: string): Promise<any>
|
||||
revoke(userId: string, keyId: string): Promise<any>
|
||||
findActiveByKeyHash(keyHash: string): Promise<any>
|
||||
updateLastUsed(id: string): Promise<any>
|
||||
}
|
||||
class ApiKeysService {
|
||||
constructor(apiKeysRepository: ApiKeysRepository, hashingService: HashingService):
|
||||
logger: Logger
|
||||
create(userId: string, name: string, expiresAt?: Date): Promise<{name: string, key: string, exp…
|
||||
findAll(userId: string): Promise<any>
|
||||
revoke(userId: string, keyId: string): Promise<any>
|
||||
validateKey(key: string): Promise<any>
|
||||
}
|
||||
class AppController {
|
||||
constructor(appService: AppService):
|
||||
getHello(): string
|
||||
}
|
||||
class AppModule {
|
||||
configure(consumer: MiddlewareConsumer): void
|
||||
}
|
||||
class AppService {
|
||||
getHello(): string
|
||||
}
|
||||
class AuditLogInDb
|
||||
class AuthController {
|
||||
constructor(authService: AuthService, bootstrapService: BootstrapService, configService: ConfigService):
|
||||
register(registerDto: RegisterDto): Promise<{message: string, userId: any}>
|
||||
login(loginDto: LoginDto, userAgent: string, req: Request, res: Response): Promise<Response<any, Record<string, an…
|
||||
verifyTwoFactor(verify2faDto: Verify2faDto, userAgent: string, req: Request, res: Response): Promise<Response<any, Record<string, an…
|
||||
refresh(req: Request, res: Response): Promise<Response<any, Record<string, an…
|
||||
logout(req: Request, res: Response): Promise<Response<any, Record<string, an…
|
||||
bootstrapAdmin(token: string, username: string): Promise<{message: string}>
|
||||
}
|
||||
class AuthGuard {
|
||||
constructor(jwtService: JwtService, configService: ConfigService):
|
||||
canActivate(context: ExecutionContext): Promise<boolean>
|
||||
}
|
||||
class AuthModule
|
||||
class AuthService {
|
||||
constructor(usersService: UsersService, hashingService: HashingService, jwtService: JwtService, sessionsService: SessionsService, configService: ConfigService):
|
||||
logger: Logger
|
||||
generateTwoFactorSecret(userId: string): Promise<{secret: string, qrCodeDataUrl:…
|
||||
enableTwoFactor(userId: string, token: string): Promise<{message: string}>
|
||||
disableTwoFactor(userId: string, token: string): Promise<{message: string}>
|
||||
register(dto: RegisterDto): Promise<{message: string, userId: any}>
|
||||
login(dto: LoginDto, userAgent?: string, ip?: string): Promise<{message: string, requires2FA: …
|
||||
verifyTwoFactorLogin(userId: string, token: string, userAgent?: string, ip?: string): Promise<{message: string, access_token:…
|
||||
refresh(refreshToken: string): Promise<{access_token: string, refresh_…
|
||||
logout(): Promise<{message: string}>
|
||||
}
|
||||
class AuthenticatedRequest {
|
||||
user: {sub: string, username: string}
|
||||
}
|
||||
class BootstrapService {
|
||||
constructor(rbacService: RbacService, usersService: UsersService, configService: ConfigService):
|
||||
logger: Logger
|
||||
bootstrapToken: string | null
|
||||
onApplicationBootstrap(): Promise<void>
|
||||
generateBootstrapToken(): void
|
||||
consumeToken(token: string, username: string): Promise<{message: string}>
|
||||
}
|
||||
class CategoriesController {
|
||||
constructor(categoriesService: CategoriesService):
|
||||
findAll(): Promise<any>
|
||||
findOne(id: string): Promise<any>
|
||||
create(createCategoryDto: CreateCategoryDto): Promise<any>
|
||||
update(id: string, updateCategoryDto: UpdateCategoryDto): Promise<any>
|
||||
remove(id: string): Promise<any>
|
||||
}
|
||||
class CategoriesModule
|
||||
class CategoriesRepository {
|
||||
constructor(databaseService: DatabaseService):
|
||||
findAll(): Promise<any>
|
||||
countAll(): Promise<number>
|
||||
findOne(id: string): Promise<any>
|
||||
create(data: CreateCategoryDto & {slug: string}): Promise<any>
|
||||
update(id: string, data: UpdateCategoryDto & {slug?: string; updatedAt: Date}): Promise<any>
|
||||
remove(id: string): Promise<any>
|
||||
}
|
||||
class CategoriesService {
|
||||
constructor(categoriesRepository: CategoriesRepository, cacheManager: Cache):
|
||||
logger: Logger
|
||||
clearCategoriesCache(): Promise<void>
|
||||
findAll(): Promise<any>
|
||||
findOne(id: string): Promise<any>
|
||||
create(data: CreateCategoryDto): Promise<any>
|
||||
update(id: string, data: UpdateCategoryDto): Promise<any>
|
||||
remove(id: string): Promise<any>
|
||||
}
|
||||
class CategoryInDb
|
||||
class ClamScanner {
|
||||
scanStream(stream: Readable): Promise<{isInfected: boolean, viruses: …
|
||||
}
|
||||
class CommonModule
|
||||
class ContentInDb
|
||||
class ContentType {
|
||||
MEME:
|
||||
GIF:
|
||||
}
|
||||
class ContentsController {
|
||||
constructor(contentsService: ContentsService):
|
||||
create(req: AuthenticatedRequest, createContentDto: CreateContentDto): Promise<any>
|
||||
getUploadUrl(req: AuthenticatedRequest, fileName: string): Promise<{url: string, key: string}>
|
||||
upload(req: AuthenticatedRequest, file: Express.Multer.File, uploadContentDto: UploadContentDto): Promise<any>
|
||||
explore(req: AuthenticatedRequest, limit: number, offset: number, sort?: "trend" | "recent", tag?: string, category?: string, author?: string): Promise<{data: any, totalCount: any}>
|
||||
trends(req: AuthenticatedRequest, limit: number, offset: number): Promise<{data: any, totalCount: any}>
|
||||
recent(req: AuthenticatedRequest, limit: number, offset: number): Promise<{data: any, totalCount: any}>
|
||||
findOne(idOrSlug: string, req: AuthenticatedRequest, res: Response): Promise<Response<any, Record<string, an…
|
||||
incrementViews(id: string): Promise<void>
|
||||
incrementUsage(id: string): Promise<void>
|
||||
update(id: string, req: AuthenticatedRequest, updateContentDto: any): Promise<any>
|
||||
remove(id: string, req: AuthenticatedRequest): Promise<any>
|
||||
removeAdmin(id: string): Promise<any>
|
||||
updateAdmin(id: string, updateContentDto: any): Promise<any>
|
||||
}
|
||||
class ContentsModule
|
||||
class ContentsRepository {
|
||||
constructor(databaseService: DatabaseService):
|
||||
findAll(options: FindAllOptions): Promise<any>
|
||||
create(data: NewContentInDb & {userId: string}, tagNames?: string[]): Promise<any>
|
||||
findOne(idOrSlug: string, userId?: string): Promise<any>
|
||||
count(options: {tag?: string; category?: string; author?: string; query?: string; favoritesOnly?: boolean; userId?: string}): Promise<number>
|
||||
incrementViews(id: string): Promise<void>
|
||||
incrementUsage(id: string): Promise<void>
|
||||
softDelete(id: string, userId: string): Promise<any>
|
||||
softDeleteAdmin(id: string): Promise<any>
|
||||
update(id: string, data: Partial<typeof contents.$inferInsert>): Promise<any>
|
||||
findBySlug(slug: string): Promise<any>
|
||||
purgeSoftDeleted(before: Date): Promise<any>
|
||||
}
|
||||
class ContentsService {
|
||||
constructor(contentsRepository: ContentsRepository, s3Service: IStorageService, mediaService: IMediaService, configService: ConfigService, cacheManager: Cache):
|
||||
logger: Logger
|
||||
clearContentsCache(): Promise<void>
|
||||
getUploadUrl(userId: string, fileName: string): Promise<{url: string, key: string}>
|
||||
uploadAndProcess(userId: string, file: Express.Multer.File, data: UploadContentDto): Promise<any>
|
||||
findAll(options: {limit: number; offset: number; sortBy?: "trend" | "recent"; tag?: string; category?: string; author?: string; query?: string; favoritesOnly?: boolean; userId?: string}): Promise<{data: any, totalCount: any}>
|
||||
create(userId: string, data: CreateContentDto): Promise<any>
|
||||
incrementViews(id: string): Promise<void>
|
||||
incrementUsage(id: string): Promise<void>
|
||||
remove(id: string, userId: string): Promise<any>
|
||||
removeAdmin(id: string): Promise<any>
|
||||
updateAdmin(id: string, data: any): Promise<any>
|
||||
update(id: string, userId: string, data: any): Promise<any>
|
||||
findOne(idOrSlug: string, userId?: string): Promise<any>
|
||||
generateBotHtml(content: {title: string; storageKey: string}): string
|
||||
generateSlug(text: string): string
|
||||
ensureUniqueSlug(title: string): Promise<string>
|
||||
}
|
||||
class CrawlerDetectionMiddleware {
|
||||
logger: Logger
|
||||
SUSPICIOUS_PATTERNS: RegExp[]
|
||||
BOT_USER_AGENTS: RegExp[]
|
||||
use(req: Request, res: Response, next: NextFunction): void
|
||||
}
|
||||
class CreateApiKeyDto {
|
||||
name: string
|
||||
expiresAt: string
|
||||
}
|
||||
class CreateCategoryDto {
|
||||
name: string
|
||||
description: string
|
||||
iconUrl: string
|
||||
}
|
||||
class CreateContentDto {
|
||||
type: "meme" | "gif"
|
||||
title: string
|
||||
storageKey: string
|
||||
mimeType: string
|
||||
fileSize: number
|
||||
categoryId: string
|
||||
tags: string[]
|
||||
}
|
||||
class CreateReportDto {
|
||||
contentId: string
|
||||
tagId: string
|
||||
reason: "inappropriate" | "spam" | "copyright" …
|
||||
description: string
|
||||
}
|
||||
class CryptoModule
|
||||
class CryptoService {
|
||||
constructor(hashingService: HashingService, jwtService: JwtService, encryptionService: EncryptionService, postQuantumService: PostQuantumService):
|
||||
hashEmail(email: string): Promise<string>
|
||||
hashIp(ip: string): Promise<string>
|
||||
getPgpEncryptionKey(): string
|
||||
hashPassword(password: string): Promise<string>
|
||||
verifyPassword(password: string, hash: string): Promise<boolean>
|
||||
generateJwt(payload: jose.JWTPayload, expiresIn?: string): Promise<string>
|
||||
verifyJwt(token: string): Promise<T>
|
||||
encryptContent(content: string): Promise<string>
|
||||
decryptContent(jwe: string): Promise<string>
|
||||
signContent(content: string): Promise<string>
|
||||
verifyContentSignature(jws: string): Promise<string>
|
||||
generatePostQuantumKeyPair(): {publicKey: Uint8Array<ArrayBufferLike>…
|
||||
encapsulate(publicKey: Uint8Array): {cipherText: Uint8Array, sharedSecret: …
|
||||
decapsulate(cipherText: Uint8Array, secretKey: Uint8Array): Uint8Array<ArrayBufferLike>
|
||||
}
|
||||
class DatabaseModule
|
||||
class DatabaseService {
|
||||
constructor(configService: ConfigService):
|
||||
logger: Logger
|
||||
pool: Pool
|
||||
db: ReturnType<typeof drizzle>
|
||||
onModuleInit(): Promise<void>
|
||||
onModuleDestroy(): Promise<void>
|
||||
getDatabaseConnectionString(): string
|
||||
}
|
||||
class EncryptionService {
|
||||
constructor(configService: ConfigService):
|
||||
logger: Logger
|
||||
jwtSecret: Uint8Array
|
||||
encryptionKey: Uint8Array
|
||||
encryptContent(content: string): Promise<string>
|
||||
decryptContent(jwe: string): Promise<string>
|
||||
signContent(content: string): Promise<string>
|
||||
verifyContentSignature(jws: string): Promise<string>
|
||||
getPgpEncryptionKey(): string
|
||||
}
|
||||
class Env
|
||||
class FavoriteInDb
|
||||
class FavoritesController {
|
||||
constructor(favoritesService: FavoritesService):
|
||||
add(req: AuthenticatedRequest, contentId: string): Promise<any>
|
||||
remove(req: AuthenticatedRequest, contentId: string): Promise<any>
|
||||
list(req: AuthenticatedRequest, limit: number, offset: number): Promise<any>
|
||||
}
|
||||
class FavoritesModule
|
||||
class FavoritesRepository {
|
||||
constructor(databaseService: DatabaseService):
|
||||
findContentById(contentId: string): Promise<any>
|
||||
add(userId: string, contentId: string): Promise<any>
|
||||
remove(userId: string, contentId: string): Promise<any>
|
||||
findByUserId(userId: string, limit: number, offset: number): Promise<any>
|
||||
}
|
||||
class FavoritesService {
|
||||
constructor(favoritesRepository: FavoritesRepository):
|
||||
logger: Logger
|
||||
addFavorite(userId: string, contentId: string): Promise<any>
|
||||
removeFavorite(userId: string, contentId: string): Promise<any>
|
||||
getUserFavorites(userId: string, limit: number, offset: number): Promise<any>
|
||||
}
|
||||
class FindAllOptions {
|
||||
limit: number
|
||||
offset: number
|
||||
sortBy: "trend" | "recent"
|
||||
tag: string
|
||||
category: string
|
||||
author: string
|
||||
query: string
|
||||
favoritesOnly: boolean
|
||||
userId: string
|
||||
}
|
||||
class HTTPLoggerMiddleware {
|
||||
logger: Logger
|
||||
use(request: Request, response: Response, next: NextFunction): void
|
||||
}
|
||||
class HashingService {
|
||||
hashEmail(email: string): Promise<string>
|
||||
hashIp(ip: string): Promise<string>
|
||||
hashSha256(text: string): Promise<string>
|
||||
hashPassword(password: string): Promise<string>
|
||||
verifyPassword(password: string, hash: string): Promise<boolean>
|
||||
}
|
||||
class HealthController {
|
||||
constructor(databaseService: DatabaseService, cacheManager: Cache):
|
||||
check(): Promise<any>
|
||||
}
|
||||
class IMailService {
|
||||
sendEmailValidation(email: string, token: string): Promise<void>
|
||||
sendPasswordReset(email: string, token: string): Promise<void>
|
||||
}
|
||||
class IMediaProcessorStrategy {
|
||||
canHandle(mimeType: string): boolean
|
||||
process(buffer: Buffer, options?: Record<string, unknown>): Promise<MediaProcessingResult>
|
||||
}
|
||||
class IMediaService {
|
||||
scanFile(buffer: Buffer, filename: string): Promise<ScanResult>
|
||||
processImage(buffer: Buffer, format?: "webp" | "avif", resize?: {width?: number; height?: number}): Promise<MediaProcessingResult>
|
||||
processVideo(buffer: Buffer, format?: "webm" | "av1"): Promise<MediaProcessingResult>
|
||||
}
|
||||
class IStorageService {
|
||||
uploadFile(fileName: string, file: Buffer, mimeType: string, metaData?: Record<string, string>, bucketName?: string): Promise<string>
|
||||
getFile(fileName: string, bucketName?: string): Promise<Readable>
|
||||
getFileUrl(fileName: string, expiry?: number, bucketName?: string): Promise<string>
|
||||
getUploadUrl(fileName: string, expiry?: number, bucketName?: string): Promise<string>
|
||||
deleteFile(fileName: string, bucketName?: string): Promise<void>
|
||||
getFileInfo(fileName: string, bucketName?: string): Promise<unknown>
|
||||
moveFile(sourceFileName: string, destinationFileName: string, sourceBucketName?: string, destinationBucketName?: string): Promise<string>
|
||||
getPublicUrl(storageKey: string): string
|
||||
}
|
||||
class ImageProcessorStrategy {
|
||||
logger: Logger
|
||||
canHandle(mimeType: string): boolean
|
||||
process(buffer: Buffer, options?: {format: "webp" | "avif"; resize?: {width?: number; height?: number}}): Promise<MediaProcessingResult>
|
||||
}
|
||||
class JwtService {
|
||||
constructor(configService: ConfigService):
|
||||
logger: Logger
|
||||
jwtSecret: Uint8Array
|
||||
generateJwt(payload: jose.JWTPayload, expiresIn?: string): Promise<string>
|
||||
verifyJwt(token: string): Promise<T>
|
||||
}
|
||||
class LoginDto {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
class MailModule
|
||||
class MailService {
|
||||
constructor(mailerService: MailerService, configService: ConfigService):
|
||||
logger: Logger
|
||||
domain: string
|
||||
sendEmailValidation(email: string, token: string): Promise<void>
|
||||
sendPasswordReset(email: string, token: string): Promise<void>
|
||||
}
|
||||
class MediaController {
|
||||
constructor(s3Service: S3Service):
|
||||
logger: Logger
|
||||
getFile(path: string, res: Response): Promise<void>
|
||||
}
|
||||
class MediaModule
|
||||
class MediaProcessingResult {
|
||||
buffer: Buffer
|
||||
mimeType: string
|
||||
extension: string
|
||||
width: number
|
||||
height: number
|
||||
size: number
|
||||
}
|
||||
class MediaProcessingResult {
|
||||
buffer: Buffer
|
||||
mimeType: string
|
||||
extension: string
|
||||
width: number
|
||||
height: number
|
||||
size: number
|
||||
}
|
||||
class MediaService {
|
||||
constructor(configService: ConfigService, imageProcessor: ImageProcessorStrategy, videoProcessor: VideoProcessorStrategy):
|
||||
logger: Logger
|
||||
clamscan: ClamScanner | null
|
||||
isClamAvInitialized: boolean
|
||||
initClamScan(): Promise<void>
|
||||
scanFile(buffer: Buffer, filename: string): Promise<ScanResult>
|
||||
processImage(buffer: Buffer, format?: "webp" | "avif", resize?: {width?: number; height?: number}): Promise<MediaProcessingResult>
|
||||
processVideo(buffer: Buffer, format?: "webm" | "av1"): Promise<MediaProcessingResult>
|
||||
}
|
||||
class NewAuditLogInDb
|
||||
class NewCategoryInDb
|
||||
class NewContentInDb
|
||||
class NewFavoriteInDb
|
||||
class NewReportInDb
|
||||
class NewTagInDb
|
||||
class NewUserInDb
|
||||
class OptionalAuthGuard {
|
||||
constructor(jwtService: JwtService, configService: ConfigService):
|
||||
canActivate(context: ExecutionContext): Promise<boolean>
|
||||
}
|
||||
class PostQuantumService {
|
||||
generatePostQuantumKeyPair(): {publicKey: Uint8Array<ArrayBufferLike>…
|
||||
encapsulate(publicKey: Uint8Array): {cipherText: Uint8Array, sharedSecret: …
|
||||
decapsulate(cipherText: Uint8Array, secretKey: Uint8Array): Uint8Array<ArrayBufferLike>
|
||||
}
|
||||
class PurgeService {
|
||||
constructor(sessionsRepository: SessionsRepository, reportsRepository: ReportsRepository, usersRepository: UsersRepository, contentsRepository: ContentsRepository):
|
||||
logger: Logger
|
||||
purgeExpiredData(): Promise<void>
|
||||
}
|
||||
class RbacRepository {
|
||||
constructor(databaseService: DatabaseService):
|
||||
findRolesByUserId(userId: string): Promise<any>
|
||||
findPermissionsByUserId(userId: string): Promise<any[]>
|
||||
countRoles(): Promise<number>
|
||||
countAdmins(): Promise<number>
|
||||
createRole(name: string, slug: string, description?: string): Promise<any>
|
||||
assignRole(userId: string, roleSlug: string): Promise<any>
|
||||
}
|
||||
class RbacService {
|
||||
constructor(rbacRepository: RbacRepository):
|
||||
logger: Logger
|
||||
onApplicationBootstrap(): Promise<void>
|
||||
seedRoles(): Promise<void>
|
||||
getUserRoles(userId: string): Promise<any>
|
||||
getUserPermissions(userId: string): Promise<any[]>
|
||||
countAdmins(): Promise<number>
|
||||
assignRoleToUser(userId: string, roleSlug: string): Promise<any>
|
||||
}
|
||||
class RefreshDto {
|
||||
refresh_token: string
|
||||
}
|
||||
class RegisterDto {
|
||||
username: string
|
||||
displayName: string
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
class ReportInDb
|
||||
class ReportReason {
|
||||
INAPPROPRIATE:
|
||||
SPAM:
|
||||
COPYRIGHT:
|
||||
OTHER:
|
||||
}
|
||||
class ReportStatus {
|
||||
PENDING:
|
||||
REVIEWED:
|
||||
RESOLVED:
|
||||
DISMISSED:
|
||||
}
|
||||
class ReportsController {
|
||||
constructor(reportsService: ReportsService):
|
||||
create(req: AuthenticatedRequest, createReportDto: CreateReportDto): Promise<any>
|
||||
findAll(limit: number, offset: number): Promise<any>
|
||||
updateStatus(id: string, updateReportStatusDto: UpdateReportStatusDto): Promise<any>
|
||||
}
|
||||
class ReportsModule
|
||||
class ReportsRepository {
|
||||
constructor(databaseService: DatabaseService):
|
||||
create(data: {reporterId: string; contentId?: string; tagId?: string; reason: "inappropriate" | "spam" | "copyright" | "other"; description?: string}): Promise<any>
|
||||
findAll(limit: number, offset: number): Promise<any>
|
||||
updateStatus(id: string, status: "pending" | "reviewed" | "resolved" | "dismissed"): Promise<any>
|
||||
purgeObsolete(now: Date): Promise<any>
|
||||
}
|
||||
class ReportsService {
|
||||
constructor(reportsRepository: ReportsRepository):
|
||||
logger: Logger
|
||||
create(reporterId: string, data: CreateReportDto): Promise<any>
|
||||
findAll(limit: number, offset: number): Promise<any>
|
||||
updateStatus(id: string, status: "pending" | "reviewed" | "resolved" | "dismissed"): Promise<any>
|
||||
}
|
||||
class RequestWithUser {
|
||||
user: {sub?: string, username?: string, id?: …
|
||||
}
|
||||
class RolesGuard {
|
||||
constructor(reflector: Reflector, rbacService: RbacService):
|
||||
canActivate(context: ExecutionContext): Promise<boolean>
|
||||
}
|
||||
class S3Module
|
||||
class S3Service {
|
||||
constructor(configService: ConfigService):
|
||||
logger: Logger
|
||||
minioClient: Minio.Client
|
||||
bucketName: string
|
||||
onModuleInit(): Promise<void>
|
||||
ensureBucketExists(bucketName: string): Promise<void>
|
||||
uploadFile(fileName: string, file: Buffer, mimeType: string, metaData?: Minio.ItemBucketMetadata, bucketName?: string): Promise<string>
|
||||
getFile(fileName: string, bucketName?: string): Promise<stream.Readable>
|
||||
getFileUrl(fileName: string, expiry?: number, bucketName?: string): Promise<string>
|
||||
getUploadUrl(fileName: string, expiry?: number, bucketName?: string): Promise<string>
|
||||
deleteFile(fileName: string, bucketName?: string): Promise<void>
|
||||
getFileInfo(fileName: string, bucketName?: string): Promise<BucketItemStat>
|
||||
moveFile(sourceFileName: string, destinationFileName: string, sourceBucketName?: string, destinationBucketName?: string): Promise<string>
|
||||
getPublicUrl(storageKey: string): string
|
||||
}
|
||||
class ScanResult {
|
||||
isInfected: boolean
|
||||
virusName: string
|
||||
}
|
||||
class ScanResult {
|
||||
isInfected: boolean
|
||||
virusName: string
|
||||
}
|
||||
class SessionData {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
userId: string
|
||||
}
|
||||
class SessionsModule
|
||||
class SessionsRepository {
|
||||
constructor(databaseService: DatabaseService):
|
||||
create(data: {userId: string; refreshToken: string; userAgent?: string; ipHash?: string | null; expiresAt: Date}): Promise<any>
|
||||
findValidByRefreshToken(refreshToken: string): Promise<any>
|
||||
update(sessionId: string, data: Record<string, unknown>): Promise<any>
|
||||
revoke(sessionId: string): Promise<void>
|
||||
revokeAllByUserId(userId: string): Promise<void>
|
||||
purgeExpired(now: Date): Promise<any>
|
||||
}
|
||||
class SessionsService {
|
||||
constructor(sessionsRepository: SessionsRepository, hashingService: HashingService, jwtService: JwtService):
|
||||
createSession(userId: string, userAgent?: string, ip?: string): Promise<any>
|
||||
refreshSession(oldRefreshToken: string): Promise<any>
|
||||
revokeSession(sessionId: string): Promise<void>
|
||||
revokeAllUserSessions(userId: string): Promise<void>
|
||||
}
|
||||
class TagInDb
|
||||
class TagsController {
|
||||
constructor(tagsService: TagsService):
|
||||
findAll(limit: number, offset: number, query?: string, sort?: "popular" | "recent"): Promise<any>
|
||||
}
|
||||
class TagsModule
|
||||
class TagsRepository {
|
||||
constructor(databaseService: DatabaseService):
|
||||
findAll(options: {limit: number; offset: number; query?: string; sortBy?: "popular" | "recent"}): Promise<any>
|
||||
}
|
||||
class TagsService {
|
||||
constructor(tagsRepository: TagsRepository):
|
||||
logger: Logger
|
||||
findAll(options: {limit: number; offset: number; query?: string; sortBy?: "popular" | "recent"}): Promise<any>
|
||||
}
|
||||
class UpdateCategoryDto
|
||||
class UpdateConsentDto {
|
||||
termsVersion: string
|
||||
privacyVersion: string
|
||||
}
|
||||
class UpdateReportStatusDto {
|
||||
status: "pending" | "reviewed" | "resolved" | "…
|
||||
}
|
||||
class UpdateUserDto {
|
||||
displayName: string
|
||||
bio: string
|
||||
avatarUrl: string
|
||||
status: "active" | "verification" | "suspended"…
|
||||
role: string
|
||||
}
|
||||
class UploadContentDto {
|
||||
type: "meme" | "gif"
|
||||
title: string
|
||||
categoryId: string
|
||||
tags: string[]
|
||||
}
|
||||
class UserInDb
|
||||
class UsersController {
|
||||
constructor(usersService: UsersService, authService: AuthService):
|
||||
findAll(limit: number, offset: number): Promise<{data: any, totalCount: any}>
|
||||
findPublicProfile(username: string): Promise<any>
|
||||
findMe(req: AuthenticatedRequest): Promise<any>
|
||||
exportMe(req: AuthenticatedRequest): Promise<null | {profile: any, contents:…
|
||||
updateMe(req: AuthenticatedRequest, updateUserDto: UpdateUserDto): Promise<any>
|
||||
updateAvatar(req: AuthenticatedRequest, file: Express.Multer.File): Promise<any>
|
||||
updateConsent(req: AuthenticatedRequest, consentDto: UpdateConsentDto): Promise<any>
|
||||
removeMe(req: AuthenticatedRequest): Promise<any>
|
||||
removeAdmin(uuid: string): Promise<any>
|
||||
updateAdmin(uuid: string, updateUserDto: UpdateUserDto): Promise<any>
|
||||
setup2fa(req: AuthenticatedRequest): Promise<{secret: string, qrCodeDataUrl:…
|
||||
enable2fa(req: AuthenticatedRequest, token: string): Promise<{message: string}>
|
||||
disable2fa(req: AuthenticatedRequest, token: string): Promise<{message: string}>
|
||||
}
|
||||
class UsersModule
|
||||
class UsersRepository {
|
||||
constructor(databaseService: DatabaseService):
|
||||
create(data: {username: string; email: string; passwordHash: string; emailHash: string}): Promise<any>
|
||||
findByEmailHash(emailHash: string): Promise<any>
|
||||
findOneWithPrivateData(uuid: string): Promise<any>
|
||||
countAll(): Promise<number>
|
||||
findAll(limit: number, offset: number): Promise<any>
|
||||
findByUsername(username: string): Promise<any>
|
||||
findOne(uuid: string): Promise<any>
|
||||
update(uuid: string, data: Partial<typeof users.$inferInsert>): Promise<any>
|
||||
getTwoFactorSecret(uuid: string): Promise<any>
|
||||
getUserContents(uuid: string): Promise<any>
|
||||
getUserFavorites(uuid: string): Promise<any>
|
||||
softDeleteUserAndContents(uuid: string): Promise<any>
|
||||
purgeDeleted(before: Date): Promise<any>
|
||||
}
|
||||
class UsersService {
|
||||
constructor(usersRepository: UsersRepository, cacheManager: Cache, rbacService: RbacService, mediaService: IMediaService, s3Service: IStorageService):
|
||||
logger: Logger
|
||||
clearUserCache(username?: string): Promise<void>
|
||||
create(data: {username: string; email: string; passwordHash: string; emailHash: string}): Promise<any>
|
||||
findByEmailHash(emailHash: string): Promise<any>
|
||||
findOneWithPrivateData(uuid: string): Promise<any>
|
||||
findAll(limit: number, offset: number): Promise<{data: any, totalCount: any}>
|
||||
findPublicProfile(username: string): Promise<any>
|
||||
findOne(uuid: string): Promise<any>
|
||||
update(uuid: string, data: UpdateUserDto): Promise<any>
|
||||
updateAvatar(uuid: string, file: Express.Multer.File): Promise<any>
|
||||
updateConsent(uuid: string, termsVersion: string, privacyVersion: string): Promise<any>
|
||||
setTwoFactorSecret(uuid: string, secret: string): Promise<any>
|
||||
toggleTwoFactor(uuid: string, enabled: boolean): Promise<any>
|
||||
getTwoFactorSecret(uuid: string): Promise<string | null>
|
||||
exportUserData(uuid: string): Promise<null | {profile: any, contents:…
|
||||
remove(uuid: string): Promise<any>
|
||||
}
|
||||
class Verify2faDto {
|
||||
userId: string
|
||||
token: string
|
||||
}
|
||||
class VideoProcessorStrategy {
|
||||
logger: Logger
|
||||
canHandle(mimeType: string): boolean
|
||||
process(buffer: Buffer, options?: {format: "webm" | "av1"}): Promise<MediaProcessingResult>
|
||||
}
|
||||
|
||||
AdminController -[#595959,dashed]-> AdminService
|
||||
AdminService -[#595959,dashed]-> CategoriesRepository
|
||||
AdminService -[#595959,dashed]-> ContentsRepository
|
||||
AdminService -[#595959,dashed]-> UsersRepository
|
||||
AllExceptionsFilter -[#595959,dashed]-> RequestWithUser
|
||||
ApiKeysController -[#595959,dashed]-> ApiKeysService
|
||||
ApiKeysController -[#595959,dashed]-> AuthenticatedRequest
|
||||
ApiKeysController -[#595959,dashed]-> CreateApiKeyDto
|
||||
ApiKeysRepository -[#595959,dashed]-> DatabaseService
|
||||
ApiKeysService -[#595959,dashed]-> ApiKeysRepository
|
||||
ApiKeysService -[#595959,dashed]-> ApiKeysService
|
||||
ApiKeysService -[#595959,dashed]-> HashingService
|
||||
AppController -[#595959,dashed]-> AppService
|
||||
AppModule -[#595959,dashed]-> CrawlerDetectionMiddleware
|
||||
AppModule -[#595959,dashed]-> HTTPLoggerMiddleware
|
||||
AuthController -[#595959,dashed]-> AuthService
|
||||
AuthController -[#595959,dashed]-> BootstrapService
|
||||
AuthController -[#595959,dashed]-> LoginDto
|
||||
AuthController -[#595959,dashed]-> RegisterDto
|
||||
AuthController -[#595959,dashed]-> SessionData
|
||||
AuthController -[#595959,dashed]-> Verify2faDto
|
||||
AuthGuard -[#595959,dashed]-> JwtService
|
||||
AuthGuard -[#595959,dashed]-> SessionData
|
||||
AuthService -[#595959,dashed]-> AuthService
|
||||
AuthService -[#595959,dashed]-> HashingService
|
||||
AuthService -[#595959,dashed]-> JwtService
|
||||
AuthService -[#595959,dashed]-> LoginDto
|
||||
AuthService -[#595959,dashed]-> RegisterDto
|
||||
AuthService -[#595959,dashed]-> SessionsService
|
||||
AuthService -[#595959,dashed]-> UsersService
|
||||
BootstrapService -[#595959,dashed]-> BootstrapService
|
||||
BootstrapService -[#595959,dashed]-> RbacService
|
||||
BootstrapService -[#595959,dashed]-> UsersService
|
||||
CategoriesController -[#595959,dashed]-> AuthGuard
|
||||
CategoriesController -[#595959,dashed]-> CategoriesService
|
||||
CategoriesController -[#595959,dashed]-> CreateCategoryDto
|
||||
CategoriesController -[#595959,dashed]-> RolesGuard
|
||||
CategoriesController -[#595959,dashed]-> UpdateCategoryDto
|
||||
CategoriesRepository -[#595959,dashed]-> CreateCategoryDto
|
||||
CategoriesRepository -[#595959,dashed]-> DatabaseService
|
||||
CategoriesRepository -[#595959,dashed]-> UpdateCategoryDto
|
||||
CategoriesService -[#595959,dashed]-> CategoriesRepository
|
||||
CategoriesService -[#595959,dashed]-> CategoriesService
|
||||
CategoriesService -[#595959,dashed]-> CreateCategoryDto
|
||||
CategoriesService -[#595959,dashed]-> UpdateCategoryDto
|
||||
ContentsController -[#595959,dashed]-> AuthGuard
|
||||
ContentsController -[#595959,dashed]-> AuthenticatedRequest
|
||||
ContentsController -[#595959,dashed]-> ContentsService
|
||||
ContentsController -[#595959,dashed]-> CreateContentDto
|
||||
ContentsController -[#595959,dashed]-> OptionalAuthGuard
|
||||
ContentsController -[#595959,dashed]-> RolesGuard
|
||||
ContentsController -[#595959,dashed]-> UploadContentDto
|
||||
ContentsRepository -[#595959,dashed]-> DatabaseService
|
||||
ContentsRepository -[#595959,dashed]-> FindAllOptions
|
||||
ContentsRepository -[#595959,dashed]-> NewContentInDb
|
||||
ContentsService -[#595959,dashed]-> ContentsRepository
|
||||
ContentsService -[#595959,dashed]-> ContentsService
|
||||
ContentsService -[#595959,dashed]-> CreateContentDto
|
||||
ContentsService -[#595959,dashed]-> IMediaService
|
||||
ContentsService -[#595959,dashed]-> IStorageService
|
||||
ContentsService -[#595959,dashed]-> MediaProcessingResult
|
||||
ContentsService -[#595959,dashed]-> MediaService
|
||||
ContentsService -[#595959,dashed]-> S3Service
|
||||
ContentsService -[#595959,dashed]-> UploadContentDto
|
||||
CryptoService -[#595959,dashed]-> EncryptionService
|
||||
CryptoService -[#595959,dashed]-> HashingService
|
||||
CryptoService -[#595959,dashed]-> JwtService
|
||||
CryptoService -[#595959,dashed]-> PostQuantumService
|
||||
DatabaseService -[#595959,dashed]-> DatabaseService
|
||||
EncryptionService -[#595959,dashed]-> EncryptionService
|
||||
FavoritesController -[#595959,dashed]-> AuthenticatedRequest
|
||||
FavoritesController -[#595959,dashed]-> FavoritesService
|
||||
FavoritesRepository -[#595959,dashed]-> DatabaseService
|
||||
FavoritesService -[#595959,dashed]-> FavoritesRepository
|
||||
FavoritesService -[#595959,dashed]-> FavoritesService
|
||||
HealthController -[#595959,dashed]-> DatabaseService
|
||||
IMediaProcessorStrategy -[#595959,dashed]-> MediaProcessingResult
|
||||
IMediaService -[#595959,dashed]-> MediaProcessingResult
|
||||
IMediaService -[#595959,dashed]-> ScanResult
|
||||
ImageProcessorStrategy -[#008200,dashed]-^ IMediaProcessorStrategy
|
||||
ImageProcessorStrategy -[#595959,dashed]-> ImageProcessorStrategy
|
||||
ImageProcessorStrategy -[#595959,dashed]-> MediaProcessingResult
|
||||
JwtService -[#595959,dashed]-> JwtService
|
||||
MailService -[#008200,dashed]-^ IMailService
|
||||
MailService -[#595959,dashed]-> MailService
|
||||
MediaController -[#595959,dashed]-> MediaController
|
||||
MediaController -[#595959,dashed]-> S3Service
|
||||
MediaService -[#595959,dashed]-> ClamScanner
|
||||
MediaService -[#008200,dashed]-^ IMediaService
|
||||
MediaService -[#595959,dashed]-> ImageProcessorStrategy
|
||||
MediaService -[#595959,dashed]-> MediaProcessingResult
|
||||
MediaService -[#595959,dashed]-> MediaService
|
||||
MediaService -[#595959,dashed]-> ScanResult
|
||||
MediaService -[#595959,dashed]-> VideoProcessorStrategy
|
||||
OptionalAuthGuard -[#595959,dashed]-> JwtService
|
||||
OptionalAuthGuard -[#595959,dashed]-> SessionData
|
||||
PurgeService -[#595959,dashed]-> ContentsRepository
|
||||
PurgeService -[#595959,dashed]-> PurgeService
|
||||
PurgeService -[#595959,dashed]-> ReportsRepository
|
||||
PurgeService -[#595959,dashed]-> SessionsRepository
|
||||
PurgeService -[#595959,dashed]-> UsersRepository
|
||||
RbacRepository -[#595959,dashed]-> DatabaseService
|
||||
RbacService -[#595959,dashed]-> RbacRepository
|
||||
RbacService -[#595959,dashed]-> RbacService
|
||||
ReportsController -[#595959,dashed]-> AuthGuard
|
||||
ReportsController -[#595959,dashed]-> AuthenticatedRequest
|
||||
ReportsController -[#595959,dashed]-> CreateReportDto
|
||||
ReportsController -[#595959,dashed]-> ReportsService
|
||||
ReportsController -[#595959,dashed]-> RolesGuard
|
||||
ReportsController -[#595959,dashed]-> UpdateReportStatusDto
|
||||
ReportsRepository -[#595959,dashed]-> DatabaseService
|
||||
ReportsService -[#595959,dashed]-> CreateReportDto
|
||||
ReportsService -[#595959,dashed]-> ReportsRepository
|
||||
ReportsService -[#595959,dashed]-> ReportsService
|
||||
RolesGuard -[#595959,dashed]-> RbacService
|
||||
S3Service -[#008200,dashed]-^ IStorageService
|
||||
S3Service -[#595959,dashed]-> S3Service
|
||||
SessionsRepository -[#595959,dashed]-> DatabaseService
|
||||
SessionsService -[#595959,dashed]-> HashingService
|
||||
SessionsService -[#595959,dashed]-> JwtService
|
||||
SessionsService -[#595959,dashed]-> SessionsRepository
|
||||
TagsController -[#595959,dashed]-> TagsService
|
||||
TagsRepository -[#595959,dashed]-> DatabaseService
|
||||
TagsService -[#595959,dashed]-> TagsRepository
|
||||
TagsService -[#595959,dashed]-> TagsService
|
||||
UsersController -[#595959,dashed]-> AuthGuard
|
||||
UsersController -[#595959,dashed]-> AuthService
|
||||
UsersController -[#595959,dashed]-> AuthenticatedRequest
|
||||
UsersController -[#595959,dashed]-> RolesGuard
|
||||
UsersController -[#595959,dashed]-> UpdateConsentDto
|
||||
UsersController -[#595959,dashed]-> UpdateUserDto
|
||||
UsersController -[#595959,dashed]-> UsersService
|
||||
UsersRepository -[#595959,dashed]-> DatabaseService
|
||||
UsersService -[#595959,dashed]-> IMediaService
|
||||
UsersService -[#595959,dashed]-> IStorageService
|
||||
UsersService -[#595959,dashed]-> MediaService
|
||||
UsersService -[#595959,dashed]-> RbacService
|
||||
UsersService -[#595959,dashed]-> S3Service
|
||||
UsersService -[#595959,dashed]-> UpdateUserDto
|
||||
UsersService -[#595959,dashed]-> UsersRepository
|
||||
UsersService -[#595959,dashed]-> UsersService
|
||||
VideoProcessorStrategy -[#008200,dashed]-^ IMediaProcessorStrategy
|
||||
VideoProcessorStrategy -[#595959,dashed]-> MediaProcessingResult
|
||||
VideoProcessorStrategy -[#595959,dashed]-> VideoProcessorStrategy
|
||||
@enduml
|
||||
177
backend/.migrations/0000_right_sally_floyd.sql
Normal file
177
backend/.migrations/0000_right_sally_floyd.sql
Normal file
@@ -0,0 +1,177 @@
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
CREATE TYPE "public"."user_status" AS ENUM('active', 'verification', 'suspended', 'pending', 'deleted');--> statement-breakpoint
|
||||
CREATE TYPE "public"."content_type" AS ENUM('meme', 'gif');--> statement-breakpoint
|
||||
CREATE TYPE "public"."report_reason" AS ENUM('inappropriate', 'spam', 'copyright', 'other');--> statement-breakpoint
|
||||
CREATE TYPE "public"."report_status" AS ENUM('pending', 'reviewed', 'resolved', 'dismissed');--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"uuid" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"status" "user_status" DEFAULT 'pending' NOT NULL,
|
||||
"email" "bytea" NOT NULL,
|
||||
"email_hash" varchar(64) NOT NULL,
|
||||
"display_name" varchar(32),
|
||||
"username" varchar(32) NOT NULL,
|
||||
"password_hash" varchar(72) NOT NULL,
|
||||
"two_factor_secret" "bytea",
|
||||
"is_two_factor_enabled" boolean DEFAULT false NOT NULL,
|
||||
"terms_version" varchar(16),
|
||||
"privacy_version" varchar(16),
|
||||
"gdpr_accepted_at" timestamp with time zone,
|
||||
"last_login_at" timestamp with time zone,
|
||||
"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,
|
||||
CONSTRAINT "users_email_hash_unique" UNIQUE("email_hash"),
|
||||
CONSTRAINT "users_username_unique" UNIQUE("username")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "permissions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" varchar(64) NOT NULL,
|
||||
"slug" varchar(64) NOT NULL,
|
||||
"description" varchar(128),
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "permissions_name_unique" UNIQUE("name"),
|
||||
CONSTRAINT "permissions_slug_unique" UNIQUE("slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "roles" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" varchar(64) NOT NULL,
|
||||
"slug" varchar(64) NOT NULL,
|
||||
"description" varchar(128),
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "roles_name_unique" UNIQUE("name"),
|
||||
CONSTRAINT "roles_slug_unique" UNIQUE("slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "roles_to_permissions" (
|
||||
"role_id" uuid NOT NULL,
|
||||
"permission_id" uuid NOT NULL,
|
||||
CONSTRAINT "roles_to_permissions_role_id_permission_id_pk" PRIMARY KEY("role_id","permission_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users_to_roles" (
|
||||
"user_id" uuid NOT NULL,
|
||||
"role_id" uuid NOT NULL,
|
||||
CONSTRAINT "users_to_roles_user_id_role_id_pk" PRIMARY KEY("user_id","role_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sessions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"refresh_token" varchar(512) NOT NULL,
|
||||
"user_agent" varchar(255),
|
||||
"ip_hash" varchar(64),
|
||||
"is_valid" boolean DEFAULT true NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "sessions_refresh_token_unique" UNIQUE("refresh_token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "api_keys" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"key_hash" varchar(128) NOT NULL,
|
||||
"name" varchar(128) NOT NULL,
|
||||
"prefix" varchar(8) NOT NULL,
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"last_used_at" timestamp with time zone,
|
||||
"expires_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "api_keys_key_hash_unique" UNIQUE("key_hash")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "tags" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" varchar(64) NOT NULL,
|
||||
"slug" varchar(64) NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "tags_name_unique" UNIQUE("name"),
|
||||
CONSTRAINT "tags_slug_unique" UNIQUE("slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "contents" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"type" "content_type" NOT NULL,
|
||||
"title" varchar(255) NOT NULL,
|
||||
"storage_key" varchar(512) NOT NULL,
|
||||
"mime_type" varchar(128) NOT NULL,
|
||||
"file_size" integer 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,
|
||||
CONSTRAINT "contents_storage_key_unique" UNIQUE("storage_key")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "contents_to_tags" (
|
||||
"content_id" uuid NOT NULL,
|
||||
"tag_id" uuid NOT NULL,
|
||||
CONSTRAINT "contents_to_tags_content_id_tag_id_pk" PRIMARY KEY("content_id","tag_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "reports" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"reporter_id" uuid NOT NULL,
|
||||
"content_id" uuid,
|
||||
"tag_id" uuid,
|
||||
"reason" "report_reason" NOT NULL,
|
||||
"description" text,
|
||||
"status" "report_status" DEFAULT 'pending' NOT NULL,
|
||||
"expires_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "audit_logs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid,
|
||||
"action" varchar(64) NOT NULL,
|
||||
"entity_type" varchar(64) NOT NULL,
|
||||
"entity_id" uuid,
|
||||
"details" jsonb,
|
||||
"ip_hash" varchar(64),
|
||||
"user_agent" varchar(255),
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "roles_to_permissions" ADD CONSTRAINT "roles_to_permissions_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "roles_to_permissions" ADD CONSTRAINT "roles_to_permissions_permission_id_permissions_id_fk" FOREIGN KEY ("permission_id") REFERENCES "public"."permissions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "users_to_roles" ADD CONSTRAINT "users_to_roles_user_id_users_uuid_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "users_to_roles" ADD CONSTRAINT "users_to_roles_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_uuid_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_user_id_users_uuid_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "contents" ADD CONSTRAINT "contents_user_id_users_uuid_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "contents_to_tags" ADD CONSTRAINT "contents_to_tags_content_id_contents_id_fk" FOREIGN KEY ("content_id") REFERENCES "public"."contents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "contents_to_tags" ADD CONSTRAINT "contents_to_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "reports" ADD CONSTRAINT "reports_reporter_id_users_uuid_fk" FOREIGN KEY ("reporter_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "reports" ADD CONSTRAINT "reports_content_id_contents_id_fk" FOREIGN KEY ("content_id") REFERENCES "public"."contents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "reports" ADD CONSTRAINT "reports_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_user_id_users_uuid_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("uuid") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "users_uuid_idx" ON "users" USING btree ("uuid");--> statement-breakpoint
|
||||
CREATE INDEX "users_email_hash_idx" ON "users" USING btree ("email_hash");--> statement-breakpoint
|
||||
CREATE INDEX "users_username_idx" ON "users" USING btree ("username");--> statement-breakpoint
|
||||
CREATE INDEX "users_status_idx" ON "users" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX "permissions_slug_idx" ON "permissions" USING btree ("slug");--> statement-breakpoint
|
||||
CREATE INDEX "roles_slug_idx" ON "roles" USING btree ("slug");--> statement-breakpoint
|
||||
CREATE INDEX "sessions_user_id_idx" ON "sessions" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "sessions_refresh_token_idx" ON "sessions" USING btree ("refresh_token");--> statement-breakpoint
|
||||
CREATE INDEX "sessions_expires_at_idx" ON "sessions" USING btree ("expires_at");--> statement-breakpoint
|
||||
CREATE INDEX "api_keys_user_id_idx" ON "api_keys" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "api_keys_key_hash_idx" ON "api_keys" USING btree ("key_hash");--> statement-breakpoint
|
||||
CREATE INDEX "tags_slug_idx" ON "tags" USING btree ("slug");--> statement-breakpoint
|
||||
CREATE INDEX "contents_user_id_idx" ON "contents" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "contents_storage_key_idx" ON "contents" USING btree ("storage_key");--> statement-breakpoint
|
||||
CREATE INDEX "contents_deleted_at_idx" ON "contents" USING btree ("deleted_at");--> statement-breakpoint
|
||||
CREATE INDEX "reports_reporter_id_idx" ON "reports" USING btree ("reporter_id");--> statement-breakpoint
|
||||
CREATE INDEX "reports_content_id_idx" ON "reports" USING btree ("content_id");--> statement-breakpoint
|
||||
CREATE INDEX "reports_tag_id_idx" ON "reports" USING btree ("tag_id");--> statement-breakpoint
|
||||
CREATE INDEX "reports_status_idx" ON "reports" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX "reports_expires_at_idx" ON "reports" USING btree ("expires_at");--> statement-breakpoint
|
||||
CREATE INDEX "audit_logs_user_id_idx" ON "audit_logs" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "audit_logs_action_idx" ON "audit_logs" USING btree ("action");--> statement-breakpoint
|
||||
CREATE INDEX "audit_logs_entity_idx" ON "audit_logs" USING btree ("entity_type","entity_id");--> statement-breakpoint
|
||||
CREATE INDEX "audit_logs_created_at_idx" ON "audit_logs" USING btree ("created_at");
|
||||
30
backend/.migrations/0001_purple_goliath.sql
Normal file
30
backend/.migrations/0001_purple_goliath.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
CREATE TABLE "categories" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" varchar(64) NOT NULL,
|
||||
"slug" varchar(64) NOT NULL,
|
||||
"description" varchar(255),
|
||||
"icon_url" varchar(512),
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "categories_name_unique" UNIQUE("name"),
|
||||
CONSTRAINT "categories_slug_unique" UNIQUE("slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "favorites" (
|
||||
"user_id" uuid NOT NULL,
|
||||
"content_id" uuid NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "favorites_user_id_content_id_pk" PRIMARY KEY("user_id","content_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tags" ADD COLUMN "user_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "contents" ADD COLUMN "category_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "contents" ADD COLUMN "slug" varchar(255) NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "contents" ADD COLUMN "views" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "contents" ADD COLUMN "usage_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "favorites" ADD CONSTRAINT "favorites_user_id_users_uuid_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "favorites" ADD CONSTRAINT "favorites_content_id_contents_id_fk" FOREIGN KEY ("content_id") REFERENCES "public"."contents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "categories_slug_idx" ON "categories" USING btree ("slug");--> statement-breakpoint
|
||||
ALTER TABLE "tags" ADD CONSTRAINT "tags_user_id_users_uuid_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("uuid") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "contents" ADD CONSTRAINT "contents_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "contents" ADD CONSTRAINT "contents_slug_unique" UNIQUE("slug");
|
||||
1
backend/.migrations/0002_redundant_skin.sql
Normal file
1
backend/.migrations/0002_redundant_skin.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "users" ADD COLUMN "avatar_url" varchar(255);
|
||||
2
backend/.migrations/0003_colossal_fantastic_four.sql
Normal file
2
backend/.migrations/0003_colossal_fantastic_four.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "users" ALTER COLUMN "password_hash" SET DATA TYPE varchar(255);--> statement-breakpoint
|
||||
ALTER TABLE "users" DROP COLUMN "avatar_url";
|
||||
1
backend/.migrations/0004_cheerful_dakota_north.sql
Normal file
1
backend/.migrations/0004_cheerful_dakota_north.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "users" ALTER COLUMN "password_hash" SET DATA TYPE varchar(95);
|
||||
1
backend/.migrations/0005_perpetual_silverclaw.sql
Normal file
1
backend/.migrations/0005_perpetual_silverclaw.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "users" ALTER COLUMN "password_hash" SET DATA TYPE varchar(100);
|
||||
2
backend/.migrations/0006_friendly_adam_warlock.sql
Normal file
2
backend/.migrations/0006_friendly_adam_warlock.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "users" ADD COLUMN "avatar_url" varchar(512);--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "bio" varchar(255);
|
||||
1
backend/.migrations/0007_melodic_synch.sql
Normal file
1
backend/.migrations/0007_melodic_synch.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."content_type" ADD VALUE 'video';
|
||||
1316
backend/.migrations/meta/0000_snapshot.json
Normal file
1316
backend/.migrations/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1513
backend/.migrations/meta/0001_snapshot.json
Normal file
1513
backend/.migrations/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1519
backend/.migrations/meta/0002_snapshot.json
Normal file
1519
backend/.migrations/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1640
backend/.migrations/meta/0003_snapshot.json
Normal file
1640
backend/.migrations/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1640
backend/.migrations/meta/0004_snapshot.json
Normal file
1640
backend/.migrations/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1640
backend/.migrations/meta/0005_snapshot.json
Normal file
1640
backend/.migrations/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1652
backend/.migrations/meta/0006_snapshot.json
Normal file
1652
backend/.migrations/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1653
backend/.migrations/meta/0007_snapshot.json
Normal file
1653
backend/.migrations/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
62
backend/.migrations/meta/_journal.json
Normal file
62
backend/.migrations/meta/_journal.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1767618753676,
|
||||
"tag": "0000_right_sally_floyd",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1768392191169,
|
||||
"tag": "0001_purple_goliath",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1768393637823,
|
||||
"tag": "0002_redundant_skin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1768415667895,
|
||||
"tag": "0003_colossal_fantastic_four",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1768417827439,
|
||||
"tag": "0004_cheerful_dakota_north",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1768420201679,
|
||||
"tag": "0005_perpetual_silverclaw",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1768423315172,
|
||||
"tag": "0006_friendly_adam_warlock",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1769605995410,
|
||||
"tag": "0007_melodic_synch",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
51
backend/Dockerfile
Normal file
51
backend/Dockerfile
Normal file
@@ -0,0 +1,51 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM node:22-alpine AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
ENV FFMPEG_VERSION=3.0.2
|
||||
|
||||
WORKDIR /tmp/ffmpeg
|
||||
|
||||
RUN apk add --update build-base curl nasm tar bzip2 \
|
||||
zlib-dev openssl-dev yasm-dev lame-dev libogg-dev x264-dev libvpx-dev libvorbis-dev x265-dev freetype-dev libass-dev libwebp-dev rtmpdump-dev libtheora-dev opus-dev && \
|
||||
DIR=$(mktemp -d) && cd ${DIR} && \
|
||||
curl -L -s https://ffmpeg.org/releases/ffmpeg-${FFMPEG_VERSION}.tar.gz | tar zxvf - -C . && \
|
||||
cd ffmpeg-${FFMPEG_VERSION} && \
|
||||
./configure \
|
||||
--enable-version3 --enable-gpl --enable-nonfree --enable-small --enable-libmp3lame --enable-libx264 --enable-libx265 --enable-libvpx --enable-libtheora --enable-libvorbis --enable-libopus --enable-libass --enable-libwebp --enable-librtmp --enable-postproc --enable-avresample --enable-libfreetype --enable-openssl --disable-debug && \
|
||||
make && \
|
||||
make install && \
|
||||
make distclean && \
|
||||
rm -rf ${DIR} && \
|
||||
apk del build-base curl tar bzip2 x264 openssl nasm && rm -rf /var/cache/apk/*
|
||||
|
||||
FROM base AS build
|
||||
WORKDIR /usr/src/app
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY backend/package.json ./backend/
|
||||
COPY frontend/package.json ./frontend/
|
||||
COPY documentation/package.json ./documentation/
|
||||
|
||||
# Utilisation du cache pour pnpm et installation figée
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
# Deuxième passe avec cache pour les scripts/liens
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
RUN pnpm run --filter @memegoat/backend build
|
||||
RUN pnpm deploy --filter=@memegoat/backend --prod --legacy /app
|
||||
RUN cp -r backend/dist /app/dist
|
||||
RUN cp -r backend/.migrations /app/.migrations
|
||||
|
||||
FROM base AS runtime
|
||||
WORKDIR /app
|
||||
COPY --from=build /app .
|
||||
EXPOSE 3000
|
||||
ENV NODE_ENV=production
|
||||
CMD [ "node", "dist/src/main" ]
|
||||
@@ -7,27 +7,32 @@
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"includes": ["**", "!node_modules", "!dist", "!build"]
|
||||
"includes": ["**", "!node_modules", "!dist", "!build", "!.migrations"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab",
|
||||
"indentWidth": 1
|
||||
},
|
||||
"javascript": {
|
||||
"parser": {
|
||||
"unsafeParameterDecoratorsEnabled": true
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"suspicious": {
|
||||
"noUnknownAtRules": "off"
|
||||
"noUnknownAtRules": "off",
|
||||
"noExplicitAny": "off"
|
||||
},
|
||||
"style": {
|
||||
"useImportType": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"useHookAtTopLevel": "off"
|
||||
}
|
||||
},
|
||||
"domains": {
|
||||
"next": "recommended",
|
||||
"react": "recommended"
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
|
||||
19
backend/drizzle.config.ts
Normal file
19
backend/drizzle.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as process from "node:process";
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./src/database/schemas/index.ts",
|
||||
out: ".migrations",
|
||||
dialect: "postgresql",
|
||||
casing: "snake_case",
|
||||
dbCredentials: {
|
||||
host: String(process.env.POSTGRES_HOST || "localhost"),
|
||||
port: Number(process.env.POSTGRES_PORT || 5432),
|
||||
database: String(process.env.POSTGRES_DB || "app"),
|
||||
user: String(process.env.POSTGRES_USER || "app"),
|
||||
password: String(process.env.POSTGRES_PASSWORD || "app"),
|
||||
ssl: false,
|
||||
},
|
||||
verbose: true,
|
||||
strict: true,
|
||||
});
|
||||
@@ -1,13 +1,19 @@
|
||||
{
|
||||
"name": "@memegoat/backend",
|
||||
"version": "0.0.1",
|
||||
"version": "1.5.2",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"files": [
|
||||
"dist",
|
||||
".migrations",
|
||||
"drizzle.config.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"lint": "biome check",
|
||||
"lint:write": "biome check --write --unsafe",
|
||||
"format": "biome format --write",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
@@ -17,23 +23,50 @@
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs-modules/mailer": "^2.0.2",
|
||||
"@nestjs/cache-manager": "^3.1.0",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/mapped-types": "^2.1.0",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/schedule": "^6.1.0",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@noble/post-quantum": "^0.5.4",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@sentry/nestjs": "^10.32.1",
|
||||
"@sentry/profiling-node": "^10.32.1",
|
||||
"cache-manager": "^7.2.7",
|
||||
"cache-manager-redis-yet": "^5.1.5",
|
||||
"clamscan": "^2.4.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"helmet": "^8.1.0",
|
||||
"iron-session": "^8.0.4",
|
||||
"jose": "^6.1.3",
|
||||
"minio": "^8.0.6",
|
||||
"nodemailer": "^7.0.12",
|
||||
"otplib": "^12.0.1",
|
||||
"pg": "^8.16.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
"rxjs": "^7.8.1",
|
||||
"sharp": "^0.34.5",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^4.3.5",
|
||||
"drizzle-kit": "^0.31.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"globals": "^16.0.0",
|
||||
"jest": "^30.0.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
@@ -42,8 +75,23 @@
|
||||
"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"
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/fluent-ffmpeg": "^2.1.28",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/nodemailer": "^7.0.4",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/sharp": "^0.32.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"drizzle-kit": "^0.31.8"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
@@ -53,13 +101,20 @@
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
"testEnvironment": "node",
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!(.pnpm/)?(jose|@noble|uuid))"
|
||||
],
|
||||
"transform": {
|
||||
"^.+\\.(t|j)sx?$": "ts-jest"
|
||||
},
|
||||
"moduleNameMapper": {
|
||||
"^@noble/post-quantum/(.*)$": "<rootDir>/../node_modules/@noble/post-quantum/$1",
|
||||
"^@noble/hashes/(.*)$": "<rootDir>/../node_modules/@noble/hashes/$1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
62
backend/src/admin/admin.controller.spec.ts
Normal file
62
backend/src/admin/admin.controller.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
17
backend/src/admin/admin.controller.ts
Normal file
17
backend/src/admin/admin.controller.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Controller, Get, UseGuards } from "@nestjs/common";
|
||||
import { Roles } from "../auth/decorators/roles.decorator";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { RolesGuard } from "../auth/guards/roles.guard";
|
||||
import { AdminService } from "./admin.service";
|
||||
|
||||
@Controller("admin")
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
@Roles("admin")
|
||||
export class AdminController {
|
||||
constructor(private readonly adminService: AdminService) {}
|
||||
|
||||
@Get("stats")
|
||||
getStats() {
|
||||
return this.adminService.getStats();
|
||||
}
|
||||
}
|
||||
14
backend/src/admin/admin.module.ts
Normal file
14
backend/src/admin/admin.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { CategoriesModule } from "../categories/categories.module";
|
||||
import { ContentsModule } from "../contents/contents.module";
|
||||
import { UsersModule } from "../users/users.module";
|
||||
import { AdminController } from "./admin.controller";
|
||||
import { AdminService } from "./admin.service";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, UsersModule, ContentsModule, CategoriesModule],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService],
|
||||
})
|
||||
export class AdminModule {}
|
||||
58
backend/src/admin/admin.service.spec.ts
Normal file
58
backend/src/admin/admin.service.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
27
backend/src/admin/admin.service.ts
Normal file
27
backend/src/admin/admin.service.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { CategoriesRepository } from "../categories/repositories/categories.repository";
|
||||
import { ContentsRepository } from "../contents/repositories/contents.repository";
|
||||
import { UsersRepository } from "../users/repositories/users.repository";
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
constructor(
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly contentsRepository: ContentsRepository,
|
||||
private readonly categoriesRepository: CategoriesRepository,
|
||||
) {}
|
||||
|
||||
async getStats() {
|
||||
const [userCount, contentCount, categoryCount] = await Promise.all([
|
||||
this.usersRepository.countAll(),
|
||||
this.contentsRepository.count({}),
|
||||
this.categoriesRepository.countAll(),
|
||||
]);
|
||||
|
||||
return {
|
||||
users: userCount,
|
||||
contents: contentCount,
|
||||
categories: categoryCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
95
backend/src/api-keys/api-keys.controller.spec.ts
Normal file
95
backend/src/api-keys/api-keys.controller.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
42
backend/src/api-keys/api-keys.controller.ts
Normal file
42
backend/src/api-keys/api-keys.controller.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import type { AuthenticatedRequest } from "../common/interfaces/request.interface";
|
||||
import { ApiKeysService } from "./api-keys.service";
|
||||
import { CreateApiKeyDto } from "./dto/create-api-key.dto";
|
||||
|
||||
@Controller("api-keys")
|
||||
@UseGuards(AuthGuard)
|
||||
export class ApiKeysController {
|
||||
constructor(private readonly apiKeysService: ApiKeysService) {}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Body() createApiKeyDto: CreateApiKeyDto,
|
||||
) {
|
||||
return this.apiKeysService.create(
|
||||
req.user.sub,
|
||||
createApiKeyDto.name,
|
||||
createApiKeyDto.expiresAt ? new Date(createApiKeyDto.expiresAt) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll(@Req() req: AuthenticatedRequest) {
|
||||
return this.apiKeysService.findAll(req.user.sub);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
revoke(@Req() req: AuthenticatedRequest, @Param("id") id: string) {
|
||||
return this.apiKeysService.revoke(req.user.sub, id);
|
||||
}
|
||||
}
|
||||
13
backend/src/api-keys/api-keys.module.ts
Normal file
13
backend/src/api-keys/api-keys.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { ApiKeysController } from "./api-keys.controller";
|
||||
import { ApiKeysService } from "./api-keys.service";
|
||||
import { ApiKeysRepository } from "./repositories/api-keys.repository";
|
||||
|
||||
@Module({
|
||||
imports: [forwardRef(() => AuthModule)],
|
||||
controllers: [ApiKeysController],
|
||||
providers: [ApiKeysService, ApiKeysRepository],
|
||||
exports: [ApiKeysService, ApiKeysRepository],
|
||||
})
|
||||
export class ApiKeysModule {}
|
||||
128
backend/src/api-keys/api-keys.service.spec.ts
Normal file
128
backend/src/api-keys/api-keys.service.spec.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { HashingService } from "../crypto/services/hashing.service";
|
||||
import { ApiKeysService } from "./api-keys.service";
|
||||
import { ApiKeysRepository } from "./repositories/api-keys.repository";
|
||||
|
||||
describe("ApiKeysService", () => {
|
||||
let service: ApiKeysService;
|
||||
let repository: ApiKeysRepository;
|
||||
|
||||
const mockApiKeysRepository = {
|
||||
create: jest.fn(),
|
||||
findAll: jest.fn(),
|
||||
revoke: jest.fn(),
|
||||
findActiveByKeyHash: jest.fn(),
|
||||
updateLastUsed: jest.fn(),
|
||||
};
|
||||
|
||||
const mockHashingService = {
|
||||
hashSha256: jest.fn().mockResolvedValue("hashed-key"),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ApiKeysService,
|
||||
{
|
||||
provide: ApiKeysRepository,
|
||||
useValue: mockApiKeysRepository,
|
||||
},
|
||||
{
|
||||
provide: HashingService,
|
||||
useValue: mockHashingService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ApiKeysService>(ApiKeysService);
|
||||
repository = module.get<ApiKeysRepository>(ApiKeysRepository);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should create an API key", async () => {
|
||||
const userId = "user-id";
|
||||
const name = "Test Key";
|
||||
const expiresAt = new Date();
|
||||
|
||||
const result = await service.create(userId, name, expiresAt);
|
||||
|
||||
expect(repository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId,
|
||||
name,
|
||||
prefix: "mg_live_",
|
||||
expiresAt,
|
||||
}),
|
||||
);
|
||||
expect(result).toHaveProperty("key");
|
||||
expect(result.name).toBe(name);
|
||||
expect(result.expiresAt).toBe(expiresAt);
|
||||
expect(result.key).toMatch(/^mg_live_/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findAll", () => {
|
||||
it("should find all API keys for a user", async () => {
|
||||
const userId = "user-id";
|
||||
const expectedKeys = [{ id: "1", name: "Key 1" }];
|
||||
mockApiKeysRepository.findAll.mockResolvedValue(expectedKeys);
|
||||
|
||||
const result = await service.findAll(userId);
|
||||
|
||||
expect(repository.findAll).toHaveBeenCalledWith(userId);
|
||||
expect(result).toEqual(expectedKeys);
|
||||
});
|
||||
});
|
||||
|
||||
describe("revoke", () => {
|
||||
it("should revoke an API key", async () => {
|
||||
const userId = "user-id";
|
||||
const keyId = "key-id";
|
||||
const expectedResult = [{ id: keyId, isActive: false }];
|
||||
mockApiKeysRepository.revoke.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await service.revoke(userId, keyId);
|
||||
|
||||
expect(repository.revoke).toHaveBeenCalledWith(userId, keyId);
|
||||
expect(result).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateKey", () => {
|
||||
it("should validate a valid API key", async () => {
|
||||
const key = "mg_live_testkey";
|
||||
const apiKey = { id: "1", isActive: true, expiresAt: null };
|
||||
mockApiKeysRepository.findActiveByKeyHash.mockResolvedValue(apiKey);
|
||||
|
||||
const result = await service.validateKey(key);
|
||||
|
||||
expect(result).toEqual(apiKey);
|
||||
expect(repository.findActiveByKeyHash).toHaveBeenCalled();
|
||||
expect(repository.updateLastUsed).toHaveBeenCalledWith(apiKey.id);
|
||||
});
|
||||
|
||||
it("should return null for invalid API key", async () => {
|
||||
mockApiKeysRepository.findActiveByKeyHash.mockResolvedValue(null);
|
||||
const result = await service.validateKey("invalid-key");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for expired API key", async () => {
|
||||
const key = "mg_live_testkey";
|
||||
const expiredDate = new Date();
|
||||
expiredDate.setFullYear(expiredDate.getFullYear() - 1);
|
||||
const apiKey = { id: "1", isActive: true, expiresAt: expiredDate };
|
||||
mockApiKeysRepository.findActiveByKeyHash.mockResolvedValue(apiKey);
|
||||
|
||||
const result = await service.validateKey(key);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
63
backend/src/api-keys/api-keys.service.ts
Normal file
63
backend/src/api-keys/api-keys.service.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { HashingService } from "../crypto/services/hashing.service";
|
||||
import { ApiKeysRepository } from "./repositories/api-keys.repository";
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeysService {
|
||||
private readonly logger = new Logger(ApiKeysService.name);
|
||||
|
||||
constructor(
|
||||
private readonly apiKeysRepository: ApiKeysRepository,
|
||||
private readonly hashingService: HashingService,
|
||||
) {}
|
||||
|
||||
async create(userId: string, name: string, expiresAt?: Date) {
|
||||
this.logger.log(`Creating API key for user ${userId}: ${name}`);
|
||||
const prefix = "mg_live_";
|
||||
const randomPart = randomBytes(24).toString("hex");
|
||||
const key = `${prefix}${randomPart}`;
|
||||
|
||||
const keyHash = await this.hashingService.hashSha256(key);
|
||||
|
||||
await this.apiKeysRepository.create({
|
||||
userId,
|
||||
name,
|
||||
prefix: prefix.substring(0, 8),
|
||||
keyHash,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return {
|
||||
name,
|
||||
key, // Retourné une seule fois à la création
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
async findAll(userId: string) {
|
||||
return await this.apiKeysRepository.findAll(userId);
|
||||
}
|
||||
|
||||
async revoke(userId: string, keyId: string) {
|
||||
this.logger.log(`Revoking API key ${keyId} for user ${userId}`);
|
||||
return await this.apiKeysRepository.revoke(userId, keyId);
|
||||
}
|
||||
|
||||
async validateKey(key: string) {
|
||||
const keyHash = await this.hashingService.hashSha256(key);
|
||||
|
||||
const apiKey = await this.apiKeysRepository.findActiveByKeyHash(keyHash);
|
||||
|
||||
if (!apiKey) return null;
|
||||
|
||||
if (apiKey.expiresAt && apiKey.expiresAt < new Date()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update last used at
|
||||
await this.apiKeysRepository.updateLastUsed(apiKey.id);
|
||||
|
||||
return apiKey;
|
||||
}
|
||||
}
|
||||
18
backend/src/api-keys/dto/create-api-key.dto.ts
Normal file
18
backend/src/api-keys/dto/create-api-key.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
IsDateString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from "class-validator";
|
||||
|
||||
export class CreateApiKeyDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(128)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
expiresAt?: string;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
58
backend/src/api-keys/repositories/api-keys.repository.ts
Normal file
58
backend/src/api-keys/repositories/api-keys.repository.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { DatabaseService } from "../../database/database.service";
|
||||
import { apiKeys } from "../../database/schemas";
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeysRepository {
|
||||
constructor(private readonly databaseService: DatabaseService) {}
|
||||
|
||||
async create(data: {
|
||||
userId: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
keyHash: string;
|
||||
expiresAt?: Date;
|
||||
}) {
|
||||
return await this.databaseService.db.insert(apiKeys).values(data);
|
||||
}
|
||||
|
||||
async findAll(userId: string) {
|
||||
return await this.databaseService.db
|
||||
.select({
|
||||
id: apiKeys.id,
|
||||
name: apiKeys.name,
|
||||
prefix: apiKeys.prefix,
|
||||
isActive: apiKeys.isActive,
|
||||
lastUsedAt: apiKeys.lastUsedAt,
|
||||
expiresAt: apiKeys.expiresAt,
|
||||
createdAt: apiKeys.createdAt,
|
||||
})
|
||||
.from(apiKeys)
|
||||
.where(eq(apiKeys.userId, userId));
|
||||
}
|
||||
|
||||
async revoke(userId: string, keyId: string) {
|
||||
return await this.databaseService.db
|
||||
.update(apiKeys)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(and(eq(apiKeys.id, keyId), eq(apiKeys.userId, userId)))
|
||||
.returning();
|
||||
}
|
||||
|
||||
async findActiveByKeyHash(keyHash: string) {
|
||||
const result = await this.databaseService.db
|
||||
.select()
|
||||
.from(apiKeys)
|
||||
.where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true)))
|
||||
.limit(1);
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async updateLastUsed(id: string) {
|
||||
return await this.databaseService.db
|
||||
.update(apiKeys)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(eq(apiKeys.id, id));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,84 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { CacheModule } from "@nestjs/cache-manager";
|
||||
import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { ThrottlerModule } from "@nestjs/throttler";
|
||||
import { redisStore } from "cache-manager-redis-yet";
|
||||
import { AdminModule } from "./admin/admin.module";
|
||||
import { ApiKeysModule } from "./api-keys/api-keys.module";
|
||||
import { AppController } from "./app.controller";
|
||||
import { AppService } from "./app.service";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { CategoriesModule } from "./categories/categories.module";
|
||||
import { CommonModule } from "./common/common.module";
|
||||
import { CrawlerDetectionMiddleware } from "./common/middlewares/crawler-detection.middleware";
|
||||
import { HTTPLoggerMiddleware } from "./common/middlewares/http-logger.middleware";
|
||||
import { validateEnv } from "./config/env.schema";
|
||||
import { ContentsModule } from "./contents/contents.module";
|
||||
import { CryptoModule } from "./crypto/crypto.module";
|
||||
import { DatabaseModule } from "./database/database.module";
|
||||
import { FavoritesModule } from "./favorites/favorites.module";
|
||||
import { HealthController } from "./health.controller";
|
||||
import { MailModule } from "./mail/mail.module";
|
||||
import { MediaModule } from "./media/media.module";
|
||||
import { ReportsModule } from "./reports/reports.module";
|
||||
import { S3Module } from "./s3/s3.module";
|
||||
import { SessionsModule } from "./sessions/sessions.module";
|
||||
import { TagsModule } from "./tags/tags.module";
|
||||
import { UsersModule } from "./users/users.module";
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [AppController],
|
||||
imports: [
|
||||
DatabaseModule,
|
||||
CryptoModule,
|
||||
CommonModule,
|
||||
S3Module,
|
||||
MailModule,
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
CategoriesModule,
|
||||
ContentsModule,
|
||||
FavoritesModule,
|
||||
TagsModule,
|
||||
MediaModule,
|
||||
SessionsModule,
|
||||
ReportsModule,
|
||||
ApiKeysModule,
|
||||
AdminModule,
|
||||
ScheduleModule.forRoot(),
|
||||
ThrottlerModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => [
|
||||
{
|
||||
ttl: 60000,
|
||||
limit: config.get("NODE_ENV") === "production" ? 100 : 1000,
|
||||
},
|
||||
],
|
||||
}),
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
validate: validateEnv,
|
||||
}),
|
||||
CacheModule.registerAsync({
|
||||
isGlobal: true,
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (config: ConfigService) => ({
|
||||
store: await redisStore({
|
||||
url: `redis://${config.get("REDIS_HOST")}:${config.get("REDIS_PORT")}`,
|
||||
}),
|
||||
ttl: 600, // 10 minutes
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AppController, HealthController],
|
||||
providers: [AppService],
|
||||
})
|
||||
export class AppModule {}
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer
|
||||
.apply(HTTPLoggerMiddleware, CrawlerDetectionMiddleware)
|
||||
.forRoutes("*");
|
||||
}
|
||||
}
|
||||
|
||||
190
backend/src/auth/auth.controller.spec.ts
Normal file
190
backend/src/auth/auth.controller.spec.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
142
backend/src/auth/auth.controller.ts
Normal file
142
backend/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Headers,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
import type { Request, Response } from "express";
|
||||
import { getIronSession } from "iron-session";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { BootstrapService } from "./bootstrap.service";
|
||||
import { LoginDto } from "./dto/login.dto";
|
||||
import { RegisterDto } from "./dto/register.dto";
|
||||
import { Verify2faDto } from "./dto/verify-2fa.dto";
|
||||
import { getSessionOptions, SessionData } from "./session.config";
|
||||
|
||||
@Controller("auth")
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly bootstrapService: BootstrapService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
@Post("register")
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||
register(@Body() registerDto: RegisterDto) {
|
||||
return this.authService.register(registerDto);
|
||||
}
|
||||
|
||||
@Post("login")
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||
async login(
|
||||
@Body() loginDto: LoginDto,
|
||||
@Headers("user-agent") userAgent: string,
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const ip = req.ip;
|
||||
const result = await this.authService.login(loginDto, userAgent, ip);
|
||||
|
||||
if (result.access_token) {
|
||||
const session = await getIronSession<SessionData>(
|
||||
req,
|
||||
res,
|
||||
getSessionOptions(this.configService.get("SESSION_PASSWORD") as string),
|
||||
);
|
||||
session.accessToken = result.access_token;
|
||||
session.refreshToken = result.refresh_token;
|
||||
session.userId = result.userId;
|
||||
await session.save();
|
||||
|
||||
// On ne renvoie pas les tokens dans le body pour plus de sécurité
|
||||
return res.json({
|
||||
message: result.message,
|
||||
userId: result.userId,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json(result);
|
||||
}
|
||||
|
||||
@Post("verify-2fa")
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||
async verifyTwoFactor(
|
||||
@Body() verify2faDto: Verify2faDto,
|
||||
@Headers("user-agent") userAgent: string,
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const ip = req.ip;
|
||||
const result = await this.authService.verifyTwoFactorLogin(
|
||||
verify2faDto.userId,
|
||||
verify2faDto.token,
|
||||
userAgent,
|
||||
ip,
|
||||
);
|
||||
|
||||
if (result.access_token) {
|
||||
const session = await getIronSession<SessionData>(
|
||||
req,
|
||||
res,
|
||||
getSessionOptions(this.configService.get("SESSION_PASSWORD") as string),
|
||||
);
|
||||
session.accessToken = result.access_token;
|
||||
session.refreshToken = result.refresh_token;
|
||||
session.userId = verify2faDto.userId;
|
||||
await session.save();
|
||||
|
||||
return res.json({
|
||||
message: result.message,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json(result);
|
||||
}
|
||||
|
||||
@Post("refresh")
|
||||
async refresh(@Req() req: Request, @Res() res: Response) {
|
||||
const session = await getIronSession<SessionData>(
|
||||
req,
|
||||
res,
|
||||
getSessionOptions(this.configService.get("SESSION_PASSWORD") as string),
|
||||
);
|
||||
|
||||
if (!session.refreshToken) {
|
||||
return res.status(401).json({ message: "No refresh token" });
|
||||
}
|
||||
|
||||
const result = await this.authService.refresh(session.refreshToken);
|
||||
|
||||
session.accessToken = result.access_token;
|
||||
session.refreshToken = result.refresh_token;
|
||||
await session.save();
|
||||
|
||||
return res.json({ message: "Token refreshed" });
|
||||
}
|
||||
|
||||
@Post("logout")
|
||||
async logout(@Req() req: Request, @Res() res: Response) {
|
||||
const session = await getIronSession<SessionData>(
|
||||
req,
|
||||
res,
|
||||
getSessionOptions(this.configService.get("SESSION_PASSWORD") as string),
|
||||
);
|
||||
session.destroy();
|
||||
return res.json({ message: "User logged out" });
|
||||
}
|
||||
|
||||
@Get("bootstrap-admin")
|
||||
async bootstrapAdmin(
|
||||
@Query("token") token: string,
|
||||
@Query("username") username: string,
|
||||
) {
|
||||
return this.bootstrapService.consumeToken(token, username);
|
||||
}
|
||||
}
|
||||
34
backend/src/auth/auth.module.ts
Normal file
34
backend/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { SessionsModule } from "../sessions/sessions.module";
|
||||
import { UsersModule } from "../users/users.module";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { BootstrapService } from "./bootstrap.service";
|
||||
import { AuthGuard } from "./guards/auth.guard";
|
||||
import { OptionalAuthGuard } from "./guards/optional-auth.guard";
|
||||
import { RolesGuard } from "./guards/roles.guard";
|
||||
import { RbacService } from "./rbac.service";
|
||||
import { RbacRepository } from "./repositories/rbac.repository";
|
||||
|
||||
@Module({
|
||||
imports: [forwardRef(() => UsersModule), SessionsModule],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
RbacService,
|
||||
BootstrapService,
|
||||
RbacRepository,
|
||||
AuthGuard,
|
||||
OptionalAuthGuard,
|
||||
RolesGuard,
|
||||
],
|
||||
exports: [
|
||||
AuthService,
|
||||
RbacService,
|
||||
RbacRepository,
|
||||
AuthGuard,
|
||||
OptionalAuthGuard,
|
||||
RolesGuard,
|
||||
],
|
||||
})
|
||||
export class AuthModule {}
|
||||
261
backend/src/auth/auth.service.spec.ts
Normal file
261
backend/src/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
jest.mock("uuid", () => ({
|
||||
v4: jest.fn(() => "mocked-uuid"),
|
||||
}));
|
||||
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
|
||||
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(),
|
||||
jwtVerify: jest.fn(),
|
||||
}));
|
||||
|
||||
import { BadRequestException, UnauthorizedException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { authenticator } from "otplib";
|
||||
import * as qrcode from "qrcode";
|
||||
import { HashingService } from "../crypto/services/hashing.service";
|
||||
import { JwtService } from "../crypto/services/jwt.service";
|
||||
import { SessionsService } from "../sessions/sessions.service";
|
||||
import { UsersService } from "../users/users.service";
|
||||
import { AuthService } from "./auth.service";
|
||||
|
||||
jest.mock("otplib");
|
||||
jest.mock("qrcode");
|
||||
jest.mock("../users/users.service");
|
||||
jest.mock("../sessions/sessions.service");
|
||||
|
||||
describe("AuthService", () => {
|
||||
let service: AuthService;
|
||||
|
||||
const mockUsersService = {
|
||||
findOne: jest.fn(),
|
||||
setTwoFactorSecret: jest.fn(),
|
||||
getTwoFactorSecret: jest.fn(),
|
||||
toggleTwoFactor: jest.fn(),
|
||||
create: jest.fn(),
|
||||
findByEmailHash: jest.fn(),
|
||||
findOneWithPrivateData: jest.fn(),
|
||||
};
|
||||
|
||||
const mockHashingService = {
|
||||
hashPassword: jest.fn(),
|
||||
hashEmail: jest.fn(),
|
||||
verifyPassword: jest.fn(),
|
||||
};
|
||||
|
||||
const mockJwtService = {
|
||||
generateJwt: jest.fn(),
|
||||
};
|
||||
|
||||
const mockSessionsService = {
|
||||
createSession: jest.fn(),
|
||||
refreshSession: jest.fn(),
|
||||
};
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuthService,
|
||||
{ provide: UsersService, useValue: mockUsersService },
|
||||
{ provide: HashingService, useValue: mockHashingService },
|
||||
{ provide: JwtService, useValue: mockJwtService },
|
||||
{ provide: SessionsService, useValue: mockSessionsService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AuthService>(AuthService);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe("generateTwoFactorSecret", () => {
|
||||
it("should generate a 2FA secret", async () => {
|
||||
const userId = "user-id";
|
||||
const user = { username: "testuser" };
|
||||
mockUsersService.findOne.mockResolvedValue(user);
|
||||
(authenticator.generateSecret as jest.Mock).mockReturnValue("secret");
|
||||
(authenticator.keyuri as jest.Mock).mockReturnValue("otpauth://...");
|
||||
(qrcode.toDataURL as jest.Mock).mockResolvedValue(
|
||||
"data:image/png;base64,...",
|
||||
);
|
||||
|
||||
const result = await service.generateTwoFactorSecret(userId);
|
||||
|
||||
expect(result).toEqual({
|
||||
secret: "secret",
|
||||
qrCodeDataUrl: "data:image/png;base64,...",
|
||||
});
|
||||
expect(mockUsersService.setTwoFactorSecret).toHaveBeenCalledWith(
|
||||
userId,
|
||||
"secret",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw UnauthorizedException if user not found", async () => {
|
||||
mockUsersService.findOne.mockResolvedValue(null);
|
||||
await expect(service.generateTwoFactorSecret("invalid")).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enableTwoFactor", () => {
|
||||
it("should enable 2FA", async () => {
|
||||
const userId = "user-id";
|
||||
const token = "123456";
|
||||
mockUsersService.getTwoFactorSecret.mockResolvedValue("secret");
|
||||
(authenticator.verify as jest.Mock).mockReturnValue(true);
|
||||
|
||||
const result = await service.enableTwoFactor(userId, token);
|
||||
|
||||
expect(result).toEqual({ message: "2FA enabled successfully" });
|
||||
expect(mockUsersService.toggleTwoFactor).toHaveBeenCalledWith(userId, true);
|
||||
});
|
||||
|
||||
it("should throw BadRequestException if 2FA not initiated", async () => {
|
||||
mockUsersService.getTwoFactorSecret.mockResolvedValue(null);
|
||||
await expect(service.enableTwoFactor("user-id", "token")).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw BadRequestException if token is invalid", async () => {
|
||||
mockUsersService.getTwoFactorSecret.mockResolvedValue("secret");
|
||||
(authenticator.verify as jest.Mock).mockReturnValue(false);
|
||||
await expect(service.enableTwoFactor("user-id", "invalid")).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("register", () => {
|
||||
it("should register a user", async () => {
|
||||
const dto = {
|
||||
username: "test",
|
||||
email: "test@example.com",
|
||||
password: "password",
|
||||
};
|
||||
mockHashingService.hashPassword.mockResolvedValue("hashed-password");
|
||||
mockHashingService.hashEmail.mockResolvedValue("hashed-email");
|
||||
mockUsersService.create.mockResolvedValue({ uuid: "new-user-id" });
|
||||
|
||||
const result = await service.register(dto);
|
||||
|
||||
expect(result).toEqual({
|
||||
message: "User registered successfully",
|
||||
userId: "new-user-id",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("login", () => {
|
||||
it("should login a user", async () => {
|
||||
const dto = { email: "test@example.com", password: "password" };
|
||||
const user = {
|
||||
uuid: "user-id",
|
||||
username: "test",
|
||||
passwordHash: "hash",
|
||||
isTwoFactorEnabled: false,
|
||||
};
|
||||
mockHashingService.hashEmail.mockResolvedValue("hashed-email");
|
||||
mockUsersService.findByEmailHash.mockResolvedValue(user);
|
||||
mockHashingService.verifyPassword.mockResolvedValue(true);
|
||||
mockJwtService.generateJwt.mockResolvedValue("access-token");
|
||||
mockSessionsService.createSession.mockResolvedValue({
|
||||
refreshToken: "refresh-token",
|
||||
});
|
||||
|
||||
const result = await service.login(dto);
|
||||
|
||||
expect(result).toEqual({
|
||||
message: "User logged in successfully",
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return requires2FA if 2FA is enabled", async () => {
|
||||
const dto = { email: "test@example.com", password: "password" };
|
||||
const user = {
|
||||
uuid: "user-id",
|
||||
username: "test",
|
||||
passwordHash: "hash",
|
||||
isTwoFactorEnabled: true,
|
||||
};
|
||||
mockHashingService.hashEmail.mockResolvedValue("hashed-email");
|
||||
mockUsersService.findByEmailHash.mockResolvedValue(user);
|
||||
mockHashingService.verifyPassword.mockResolvedValue(true);
|
||||
|
||||
const result = await service.login(dto);
|
||||
|
||||
expect(result).toEqual({
|
||||
message: "2FA required",
|
||||
requires2FA: true,
|
||||
userId: "user-id",
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw UnauthorizedException for invalid credentials", async () => {
|
||||
mockUsersService.findByEmailHash.mockResolvedValue(null);
|
||||
await expect(service.login({ email: "x", password: "y" })).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyTwoFactorLogin", () => {
|
||||
it("should verify 2FA login", async () => {
|
||||
const userId = "user-id";
|
||||
const token = "123456";
|
||||
const user = { uuid: userId, username: "test", isTwoFactorEnabled: true };
|
||||
mockUsersService.findOneWithPrivateData.mockResolvedValue(user);
|
||||
mockUsersService.getTwoFactorSecret.mockResolvedValue("secret");
|
||||
(authenticator.verify as jest.Mock).mockReturnValue(true);
|
||||
mockJwtService.generateJwt.mockResolvedValue("access-token");
|
||||
mockSessionsService.createSession.mockResolvedValue({
|
||||
refreshToken: "refresh-token",
|
||||
});
|
||||
|
||||
const result = await service.verifyTwoFactorLogin(userId, token);
|
||||
|
||||
expect(result).toEqual({
|
||||
message: "User logged in successfully (2FA)",
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("refresh", () => {
|
||||
it("should refresh tokens", async () => {
|
||||
const refreshToken = "old-refresh";
|
||||
const session = { userId: "user-id", refreshToken: "new-refresh" };
|
||||
const user = { uuid: "user-id", username: "test" };
|
||||
mockSessionsService.refreshSession.mockResolvedValue(session);
|
||||
mockUsersService.findOne.mockResolvedValue(user);
|
||||
mockJwtService.generateJwt.mockResolvedValue("new-access");
|
||||
|
||||
const result = await service.refresh(refreshToken);
|
||||
|
||||
expect(result).toEqual({
|
||||
access_token: "new-access",
|
||||
refresh_token: "new-refresh",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
219
backend/src/auth/auth.service.ts
Normal file
219
backend/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { authenticator } from "otplib";
|
||||
import { toDataURL } from "qrcode";
|
||||
import { HashingService } from "../crypto/services/hashing.service";
|
||||
import { JwtService } from "../crypto/services/jwt.service";
|
||||
import { SessionsService } from "../sessions/sessions.service";
|
||||
import { UsersService } from "../users/users.service";
|
||||
import { LoginDto } from "./dto/login.dto";
|
||||
import { RegisterDto } from "./dto/register.dto";
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(forwardRef(() => UsersService))
|
||||
private readonly usersService: UsersService,
|
||||
private readonly hashingService: HashingService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly sessionsService: SessionsService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async generateTwoFactorSecret(userId: string) {
|
||||
this.logger.log(`Generating 2FA secret for user ${userId}`);
|
||||
const user = await this.usersService.findOne(userId);
|
||||
if (!user) throw new UnauthorizedException();
|
||||
|
||||
const secret = authenticator.generateSecret();
|
||||
const otpauthUrl = authenticator.keyuri(
|
||||
user.username,
|
||||
this.configService.get("DOMAIN_NAME") || "Memegoat",
|
||||
secret,
|
||||
);
|
||||
|
||||
await this.usersService.setTwoFactorSecret(userId, secret);
|
||||
|
||||
const qrCodeDataUrl = await toDataURL(otpauthUrl);
|
||||
return {
|
||||
secret,
|
||||
qrCodeDataUrl,
|
||||
};
|
||||
}
|
||||
|
||||
async enableTwoFactor(userId: string, token: string) {
|
||||
this.logger.log(`Enabling 2FA for user ${userId}`);
|
||||
const secret = await this.usersService.getTwoFactorSecret(userId);
|
||||
if (!secret) {
|
||||
throw new BadRequestException("2FA not initiated");
|
||||
}
|
||||
|
||||
const isValid = authenticator.verify({ token, secret });
|
||||
if (!isValid) {
|
||||
throw new BadRequestException("Invalid 2FA token");
|
||||
}
|
||||
|
||||
await this.usersService.toggleTwoFactor(userId, true);
|
||||
return { message: "2FA enabled successfully" };
|
||||
}
|
||||
|
||||
async disableTwoFactor(userId: string, token: string) {
|
||||
this.logger.log(`Disabling 2FA for user ${userId}`);
|
||||
const secret = await this.usersService.getTwoFactorSecret(userId);
|
||||
if (!secret) {
|
||||
throw new BadRequestException("2FA not enabled");
|
||||
}
|
||||
|
||||
const isValid = authenticator.verify({ token, secret });
|
||||
if (!isValid) {
|
||||
throw new BadRequestException("Invalid 2FA token");
|
||||
}
|
||||
|
||||
await this.usersService.toggleTwoFactor(userId, false);
|
||||
return { message: "2FA disabled successfully" };
|
||||
}
|
||||
|
||||
async register(dto: RegisterDto) {
|
||||
this.logger.log(`Registering new user: ${dto.username}`);
|
||||
const { username, email, password } = dto;
|
||||
|
||||
const passwordHash = await this.hashingService.hashPassword(password);
|
||||
const emailHash = await this.hashingService.hashEmail(email);
|
||||
|
||||
const user = await this.usersService.create({
|
||||
username,
|
||||
email,
|
||||
passwordHash,
|
||||
emailHash,
|
||||
});
|
||||
|
||||
return {
|
||||
message: "User registered successfully",
|
||||
userId: user.uuid,
|
||||
};
|
||||
}
|
||||
|
||||
async login(dto: LoginDto, userAgent?: string, ip?: string) {
|
||||
this.logger.log(`Login attempt for email: ${dto.email}`);
|
||||
const { email, password } = dto;
|
||||
|
||||
const emailHash = await this.hashingService.hashEmail(email);
|
||||
const user = await this.usersService.findByEmailHash(emailHash);
|
||||
|
||||
if (!user) {
|
||||
this.logger.warn(`Login failed: user not found for email hash`);
|
||||
throw new UnauthorizedException("Invalid credentials");
|
||||
}
|
||||
|
||||
const isPasswordValid = await this.hashingService.verifyPassword(
|
||||
password,
|
||||
user.passwordHash,
|
||||
);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
this.logger.warn(`Login failed: invalid password for user ${user.uuid}`);
|
||||
throw new UnauthorizedException("Invalid credentials");
|
||||
}
|
||||
|
||||
if (user.isTwoFactorEnabled) {
|
||||
this.logger.log(`2FA required for user ${user.uuid}`);
|
||||
return {
|
||||
message: "2FA required",
|
||||
requires2FA: true,
|
||||
userId: user.uuid,
|
||||
};
|
||||
}
|
||||
|
||||
const accessToken = await this.jwtService.generateJwt({
|
||||
sub: user.uuid,
|
||||
username: user.username,
|
||||
});
|
||||
|
||||
const session = await this.sessionsService.createSession(
|
||||
user.uuid,
|
||||
userAgent,
|
||||
ip,
|
||||
);
|
||||
|
||||
this.logger.log(`User ${user.uuid} logged in successfully`);
|
||||
return {
|
||||
message: "User logged in successfully",
|
||||
access_token: accessToken,
|
||||
refresh_token: session.refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
async verifyTwoFactorLogin(
|
||||
userId: string,
|
||||
token: string,
|
||||
userAgent?: string,
|
||||
ip?: string,
|
||||
) {
|
||||
this.logger.log(`2FA verification attempt for user ${userId}`);
|
||||
const user = await this.usersService.findOneWithPrivateData(userId);
|
||||
if (!user || !user.isTwoFactorEnabled) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const secret = await this.usersService.getTwoFactorSecret(userId);
|
||||
if (!secret) throw new UnauthorizedException();
|
||||
|
||||
const isValid = authenticator.verify({ token, secret });
|
||||
if (!isValid) {
|
||||
this.logger.warn(
|
||||
`2FA verification failed for user ${userId}: invalid token`,
|
||||
);
|
||||
throw new UnauthorizedException("Invalid 2FA token");
|
||||
}
|
||||
|
||||
const accessToken = await this.jwtService.generateJwt({
|
||||
sub: user.uuid,
|
||||
username: user.username,
|
||||
});
|
||||
|
||||
const session = await this.sessionsService.createSession(
|
||||
user.uuid,
|
||||
userAgent,
|
||||
ip,
|
||||
);
|
||||
|
||||
this.logger.log(`User ${userId} logged in successfully via 2FA`);
|
||||
return {
|
||||
message: "User logged in successfully (2FA)",
|
||||
access_token: accessToken,
|
||||
refresh_token: session.refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string) {
|
||||
const session = await this.sessionsService.refreshSession(refreshToken);
|
||||
const user = await this.usersService.findOne(session.userId);
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException("User not found");
|
||||
}
|
||||
|
||||
const accessToken = await this.jwtService.generateJwt({
|
||||
sub: user.uuid,
|
||||
username: user.username,
|
||||
});
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
refresh_token: session.refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
async logout() {
|
||||
return { message: "User logged out" };
|
||||
}
|
||||
}
|
||||
114
backend/src/auth/bootstrap.service.spec.ts
Normal file
114
backend/src/auth/bootstrap.service.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { UnauthorizedException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { UsersService } from "../users/users.service";
|
||||
import { BootstrapService } from "./bootstrap.service";
|
||||
import { RbacService } from "./rbac.service";
|
||||
|
||||
describe("BootstrapService", () => {
|
||||
let service: BootstrapService;
|
||||
let rbacService: RbacService;
|
||||
let _usersService: UsersService;
|
||||
|
||||
const mockRbacService = {
|
||||
countAdmins: jest.fn(),
|
||||
assignRoleToUser: jest.fn(),
|
||||
};
|
||||
|
||||
const mockUsersService = {
|
||||
findPublicProfile: jest.fn(),
|
||||
};
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
BootstrapService,
|
||||
{ provide: RbacService, useValue: mockRbacService },
|
||||
{ provide: UsersService, useValue: mockUsersService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<BootstrapService>(BootstrapService);
|
||||
rbacService = module.get<RbacService>(RbacService);
|
||||
_usersService = module.get<UsersService>(UsersService);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe("onApplicationBootstrap", () => {
|
||||
it("should generate a token if no admin exists", async () => {
|
||||
mockRbacService.countAdmins.mockResolvedValue(0);
|
||||
const generateTokenSpy = jest.spyOn(
|
||||
service as any,
|
||||
"generateBootstrapToken",
|
||||
);
|
||||
|
||||
await service.onApplicationBootstrap();
|
||||
|
||||
expect(rbacService.countAdmins).toHaveBeenCalled();
|
||||
expect(generateTokenSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not generate a token if admin exists", async () => {
|
||||
mockRbacService.countAdmins.mockResolvedValue(1);
|
||||
const generateTokenSpy = jest.spyOn(
|
||||
service as any,
|
||||
"generateBootstrapToken",
|
||||
);
|
||||
|
||||
await service.onApplicationBootstrap();
|
||||
|
||||
expect(rbacService.countAdmins).toHaveBeenCalled();
|
||||
expect(generateTokenSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("consumeToken", () => {
|
||||
it("should throw UnauthorizedException if token is invalid", async () => {
|
||||
mockRbacService.countAdmins.mockResolvedValue(0);
|
||||
await service.onApplicationBootstrap();
|
||||
|
||||
await expect(service.consumeToken("wrong-token", "user1")).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw UnauthorizedException if user not found", async () => {
|
||||
mockRbacService.countAdmins.mockResolvedValue(0);
|
||||
await service.onApplicationBootstrap();
|
||||
const token = (service as any).bootstrapToken;
|
||||
|
||||
mockUsersService.findPublicProfile.mockResolvedValue(null);
|
||||
|
||||
await expect(service.consumeToken(token, "user1")).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it("should assign admin role and invalidate token on success", async () => {
|
||||
mockRbacService.countAdmins.mockResolvedValue(0);
|
||||
await service.onApplicationBootstrap();
|
||||
const token = (service as any).bootstrapToken;
|
||||
|
||||
const mockUser = { uuid: "user-uuid", username: "user1" };
|
||||
mockUsersService.findPublicProfile.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await service.consumeToken(token, "user1");
|
||||
|
||||
expect(rbacService.assignRoleToUser).toHaveBeenCalledWith(
|
||||
"user-uuid",
|
||||
"admin",
|
||||
);
|
||||
expect((service as any).bootstrapToken).toBeNull();
|
||||
expect(result.message).toContain("user1 is now an administrator");
|
||||
});
|
||||
});
|
||||
});
|
||||
67
backend/src/auth/bootstrap.service.ts
Normal file
67
backend/src/auth/bootstrap.service.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import * as crypto from "node:crypto";
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnApplicationBootstrap,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { UsersService } from "../users/users.service";
|
||||
import { RbacService } from "./rbac.service";
|
||||
|
||||
@Injectable()
|
||||
export class BootstrapService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(BootstrapService.name);
|
||||
private bootstrapToken: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly rbacService: RbacService,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
const adminCount = await this.rbacService.countAdmins();
|
||||
if (adminCount === 0) {
|
||||
this.generateBootstrapToken();
|
||||
}
|
||||
}
|
||||
|
||||
private generateBootstrapToken() {
|
||||
this.bootstrapToken = crypto.randomBytes(32).toString("hex");
|
||||
const domain = this.configService.get("DOMAIN_NAME") || "localhost";
|
||||
const protocol = domain.includes("localhost") ? "http" : "https";
|
||||
const url = `${protocol}://${domain}/auth/bootstrap-admin`;
|
||||
|
||||
this.logger.warn("SECURITY ALERT: No administrator found in database.");
|
||||
this.logger.warn(
|
||||
"To create the first administrator, use the following endpoint:",
|
||||
);
|
||||
this.logger.warn(
|
||||
`Endpoint: GET ${url}?token=${this.bootstrapToken}&username=votre_nom_utilisateur`,
|
||||
);
|
||||
this.logger.warn(
|
||||
'Exemple: curl -X GET "http://localhost/auth/bootstrap-admin?token=...&username=..."',
|
||||
);
|
||||
this.logger.warn("This token is one-time use only.");
|
||||
}
|
||||
|
||||
async consumeToken(token: string, username: string) {
|
||||
if (!this.bootstrapToken || token !== this.bootstrapToken) {
|
||||
throw new UnauthorizedException("Invalid or expired bootstrap token");
|
||||
}
|
||||
|
||||
const user = await this.usersService.findPublicProfile(username);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException(`User ${username} not found`);
|
||||
}
|
||||
|
||||
await this.rbacService.assignRoleToUser(user.uuid, "admin");
|
||||
this.bootstrapToken = null; // One-time use
|
||||
|
||||
this.logger.log(
|
||||
`User ${username} has been promoted to administrator via bootstrap token.`,
|
||||
);
|
||||
return { message: `User ${username} is now an administrator` };
|
||||
}
|
||||
}
|
||||
3
backend/src/auth/decorators/roles.decorator.ts
Normal file
3
backend/src/auth/decorators/roles.decorator.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { SetMetadata } from "@nestjs/common";
|
||||
|
||||
export const Roles = (...roles: string[]) => SetMetadata("roles", roles);
|
||||
10
backend/src/auth/dto/login.dto.ts
Normal file
10
backend/src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsEmail, IsNotEmpty, IsString } from "class-validator";
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password!: string;
|
||||
}
|
||||
7
backend/src/auth/dto/refresh.dto.ts
Normal file
7
backend/src/auth/dto/refresh.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsNotEmpty, IsString } from "class-validator";
|
||||
|
||||
export class RefreshDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
refresh_token!: string;
|
||||
}
|
||||
25
backend/src/auth/dto/register.dto.ts
Normal file
25
backend/src/auth/dto/register.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from "class-validator";
|
||||
|
||||
export class RegisterDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(32)
|
||||
username!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
displayName?: string;
|
||||
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
}
|
||||
10
backend/src/auth/dto/verify-2fa.dto.ts
Normal file
10
backend/src/auth/dto/verify-2fa.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsNotEmpty, IsString, IsUUID } from "class-validator";
|
||||
|
||||
export class Verify2faDto {
|
||||
@IsUUID()
|
||||
userId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
token!: string;
|
||||
}
|
||||
89
backend/src/auth/guards/auth.guard.spec.ts
Normal file
89
backend/src/auth/guards/auth.guard.spec.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
44
backend/src/auth/guards/auth.guard.ts
Normal file
44
backend/src/auth/guards/auth.guard.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { getIronSession } from "iron-session";
|
||||
import { JwtService } from "../../crypto/services/jwt.service";
|
||||
import { getSessionOptions, SessionData } from "../session.config";
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const response = context.switchToHttp().getResponse();
|
||||
|
||||
const session = await getIronSession<SessionData>(
|
||||
request,
|
||||
response,
|
||||
getSessionOptions(this.configService.get("SESSION_PASSWORD") as string),
|
||||
);
|
||||
|
||||
const token = session.accessToken;
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await this.jwtService.verifyJwt(token);
|
||||
request.user = payload;
|
||||
} catch {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
84
backend/src/auth/guards/optional-auth.guard.spec.ts
Normal file
84
backend/src/auth/guards/optional-auth.guard.spec.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
39
backend/src/auth/guards/optional-auth.guard.ts
Normal file
39
backend/src/auth/guards/optional-auth.guard.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { getIronSession } from "iron-session";
|
||||
import { JwtService } from "../../crypto/services/jwt.service";
|
||||
import { getSessionOptions, SessionData } from "../session.config";
|
||||
|
||||
@Injectable()
|
||||
export class OptionalAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const response = context.switchToHttp().getResponse();
|
||||
|
||||
const session = await getIronSession<SessionData>(
|
||||
request,
|
||||
response,
|
||||
getSessionOptions(this.configService.get("SESSION_PASSWORD") as string),
|
||||
);
|
||||
|
||||
const token = session.accessToken;
|
||||
|
||||
if (!token) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await this.jwtService.verifyJwt(token);
|
||||
request.user = payload;
|
||||
} catch {
|
||||
// Ignore invalid tokens for optional auth
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
90
backend/src/auth/guards/roles.guard.spec.ts
Normal file
90
backend/src/auth/guards/roles.guard.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
28
backend/src/auth/guards/roles.guard.ts
Normal file
28
backend/src/auth/guards/roles.guard.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { RbacService } from "../rbac.service";
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private rbacService: RbacService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<string[]>("roles", [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!requiredRoles) {
|
||||
return true;
|
||||
}
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const userRoles = await this.rbacService.getUserRoles(user.sub);
|
||||
return requiredRoles.some((role) => userRoles.includes(role));
|
||||
}
|
||||
}
|
||||
94
backend/src/auth/rbac.service.spec.ts
Normal file
94
backend/src/auth/rbac.service.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { RbacService } from "./rbac.service";
|
||||
import { RbacRepository } from "./repositories/rbac.repository";
|
||||
|
||||
describe("RbacService", () => {
|
||||
let service: RbacService;
|
||||
let repository: RbacRepository;
|
||||
|
||||
const mockRbacRepository = {
|
||||
findRolesByUserId: jest.fn(),
|
||||
findPermissionsByUserId: jest.fn(),
|
||||
countRoles: jest.fn(),
|
||||
createRole: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
RbacService,
|
||||
{
|
||||
provide: RbacRepository,
|
||||
useValue: mockRbacRepository,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<RbacService>(RbacService);
|
||||
repository = module.get<RbacRepository>(RbacRepository);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe("getUserRoles", () => {
|
||||
it("should return user roles", async () => {
|
||||
const userId = "user-id";
|
||||
const mockRoles = ["admin", "user"];
|
||||
mockRbacRepository.findRolesByUserId.mockResolvedValue(mockRoles);
|
||||
|
||||
const result = await service.getUserRoles(userId);
|
||||
|
||||
expect(result).toEqual(mockRoles);
|
||||
expect(repository.findRolesByUserId).toHaveBeenCalledWith(userId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUserPermissions", () => {
|
||||
it("should return user permissions", async () => {
|
||||
const userId = "user-id";
|
||||
const mockPermissions = ["read", "write"];
|
||||
mockRbacRepository.findPermissionsByUserId.mockResolvedValue(
|
||||
mockPermissions,
|
||||
);
|
||||
|
||||
const result = await service.getUserPermissions(userId);
|
||||
|
||||
expect(result).toEqual(mockPermissions);
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
66
backend/src/auth/rbac.service.ts
Normal file
66
backend/src/auth/rbac.service.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from "@nestjs/common";
|
||||
import { RbacRepository } from "./repositories/rbac.repository";
|
||||
|
||||
@Injectable()
|
||||
export class RbacService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(RbacService.name);
|
||||
|
||||
constructor(private readonly rbacRepository: RbacRepository) {}
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
this.logger.log("RbacService initialized, checking roles...");
|
||||
await this.seedRoles();
|
||||
}
|
||||
|
||||
async seedRoles() {
|
||||
try {
|
||||
const count = await this.rbacRepository.countRoles();
|
||||
if (count === 0) {
|
||||
this.logger.log("No roles found, seeding default roles...");
|
||||
const defaultRoles = [
|
||||
{
|
||||
name: "Administrator",
|
||||
slug: "admin",
|
||||
description: "Full system access",
|
||||
},
|
||||
{
|
||||
name: "Moderator",
|
||||
slug: "moderator",
|
||||
description: "Access to moderation tools",
|
||||
},
|
||||
{ name: "User", slug: "user", description: "Standard user access" },
|
||||
];
|
||||
|
||||
for (const role of defaultRoles) {
|
||||
await this.rbacRepository.createRole(
|
||||
role.name,
|
||||
role.slug,
|
||||
role.description,
|
||||
);
|
||||
this.logger.log(`Created role: ${role.slug}`);
|
||||
}
|
||||
this.logger.log("Default roles seeded successfully.");
|
||||
} else {
|
||||
this.logger.log(`${count} roles already exist, skipping seeding.`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error("Error during roles seeding:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async getUserRoles(userId: string) {
|
||||
return this.rbacRepository.findRolesByUserId(userId);
|
||||
}
|
||||
|
||||
async getUserPermissions(userId: string) {
|
||||
return this.rbacRepository.findPermissionsByUserId(userId);
|
||||
}
|
||||
|
||||
async countAdmins() {
|
||||
return this.rbacRepository.countAdmins();
|
||||
}
|
||||
|
||||
async assignRoleToUser(userId: string, roleSlug: string) {
|
||||
return this.rbacRepository.assignRole(userId, roleSlug);
|
||||
}
|
||||
}
|
||||
90
backend/src/auth/repositories/rbac.repository.ts
Normal file
90
backend/src/auth/repositories/rbac.repository.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { DatabaseService } from "../../database/database.service";
|
||||
import {
|
||||
permissions,
|
||||
roles,
|
||||
rolesToPermissions,
|
||||
usersToRoles,
|
||||
} from "../../database/schemas";
|
||||
|
||||
@Injectable()
|
||||
export class RbacRepository {
|
||||
constructor(private readonly databaseService: DatabaseService) {}
|
||||
|
||||
async findRolesByUserId(userId: string) {
|
||||
const result = await this.databaseService.db
|
||||
.select({
|
||||
slug: roles.slug,
|
||||
})
|
||||
.from(usersToRoles)
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(eq(usersToRoles.userId, userId));
|
||||
|
||||
return result.map((r) => r.slug);
|
||||
}
|
||||
|
||||
async findPermissionsByUserId(userId: string) {
|
||||
const result = await this.databaseService.db
|
||||
.select({
|
||||
slug: permissions.slug,
|
||||
})
|
||||
.from(usersToRoles)
|
||||
.innerJoin(
|
||||
rolesToPermissions,
|
||||
eq(usersToRoles.roleId, rolesToPermissions.roleId),
|
||||
)
|
||||
.innerJoin(permissions, eq(rolesToPermissions.permissionId, permissions.id))
|
||||
.where(eq(usersToRoles.userId, userId));
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
18
backend/src/auth/session.config.ts
Normal file
18
backend/src/auth/session.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { SessionOptions } from "iron-session";
|
||||
|
||||
export interface SessionData {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export const getSessionOptions = (password: string): SessionOptions => ({
|
||||
password,
|
||||
cookieName: "memegoat_session",
|
||||
cookieOptions: {
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||
},
|
||||
});
|
||||
105
backend/src/categories/categories.controller.spec.ts
Normal file
105
backend/src/categories/categories.controller.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
57
backend/src/categories/categories.controller.ts
Normal file
57
backend/src/categories/categories.controller.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { CacheInterceptor, CacheKey, CacheTTL } from "@nestjs/cache-manager";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { Roles } from "../auth/decorators/roles.decorator";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { RolesGuard } from "../auth/guards/roles.guard";
|
||||
import { CategoriesService } from "./categories.service";
|
||||
import { CreateCategoryDto } from "./dto/create-category.dto";
|
||||
import { UpdateCategoryDto } from "./dto/update-category.dto";
|
||||
|
||||
@Controller("categories")
|
||||
export class CategoriesController {
|
||||
constructor(private readonly categoriesService: CategoriesService) {}
|
||||
|
||||
@Get()
|
||||
@UseInterceptors(CacheInterceptor)
|
||||
@CacheKey("categories/all")
|
||||
@CacheTTL(3600000) // 1 heure
|
||||
findAll() {
|
||||
return this.categoriesService.findAll();
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
findOne(@Param("id") id: string) {
|
||||
return this.categoriesService.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
@Roles("admin")
|
||||
create(@Body() createCategoryDto: CreateCategoryDto) {
|
||||
return this.categoriesService.create(createCategoryDto);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
@Roles("admin")
|
||||
update(@Param("id") id: string, @Body() updateCategoryDto: UpdateCategoryDto) {
|
||||
return this.categoriesService.update(id, updateCategoryDto);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
@Roles("admin")
|
||||
remove(@Param("id") id: string) {
|
||||
return this.categoriesService.remove(id);
|
||||
}
|
||||
}
|
||||
13
backend/src/categories/categories.module.ts
Normal file
13
backend/src/categories/categories.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { CategoriesController } from "./categories.controller";
|
||||
import { CategoriesService } from "./categories.service";
|
||||
import { CategoriesRepository } from "./repositories/categories.repository";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [CategoriesController],
|
||||
providers: [CategoriesService, CategoriesRepository],
|
||||
exports: [CategoriesService, CategoriesRepository],
|
||||
})
|
||||
export class CategoriesModule {}
|
||||
124
backend/src/categories/categories.service.spec.ts
Normal file
124
backend/src/categories/categories.service.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { CategoriesService } from "./categories.service";
|
||||
import { CreateCategoryDto } from "./dto/create-category.dto";
|
||||
import { UpdateCategoryDto } from "./dto/update-category.dto";
|
||||
import { CategoriesRepository } from "./repositories/categories.repository";
|
||||
|
||||
describe("CategoriesService", () => {
|
||||
let service: CategoriesService;
|
||||
let repository: CategoriesRepository;
|
||||
|
||||
const mockCategoriesRepository = {
|
||||
findAll: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
};
|
||||
|
||||
const mockCacheManager = {
|
||||
del: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
CategoriesService,
|
||||
{
|
||||
provide: CategoriesRepository,
|
||||
useValue: mockCategoriesRepository,
|
||||
},
|
||||
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<CategoriesService>(CategoriesService);
|
||||
repository = module.get<CategoriesRepository>(CategoriesRepository);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe("findAll", () => {
|
||||
it("should return all categories ordered by name", async () => {
|
||||
const mockCategories = [{ name: "A" }, { name: "B" }];
|
||||
mockCategoriesRepository.findAll.mockResolvedValue(mockCategories);
|
||||
|
||||
const result = await service.findAll();
|
||||
|
||||
expect(result).toEqual(mockCategories);
|
||||
expect(repository.findAll).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOne", () => {
|
||||
it("should return a category by id", async () => {
|
||||
const mockCategory = { id: "1", name: "Cat" };
|
||||
mockCategoriesRepository.findOne.mockResolvedValue(mockCategory);
|
||||
|
||||
const result = await service.findOne("1");
|
||||
|
||||
expect(result).toEqual(mockCategory);
|
||||
expect(repository.findOne).toHaveBeenCalledWith("1");
|
||||
});
|
||||
|
||||
it("should return null if category not found", async () => {
|
||||
mockCategoriesRepository.findOne.mockResolvedValue(null);
|
||||
const result = await service.findOne("999");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should create a category and generate slug", async () => {
|
||||
const dto: CreateCategoryDto = { name: "Test Category" };
|
||||
mockCategoriesRepository.create.mockResolvedValue([
|
||||
{ ...dto, slug: "test-category" },
|
||||
]);
|
||||
|
||||
const result = await service.create(dto);
|
||||
|
||||
expect(repository.create).toHaveBeenCalledWith({
|
||||
name: "Test Category",
|
||||
slug: "test-category",
|
||||
});
|
||||
expect(result[0].slug).toBe("test-category");
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("should update a category and regenerate slug", async () => {
|
||||
const id = "1";
|
||||
const dto: UpdateCategoryDto = { name: "New Name" };
|
||||
mockCategoriesRepository.update.mockResolvedValue([
|
||||
{ id, ...dto, slug: "new-name" },
|
||||
]);
|
||||
|
||||
const result = await service.update(id, dto);
|
||||
|
||||
expect(repository.update).toHaveBeenCalledWith(
|
||||
id,
|
||||
expect.objectContaining({
|
||||
name: "New Name",
|
||||
slug: "new-name",
|
||||
}),
|
||||
);
|
||||
expect(result[0].slug).toBe("new-name");
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove", () => {
|
||||
it("should remove a category", async () => {
|
||||
const id = "1";
|
||||
mockCategoriesRepository.remove.mockResolvedValue([{ id }]);
|
||||
|
||||
const result = await service.remove(id);
|
||||
|
||||
expect(repository.remove).toHaveBeenCalledWith(id);
|
||||
expect(result).toEqual([{ id }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
67
backend/src/categories/categories.service.ts
Normal file
67
backend/src/categories/categories.service.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
||||
import type { Cache } from "cache-manager";
|
||||
import { CreateCategoryDto } from "./dto/create-category.dto";
|
||||
import { UpdateCategoryDto } from "./dto/update-category.dto";
|
||||
import { CategoriesRepository } from "./repositories/categories.repository";
|
||||
|
||||
@Injectable()
|
||||
export class CategoriesService {
|
||||
private readonly logger = new Logger(CategoriesService.name);
|
||||
|
||||
constructor(
|
||||
private readonly categoriesRepository: CategoriesRepository,
|
||||
@Inject(CACHE_MANAGER) private cacheManager: Cache,
|
||||
) {}
|
||||
|
||||
private async clearCategoriesCache() {
|
||||
this.logger.log("Clearing categories cache");
|
||||
await this.cacheManager.del("categories/all");
|
||||
}
|
||||
|
||||
async findAll() {
|
||||
return await this.categoriesRepository.findAll();
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
return await this.categoriesRepository.findOne(id);
|
||||
}
|
||||
|
||||
async create(data: CreateCategoryDto) {
|
||||
this.logger.log(`Creating category: ${data.name}`);
|
||||
const slug = data.name
|
||||
.toLowerCase()
|
||||
.replace(/ /g, "-")
|
||||
.replace(/[^\w-]/g, "");
|
||||
const result = await this.categoriesRepository.create({ ...data, slug });
|
||||
|
||||
await this.clearCategoriesCache();
|
||||
return result;
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateCategoryDto) {
|
||||
this.logger.log(`Updating category: ${id}`);
|
||||
const updateData = {
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
slug: data.name
|
||||
? data.name
|
||||
.toLowerCase()
|
||||
.replace(/ /g, "-")
|
||||
.replace(/[^\w-]/g, "")
|
||||
: undefined,
|
||||
};
|
||||
const result = await this.categoriesRepository.update(id, updateData);
|
||||
|
||||
await this.clearCategoriesCache();
|
||||
return result;
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
this.logger.log(`Removing category: ${id}`);
|
||||
const result = await this.categoriesRepository.remove(id);
|
||||
|
||||
await this.clearCategoriesCache();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
18
backend/src/categories/dto/create-category.dto.ts
Normal file
18
backend/src/categories/dto/create-category.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator";
|
||||
|
||||
export class CreateCategoryDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(64)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
iconUrl?: string;
|
||||
}
|
||||
4
backend/src/categories/dto/update-category.dto.ts
Normal file
4
backend/src/categories/dto/update-category.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from "@nestjs/mapped-types";
|
||||
import { CreateCategoryDto } from "./create-category.dto";
|
||||
|
||||
export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {}
|
||||
@@ -0,0 +1,82 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
60
backend/src/categories/repositories/categories.repository.ts
Normal file
60
backend/src/categories/repositories/categories.repository.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { DatabaseService } from "../../database/database.service";
|
||||
import { categories } from "../../database/schemas";
|
||||
import type { CreateCategoryDto } from "../dto/create-category.dto";
|
||||
import type { UpdateCategoryDto } from "../dto/update-category.dto";
|
||||
|
||||
@Injectable()
|
||||
export class CategoriesRepository {
|
||||
constructor(private readonly databaseService: DatabaseService) {}
|
||||
|
||||
async findAll() {
|
||||
return await this.databaseService.db
|
||||
.select()
|
||||
.from(categories)
|
||||
.orderBy(categories.name);
|
||||
}
|
||||
|
||||
async countAll() {
|
||||
const result = await this.databaseService.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(categories);
|
||||
return Number(result[0].count);
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const result = await this.databaseService.db
|
||||
.select()
|
||||
.from(categories)
|
||||
.where(eq(categories.id, id))
|
||||
.limit(1);
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async create(data: CreateCategoryDto & { slug: string }) {
|
||||
return await this.databaseService.db
|
||||
.insert(categories)
|
||||
.values(data)
|
||||
.returning();
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
data: UpdateCategoryDto & { slug?: string; updatedAt: Date },
|
||||
) {
|
||||
return await this.databaseService.db
|
||||
.update(categories)
|
||||
.set(data)
|
||||
.where(eq(categories.id, id))
|
||||
.returning();
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
return await this.databaseService.db
|
||||
.delete(categories)
|
||||
.where(eq(categories.id, id))
|
||||
.returning();
|
||||
}
|
||||
}
|
||||
21
backend/src/common/common.module.ts
Normal file
21
backend/src/common/common.module.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { forwardRef, Global, Module } from "@nestjs/common";
|
||||
import { ContentsModule } from "../contents/contents.module";
|
||||
import { DatabaseModule } from "../database/database.module";
|
||||
import { ReportsModule } from "../reports/reports.module";
|
||||
import { SessionsModule } from "../sessions/sessions.module";
|
||||
import { UsersModule } from "../users/users.module";
|
||||
import { PurgeService } from "./services/purge.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
DatabaseModule,
|
||||
forwardRef(() => SessionsModule),
|
||||
forwardRef(() => ReportsModule),
|
||||
forwardRef(() => UsersModule),
|
||||
forwardRef(() => ContentsModule),
|
||||
],
|
||||
providers: [PurgeService],
|
||||
exports: [PurgeService],
|
||||
})
|
||||
export class CommonModule {}
|
||||
67
backend/src/common/filters/http-exception.filter.ts
Normal file
67
backend/src/common/filters/http-exception.filter.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import * as Sentry from "@sentry/nestjs";
|
||||
import { Request, Response } from "express";
|
||||
|
||||
interface RequestWithUser extends Request {
|
||||
user?: {
|
||||
sub?: string;
|
||||
username?: string;
|
||||
id?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@Catch()
|
||||
export class AllExceptionsFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger("ExceptionFilter");
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<RequestWithUser>();
|
||||
|
||||
const status =
|
||||
exception instanceof HttpException
|
||||
? exception.getStatus()
|
||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
const message =
|
||||
exception instanceof HttpException
|
||||
? exception.getResponse()
|
||||
: "Internal server error";
|
||||
|
||||
const userId = request.user?.sub || request.user?.id;
|
||||
const userPart = userId ? `[User: ${userId}] ` : "";
|
||||
|
||||
const errorResponse = {
|
||||
statusCode: status,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
method: request.method,
|
||||
message:
|
||||
typeof message === "object" && message !== null
|
||||
? (message as Record<string, unknown>).message || message
|
||||
: message,
|
||||
};
|
||||
|
||||
if (status === HttpStatus.INTERNAL_SERVER_ERROR) {
|
||||
Sentry.captureException(exception);
|
||||
this.logger.error(
|
||||
`${userPart}${request.method} ${request.url} - Error: ${exception instanceof Error ? exception.message : "Unknown error"}`,
|
||||
exception instanceof Error ? exception.stack : "",
|
||||
);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`${userPart}${request.method} ${request.url} - Status: ${status} - Message: ${JSON.stringify(message)}`,
|
||||
);
|
||||
}
|
||||
|
||||
response.status(status).json(errorResponse);
|
||||
}
|
||||
}
|
||||
4
backend/src/common/interfaces/mail.interface.ts
Normal file
4
backend/src/common/interfaces/mail.interface.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface IMailService {
|
||||
sendEmailValidation(email: string, token: string): Promise<void>;
|
||||
sendPasswordReset(email: string, token: string): Promise<void>;
|
||||
}
|
||||
26
backend/src/common/interfaces/media.interface.ts
Normal file
26
backend/src/common/interfaces/media.interface.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export interface MediaProcessingResult {
|
||||
buffer: Buffer;
|
||||
mimeType: string;
|
||||
extension: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
isInfected: boolean;
|
||||
virusName?: string;
|
||||
}
|
||||
|
||||
export interface 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>;
|
||||
}
|
||||
8
backend/src/common/interfaces/request.interface.ts
Normal file
8
backend/src/common/interfaces/request.interface.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Request } from "express";
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user: {
|
||||
sub: string;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
38
backend/src/common/interfaces/storage.interface.ts
Normal file
38
backend/src/common/interfaces/storage.interface.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Readable } from "node:stream";
|
||||
|
||||
export interface 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;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Injectable, Logger, NestMiddleware } from "@nestjs/common";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
|
||||
@Injectable()
|
||||
export class CrawlerDetectionMiddleware implements NestMiddleware {
|
||||
private readonly logger = new Logger("CrawlerDetection");
|
||||
|
||||
private readonly SUSPICIOUS_PATTERNS = [
|
||||
/\.env/,
|
||||
/wp-admin/,
|
||||
/wp-login/,
|
||||
/\.git/,
|
||||
/\.php$/,
|
||||
/xmlrpc/,
|
||||
/config/,
|
||||
/setup/,
|
||||
/wp-config/,
|
||||
/_next/,
|
||||
/install/,
|
||||
/admin/,
|
||||
/phpmyadmin/,
|
||||
/sql/,
|
||||
/backup/,
|
||||
/db\./,
|
||||
/backup\./,
|
||||
/cgi-bin/,
|
||||
/\.well-known\/security\.txt/, // Bien que légitime, souvent scanné
|
||||
];
|
||||
|
||||
private readonly BOT_USER_AGENTS = [
|
||||
/bot/i,
|
||||
/crawler/i,
|
||||
/spider/i,
|
||||
/python/i,
|
||||
/curl/i,
|
||||
/wget/i,
|
||||
/nmap/i,
|
||||
/nikto/i,
|
||||
/zgrab/i,
|
||||
/masscan/i,
|
||||
];
|
||||
|
||||
use(req: Request, res: Response, next: NextFunction) {
|
||||
const { method, url, ip } = req;
|
||||
const userAgent = req.get("user-agent") || "unknown";
|
||||
|
||||
res.on("finish", () => {
|
||||
if (res.statusCode === 404) {
|
||||
const isSuspiciousPath = this.SUSPICIOUS_PATTERNS.some((pattern) =>
|
||||
pattern.test(url),
|
||||
);
|
||||
const isBotUserAgent = this.BOT_USER_AGENTS.some((pattern) =>
|
||||
pattern.test(userAgent),
|
||||
);
|
||||
|
||||
if (isSuspiciousPath || isBotUserAgent) {
|
||||
this.logger.warn(
|
||||
`Potential crawler detected: [${ip}] ${method} ${url} - User-Agent: ${userAgent}`,
|
||||
);
|
||||
// Ici, on pourrait ajouter une logique pour bannir l'IP temporairement via Redis
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
}
|
||||
37
backend/src/common/middlewares/http-logger.middleware.ts
Normal file
37
backend/src/common/middlewares/http-logger.middleware.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
65
backend/src/common/services/purge.service.spec.ts
Normal file
65
backend/src/common/services/purge.service.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { ContentsRepository } from "../../contents/repositories/contents.repository";
|
||||
import { ReportsRepository } from "../../reports/repositories/reports.repository";
|
||||
import { SessionsRepository } from "../../sessions/repositories/sessions.repository";
|
||||
import { UsersRepository } from "../../users/repositories/users.repository";
|
||||
import { PurgeService } from "./purge.service";
|
||||
|
||||
describe("PurgeService", () => {
|
||||
let service: PurgeService;
|
||||
|
||||
const mockSessionsRepository = {
|
||||
purgeExpired: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const mockReportsRepository = {
|
||||
purgeObsolete: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const mockUsersRepository = { purgeDeleted: jest.fn().mockResolvedValue([]) };
|
||||
const mockContentsRepository = {
|
||||
purgeSoftDeleted: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(Logger.prototype, "error").mockImplementation(() => {});
|
||||
jest.spyOn(Logger.prototype, "log").mockImplementation(() => {});
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
PurgeService,
|
||||
{ provide: SessionsRepository, useValue: mockSessionsRepository },
|
||||
{ provide: ReportsRepository, useValue: mockReportsRepository },
|
||||
{ provide: UsersRepository, useValue: mockUsersRepository },
|
||||
{ provide: ContentsRepository, useValue: mockContentsRepository },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<PurgeService>(PurgeService);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe("purgeExpiredData", () => {
|
||||
it("should purge data using repositories", async () => {
|
||||
mockSessionsRepository.purgeExpired.mockResolvedValue([{ id: "s1" }]);
|
||||
mockReportsRepository.purgeObsolete.mockResolvedValue([{ id: "r1" }]);
|
||||
mockUsersRepository.purgeDeleted.mockResolvedValue([{ id: "u1" }]);
|
||||
mockContentsRepository.purgeSoftDeleted.mockResolvedValue([{ id: "c1" }]);
|
||||
|
||||
await service.purgeExpiredData();
|
||||
|
||||
expect(mockSessionsRepository.purgeExpired).toHaveBeenCalled();
|
||||
expect(mockReportsRepository.purgeObsolete).toHaveBeenCalled();
|
||||
expect(mockUsersRepository.purgeDeleted).toHaveBeenCalled();
|
||||
expect(mockContentsRepository.purgeSoftDeleted).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle errors", async () => {
|
||||
mockSessionsRepository.purgeExpired.mockRejectedValue(new Error("Db error"));
|
||||
await expect(service.purgeExpiredData()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
54
backend/src/common/services/purge.service.ts
Normal file
54
backend/src/common/services/purge.service.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { Cron, CronExpression } from "@nestjs/schedule";
|
||||
import { ContentsRepository } from "../../contents/repositories/contents.repository";
|
||||
import { ReportsRepository } from "../../reports/repositories/reports.repository";
|
||||
import { SessionsRepository } from "../../sessions/repositories/sessions.repository";
|
||||
import { UsersRepository } from "../../users/repositories/users.repository";
|
||||
|
||||
@Injectable()
|
||||
export class PurgeService {
|
||||
private readonly logger = new Logger(PurgeService.name);
|
||||
|
||||
constructor(
|
||||
private readonly sessionsRepository: SessionsRepository,
|
||||
private readonly reportsRepository: ReportsRepository,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly contentsRepository: ContentsRepository,
|
||||
) {}
|
||||
|
||||
// Toutes les nuits à minuit
|
||||
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||||
async purgeExpiredData() {
|
||||
this.logger.log("Starting automatic data purge...");
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
|
||||
// 1. Purge des sessions expirées
|
||||
const deletedSessions = await this.sessionsRepository.purgeExpired(now);
|
||||
this.logger.log(`Purged ${deletedSessions.length} expired sessions.`);
|
||||
|
||||
// 2. Purge des signalements obsolètes
|
||||
const deletedReports = await this.reportsRepository.purgeObsolete(now);
|
||||
this.logger.log(`Purged ${deletedReports.length} obsolete reports.`);
|
||||
|
||||
// 3. Purge des utilisateurs supprimés (Soft Delete > 30 jours)
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
const deletedUsers = await this.usersRepository.purgeDeleted(thirtyDaysAgo);
|
||||
this.logger.log(
|
||||
`Purged ${deletedUsers.length} users marked for deletion more than 30 days ago.`,
|
||||
);
|
||||
|
||||
// 4. Purge des contenus supprimés (Soft Delete > 30 jours)
|
||||
const deletedContents =
|
||||
await this.contentsRepository.purgeSoftDeleted(thirtyDaysAgo);
|
||||
this.logger.log(
|
||||
`Purged ${deletedContents.length} contents marked for deletion more than 30 days ago.`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error("Error during data purge", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
65
backend/src/config/env.schema.ts
Normal file
65
backend/src/config/env.schema.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const envSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||
PORT: z.coerce.number().default(3000),
|
||||
|
||||
// Database
|
||||
POSTGRES_HOST: z.string(),
|
||||
POSTGRES_PORT: z.coerce.number().default(5432),
|
||||
POSTGRES_DB: z.string(),
|
||||
POSTGRES_USER: z.string(),
|
||||
POSTGRES_PASSWORD: z.string(),
|
||||
|
||||
// S3
|
||||
S3_ENDPOINT: z.string().default("localhost"),
|
||||
S3_PORT: z.coerce.number().default(9000),
|
||||
S3_USE_SSL: z.preprocess((val) => val === "true", z.boolean()).default(false),
|
||||
S3_ACCESS_KEY: z.string().default("minioadmin"),
|
||||
S3_SECRET_KEY: z.string().default("minioadmin"),
|
||||
S3_BUCKET_NAME: z.string().default("memegoat"),
|
||||
|
||||
// Security
|
||||
JWT_SECRET: z.string().min(32),
|
||||
ENCRYPTION_KEY: z.string().length(32),
|
||||
PGP_ENCRYPTION_KEY: z.string().min(16),
|
||||
|
||||
// Mail
|
||||
MAIL_HOST: z.string(),
|
||||
MAIL_PORT: z.coerce.number(),
|
||||
MAIL_SECURE: z.preprocess((val) => val === "true", z.boolean()).default(false),
|
||||
MAIL_USER: z.string(),
|
||||
MAIL_PASS: z.string(),
|
||||
MAIL_FROM: z.string().email(),
|
||||
|
||||
DOMAIN_NAME: z.string(),
|
||||
API_URL: z.string().url().optional(),
|
||||
|
||||
// Sentry
|
||||
SENTRY_DSN: z.string().optional(),
|
||||
|
||||
// Redis
|
||||
REDIS_HOST: z.string().default("localhost"),
|
||||
REDIS_PORT: z.coerce.number().default(6379),
|
||||
|
||||
// Session
|
||||
SESSION_PASSWORD: z.string().min(32),
|
||||
|
||||
// Media Limits
|
||||
MAX_IMAGE_SIZE_KB: z.coerce.number().default(512),
|
||||
MAX_GIF_SIZE_KB: z.coerce.number().default(1024),
|
||||
MAX_VIDEO_SIZE_KB: z.coerce.number().default(10240),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
export function validateEnv(config: Record<string, unknown>) {
|
||||
const result = envSchema.safeParse(config);
|
||||
|
||||
if (!result.success) {
|
||||
console.error("❌ Invalid environment variables:", result.error.format());
|
||||
throw new Error("Invalid environment variables");
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
230
backend/src/contents/contents.controller.spec.ts
Normal file
230
backend/src/contents/contents.controller.spec.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
206
backend/src/contents/contents.controller.ts
Normal file
206
backend/src/contents/contents.controller.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { CacheInterceptor, CacheTTL } from "@nestjs/cache-manager";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
DefaultValuePipe,
|
||||
Delete,
|
||||
Get,
|
||||
Header,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseBoolPipe,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import type { Response } from "express";
|
||||
import { Roles } from "../auth/decorators/roles.decorator";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { OptionalAuthGuard } from "../auth/guards/optional-auth.guard";
|
||||
import { RolesGuard } from "../auth/guards/roles.guard";
|
||||
import type { AuthenticatedRequest } from "../common/interfaces/request.interface";
|
||||
import { ContentsService } from "./contents.service";
|
||||
import { CreateContentDto } from "./dto/create-content.dto";
|
||||
import { UploadContentDto } from "./dto/upload-content.dto";
|
||||
|
||||
@Controller("contents")
|
||||
export class ContentsController {
|
||||
constructor(private readonly contentsService: ContentsService) {}
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard)
|
||||
create(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Body() createContentDto: CreateContentDto,
|
||||
) {
|
||||
return this.contentsService.create(req.user.sub, createContentDto);
|
||||
}
|
||||
|
||||
@Post("upload-url")
|
||||
@UseGuards(AuthGuard)
|
||||
getUploadUrl(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Query("fileName") fileName: string,
|
||||
) {
|
||||
return this.contentsService.getUploadUrl(req.user.sub, fileName);
|
||||
}
|
||||
|
||||
@Post("upload")
|
||||
@UseGuards(AuthGuard)
|
||||
@UseInterceptors(FileInterceptor("file"))
|
||||
upload(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@UploadedFile()
|
||||
file: Express.Multer.File,
|
||||
@Body() uploadContentDto: UploadContentDto,
|
||||
) {
|
||||
return this.contentsService.uploadAndProcess(
|
||||
req.user.sub,
|
||||
file,
|
||||
uploadContentDto,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("explore")
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@UseInterceptors(CacheInterceptor)
|
||||
@CacheTTL(60)
|
||||
@Header("Cache-Control", "public, max-age=60")
|
||||
explore(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
|
||||
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
|
||||
@Query("sort") sort?: "trend" | "recent",
|
||||
@Query("tag") tag?: string,
|
||||
@Query("category") category?: string,
|
||||
@Query("author") author?: string,
|
||||
@Query("query") query?: string,
|
||||
@Query("favoritesOnly", new DefaultValuePipe(false), ParseBoolPipe)
|
||||
favoritesOnly?: boolean,
|
||||
@Query("userId") userIdQuery?: string,
|
||||
) {
|
||||
return this.contentsService.findAll({
|
||||
limit,
|
||||
offset,
|
||||
sortBy: sort,
|
||||
tag,
|
||||
category,
|
||||
author,
|
||||
query,
|
||||
favoritesOnly,
|
||||
userId: userIdQuery || req.user?.sub,
|
||||
});
|
||||
}
|
||||
|
||||
@Get("trends")
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@UseInterceptors(CacheInterceptor)
|
||||
@CacheTTL(300)
|
||||
@Header("Cache-Control", "public, max-age=300")
|
||||
trends(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
|
||||
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
|
||||
) {
|
||||
return this.contentsService.findAll({
|
||||
limit,
|
||||
offset,
|
||||
sortBy: "trend",
|
||||
userId: req.user?.sub,
|
||||
});
|
||||
}
|
||||
|
||||
@Get("recent")
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@UseInterceptors(CacheInterceptor)
|
||||
@CacheTTL(60)
|
||||
@Header("Cache-Control", "public, max-age=60")
|
||||
recent(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
|
||||
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
|
||||
) {
|
||||
return this.contentsService.findAll({
|
||||
limit,
|
||||
offset,
|
||||
sortBy: "recent",
|
||||
userId: req.user?.sub,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(":idOrSlug")
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@UseInterceptors(CacheInterceptor)
|
||||
@CacheTTL(3600)
|
||||
@Header("Cache-Control", "public, max-age=3600")
|
||||
async findOne(
|
||||
@Param("idOrSlug") idOrSlug: string,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const content = await this.contentsService.findOne(idOrSlug, req.user?.sub);
|
||||
if (!content) {
|
||||
throw new NotFoundException("Contenu non trouvé");
|
||||
}
|
||||
|
||||
const userAgent = req.headers["user-agent"] || "";
|
||||
const isBot =
|
||||
/bot|googlebot|crawler|spider|robot|crawling|facebookexternalhit|twitterbot/i.test(
|
||||
userAgent,
|
||||
);
|
||||
|
||||
if (isBot) {
|
||||
const html = this.contentsService.generateBotHtml(content);
|
||||
return res.send(html);
|
||||
}
|
||||
|
||||
return res.json(content);
|
||||
}
|
||||
|
||||
@Post(":id/view")
|
||||
incrementViews(@Param("id") id: string) {
|
||||
return this.contentsService.incrementViews(id);
|
||||
}
|
||||
|
||||
@Post(":id/use")
|
||||
incrementUsage(@Param("id") id: string) {
|
||||
return this.contentsService.incrementUsage(id);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@UseGuards(AuthGuard)
|
||||
update(
|
||||
@Param("id") id: string,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Body() updateContentDto: any,
|
||||
) {
|
||||
return this.contentsService.update(id, req.user.sub, updateContentDto);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@UseGuards(AuthGuard)
|
||||
remove(@Param("id") id: string, @Req() req: AuthenticatedRequest) {
|
||||
return this.contentsService.remove(id, req.user.sub);
|
||||
}
|
||||
|
||||
@Delete(":id/admin")
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
@Roles("admin")
|
||||
removeAdmin(@Param("id") id: string) {
|
||||
return this.contentsService.removeAdmin(id);
|
||||
}
|
||||
|
||||
@Patch(":id/admin")
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
@Roles("admin")
|
||||
updateAdmin(@Param("id") id: string, @Body() updateContentDto: any) {
|
||||
return this.contentsService.updateAdmin(id, updateContentDto);
|
||||
}
|
||||
}
|
||||
15
backend/src/contents/contents.module.ts
Normal file
15
backend/src/contents/contents.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { MediaModule } from "../media/media.module";
|
||||
import { S3Module } from "../s3/s3.module";
|
||||
import { ContentsController } from "./contents.controller";
|
||||
import { ContentsService } from "./contents.service";
|
||||
import { ContentsRepository } from "./repositories/contents.repository";
|
||||
|
||||
@Module({
|
||||
imports: [S3Module, AuthModule, MediaModule],
|
||||
controllers: [ContentsController],
|
||||
providers: [ContentsService, ContentsRepository],
|
||||
exports: [ContentsRepository],
|
||||
})
|
||||
export class ContentsModule {}
|
||||
228
backend/src/contents/contents.service.spec.ts
Normal file
228
backend/src/contents/contents.service.spec.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
jest.mock("uuid", () => ({
|
||||
v4: jest.fn(() => "mocked-uuid"),
|
||||
}));
|
||||
|
||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { MediaService } from "../media/media.service";
|
||||
import { S3Service } from "../s3/s3.service";
|
||||
import { ContentsService } from "./contents.service";
|
||||
import { ContentsRepository } from "./repositories/contents.repository";
|
||||
|
||||
describe("ContentsService", () => {
|
||||
let service: ContentsService;
|
||||
let s3Service: S3Service;
|
||||
let mediaService: MediaService;
|
||||
|
||||
const mockContentsRepository = {
|
||||
findAll: jest.fn(),
|
||||
count: jest.fn(),
|
||||
create: jest.fn(),
|
||||
incrementViews: jest.fn(),
|
||||
incrementUsage: jest.fn(),
|
||||
softDelete: jest.fn(),
|
||||
softDeleteAdmin: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
findBySlug: jest.fn(),
|
||||
};
|
||||
|
||||
const mockS3Service = {
|
||||
getUploadUrl: jest.fn(),
|
||||
uploadFile: jest.fn(),
|
||||
getPublicUrl: jest.fn(),
|
||||
};
|
||||
|
||||
const mockMediaService = {
|
||||
scanFile: jest.fn(),
|
||||
processImage: jest.fn(),
|
||||
processVideo: jest.fn(),
|
||||
};
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest.fn(),
|
||||
};
|
||||
|
||||
const mockCacheManager = {
|
||||
clear: jest.fn(),
|
||||
del: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ContentsService,
|
||||
{ provide: ContentsRepository, useValue: mockContentsRepository },
|
||||
{ provide: S3Service, useValue: mockS3Service },
|
||||
{ provide: MediaService, useValue: mockMediaService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ContentsService>(ContentsService);
|
||||
s3Service = module.get<S3Service>(S3Service);
|
||||
mediaService = module.get<MediaService>(MediaService);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe("getUploadUrl", () => {
|
||||
it("should return an upload URL", async () => {
|
||||
mockS3Service.getUploadUrl.mockResolvedValue("http://s3/url");
|
||||
const result = await service.getUploadUrl("user1", "test.png");
|
||||
expect(result).toHaveProperty("url", "http://s3/url");
|
||||
expect(result).toHaveProperty("key");
|
||||
expect(result.key).toContain("uploads/user1/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("uploadAndProcess", () => {
|
||||
const file = {
|
||||
buffer: Buffer.from("test"),
|
||||
originalname: "test.png",
|
||||
mimetype: "image/png",
|
||||
size: 1000,
|
||||
} as Express.Multer.File;
|
||||
|
||||
it("should upload and process an image", async () => {
|
||||
mockConfigService.get.mockReturnValue(1024); // max size
|
||||
mockMediaService.scanFile.mockResolvedValue({ isInfected: false });
|
||||
mockMediaService.processImage.mockResolvedValue({
|
||||
buffer: Buffer.from("processed"),
|
||||
extension: "webp",
|
||||
mimeType: "image/webp",
|
||||
size: 500,
|
||||
});
|
||||
mockContentsRepository.findBySlug.mockResolvedValue(null);
|
||||
mockContentsRepository.create.mockResolvedValue({ id: "content-id" });
|
||||
|
||||
const result = await service.uploadAndProcess("user1", file, {
|
||||
title: "Meme",
|
||||
type: "meme",
|
||||
});
|
||||
|
||||
expect(mediaService.scanFile).toHaveBeenCalled();
|
||||
expect(mediaService.processImage).toHaveBeenCalled();
|
||||
expect(s3Service.uploadFile).toHaveBeenCalled();
|
||||
expect(result).toEqual({ id: "content-id" });
|
||||
});
|
||||
|
||||
it("should throw if file is infected", async () => {
|
||||
mockConfigService.get.mockReturnValue(1024);
|
||||
mockMediaService.scanFile.mockResolvedValue({
|
||||
isInfected: true,
|
||||
virusName: "Eicar",
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.uploadAndProcess("user1", file, { title: "X", type: "meme" }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findAll", () => {
|
||||
it("should return contents and total count", async () => {
|
||||
mockContentsRepository.count.mockResolvedValue(10);
|
||||
mockContentsRepository.findAll.mockResolvedValue([{ id: "1" }]);
|
||||
|
||||
const result = await service.findAll({ limit: 10, offset: 0 });
|
||||
|
||||
expect(result.totalCount).toBe(10);
|
||||
expect(result.data).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("incrementViews", () => {
|
||||
it("should increment views", async () => {
|
||||
mockContentsRepository.incrementViews.mockResolvedValue([
|
||||
{ id: "1", views: 1 },
|
||||
]);
|
||||
const result = await service.incrementViews("1");
|
||||
expect(mockContentsRepository.incrementViews).toHaveBeenCalledWith("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");
|
||||
});
|
||||
});
|
||||
});
|
||||
289
backend/src/contents/contents.service.ts
Normal file
289
backend/src/contents/contents.service.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import type { Cache } from "cache-manager";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type {
|
||||
IMediaService,
|
||||
MediaProcessingResult,
|
||||
} from "../common/interfaces/media.interface";
|
||||
import type { IStorageService } from "../common/interfaces/storage.interface";
|
||||
import { MediaService } from "../media/media.service";
|
||||
import { S3Service } from "../s3/s3.service";
|
||||
import { CreateContentDto } from "./dto/create-content.dto";
|
||||
import { UploadContentDto } from "./dto/upload-content.dto";
|
||||
import { ContentsRepository } from "./repositories/contents.repository";
|
||||
|
||||
@Injectable()
|
||||
export class ContentsService {
|
||||
private readonly logger = new Logger(ContentsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly contentsRepository: ContentsRepository,
|
||||
@Inject(S3Service) private readonly s3Service: IStorageService,
|
||||
@Inject(MediaService) private readonly mediaService: IMediaService,
|
||||
private readonly configService: ConfigService,
|
||||
@Inject(CACHE_MANAGER) private cacheManager: Cache,
|
||||
) {}
|
||||
|
||||
private async clearContentsCache() {
|
||||
this.logger.log("Clearing contents cache");
|
||||
await this.cacheManager.clear();
|
||||
}
|
||||
|
||||
async getUploadUrl(userId: string, fileName: string) {
|
||||
const key = `uploads/${userId}/${Date.now()}-${fileName}`;
|
||||
const url = await this.s3Service.getUploadUrl(key);
|
||||
return { url, key };
|
||||
}
|
||||
|
||||
async uploadAndProcess(
|
||||
userId: string,
|
||||
file: Express.Multer.File,
|
||||
data: UploadContentDto,
|
||||
) {
|
||||
this.logger.log(`Uploading and processing file for user ${userId}`);
|
||||
// 0. Validation du format et de la taille
|
||||
const allowedMimeTypes = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
"video/webm",
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
];
|
||||
|
||||
if (!allowedMimeTypes.includes(file.mimetype)) {
|
||||
throw new BadRequestException(
|
||||
"Format de fichier non supporté. Formats acceptés: png, jpeg, jpg, webp, webm, mp4, mov, gif.",
|
||||
);
|
||||
}
|
||||
|
||||
const isGif = file.mimetype === "image/gif";
|
||||
const isVideo = file.mimetype.startsWith("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) {
|
||||
throw new BadRequestException(
|
||||
`Fichier trop volumineux. Limite pour ${isGif ? "GIF" : isVideo ? "vidéo" : "image"}: ${maxSizeKb} Ko.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Scan Antivirus
|
||||
const scanResult = await this.mediaService.scanFile(
|
||||
file.buffer,
|
||||
file.originalname,
|
||||
);
|
||||
if (scanResult.isInfected) {
|
||||
throw new BadRequestException(
|
||||
`Le fichier est infecté par ${scanResult.virusName}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Transcodage
|
||||
let processed: MediaProcessingResult;
|
||||
if (file.mimetype.startsWith("image/") && file.mimetype !== "image/gif") {
|
||||
// Image -> WebP (format moderne, bien supporté)
|
||||
processed = await this.mediaService.processImage(file.buffer, "webp");
|
||||
} else if (
|
||||
file.mimetype.startsWith("video/") ||
|
||||
file.mimetype === "image/gif"
|
||||
) {
|
||||
// Vidéo ou GIF -> WebM
|
||||
processed = await this.mediaService.processVideo(file.buffer, "webm");
|
||||
} else {
|
||||
throw new BadRequestException("Format de fichier non supporté");
|
||||
}
|
||||
|
||||
// 3. Upload vers S3
|
||||
const key = `contents/${userId}/${Date.now()}-${uuidv4()}.${processed.extension}`;
|
||||
await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType);
|
||||
this.logger.log(`File uploaded successfully to S3: ${key}`);
|
||||
|
||||
// 4. Création en base de données
|
||||
return await this.create(userId, {
|
||||
...data,
|
||||
storageKey: key,
|
||||
mimeType: processed.mimeType,
|
||||
fileSize: processed.size,
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(options: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
sortBy?: "trend" | "recent";
|
||||
tag?: string;
|
||||
category?: string; // Slug ou ID
|
||||
author?: string;
|
||||
query?: string;
|
||||
favoritesOnly?: boolean;
|
||||
userId?: string; // Nécessaire si favoritesOnly est vrai
|
||||
}) {
|
||||
const [data, totalCount] = await Promise.all([
|
||||
this.contentsRepository.findAll(options),
|
||||
this.contentsRepository.count(options),
|
||||
]);
|
||||
|
||||
const processedData = data.map((content) => ({
|
||||
...content,
|
||||
url: this.s3Service.getPublicUrl(content.storageKey),
|
||||
author: {
|
||||
...content.author,
|
||||
avatarUrl: content.author?.avatarUrl
|
||||
? this.s3Service.getPublicUrl(content.author.avatarUrl)
|
||||
: null,
|
||||
},
|
||||
}));
|
||||
|
||||
return { data: processedData, totalCount };
|
||||
}
|
||||
|
||||
async create(userId: string, data: CreateContentDto) {
|
||||
this.logger.log(`Creating content for user ${userId}: ${data.title}`);
|
||||
const { tags: tagNames, ...contentData } = data;
|
||||
|
||||
const slug = await this.ensureUniqueSlug(contentData.title);
|
||||
|
||||
const newContent = await this.contentsRepository.create(
|
||||
{ ...contentData, userId, slug },
|
||||
tagNames,
|
||||
);
|
||||
|
||||
await this.clearContentsCache();
|
||||
return newContent;
|
||||
}
|
||||
|
||||
async incrementViews(id: string) {
|
||||
return await this.contentsRepository.incrementViews(id);
|
||||
}
|
||||
|
||||
async incrementUsage(id: string) {
|
||||
return await this.contentsRepository.incrementUsage(id);
|
||||
}
|
||||
|
||||
async remove(id: string, userId: string) {
|
||||
this.logger.log(`Removing content ${id} for user ${userId}`);
|
||||
const deleted = await this.contentsRepository.softDelete(id, userId);
|
||||
|
||||
if (deleted) {
|
||||
await this.clearContentsCache();
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
async removeAdmin(id: string) {
|
||||
this.logger.log(`Removing content ${id} by admin`);
|
||||
const deleted = await this.contentsRepository.softDeleteAdmin(id);
|
||||
|
||||
if (deleted) {
|
||||
await this.clearContentsCache();
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
async updateAdmin(id: string, data: any) {
|
||||
this.logger.log(`Updating content ${id} by admin`);
|
||||
const updated = await this.contentsRepository.update(id, data);
|
||||
|
||||
if (updated) {
|
||||
await this.clearContentsCache();
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, data: any) {
|
||||
this.logger.log(`Updating content ${id} for user ${userId}`);
|
||||
|
||||
// Vérifier que le contenu appartient à l'utilisateur
|
||||
const existing = await this.contentsRepository.findOne(id, userId);
|
||||
if (!existing || existing.userId !== userId) {
|
||||
throw new BadRequestException(
|
||||
"Contenu non trouvé ou vous n'avez pas la permission de le modifier.",
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await this.contentsRepository.update(id, data);
|
||||
|
||||
if (updated) {
|
||||
await this.clearContentsCache();
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async findOne(idOrSlug: string, userId?: string) {
|
||||
const content = await this.contentsRepository.findOne(idOrSlug, userId);
|
||||
if (!content) return null;
|
||||
|
||||
return {
|
||||
...content,
|
||||
url: this.s3Service.getPublicUrl(content.storageKey),
|
||||
author: {
|
||||
...content.author,
|
||||
avatarUrl: content.author?.avatarUrl
|
||||
? this.s3Service.getPublicUrl(content.author.avatarUrl)
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
generateBotHtml(content: { title: string; storageKey: string }): string {
|
||||
const imageUrl = this.s3Service.getPublicUrl(content.storageKey);
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>${content.title}</title>
|
||||
<meta property="og:title" content="${content.title}" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content="${imageUrl}" />
|
||||
<meta property="og:description" content="Découvrez ce meme sur Memegoat" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="${content.title}" />
|
||||
<meta name="twitter:image" content="${imageUrl}" />
|
||||
</head>
|
||||
<body>
|
||||
<h1>${content.title}</h1>
|
||||
<img src="${imageUrl}" alt="${content.title}" />
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private generateSlug(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
.replace(/[\s_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
private async ensureUniqueSlug(title: string): Promise<string> {
|
||||
const baseSlug = this.generateSlug(title) || "content";
|
||||
let slug = baseSlug;
|
||||
let counter = 1;
|
||||
|
||||
while (true) {
|
||||
const existing = await this.contentsRepository.findBySlug(slug);
|
||||
|
||||
if (!existing) break;
|
||||
slug = `${baseSlug}-${counter++}`;
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
}
|
||||
49
backend/src/contents/dto/create-content.dto.ts
Normal file
49
backend/src/contents/dto/create-content.dto.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
} from "class-validator";
|
||||
|
||||
export enum ContentType {
|
||||
MEME = "meme",
|
||||
GIF = "gif",
|
||||
VIDEO = "video",
|
||||
}
|
||||
|
||||
export class CreateContentDto {
|
||||
@IsEnum(ContentType)
|
||||
type!: "meme" | "gif" | "video";
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
title!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(512)
|
||||
storageKey!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(128)
|
||||
mimeType!: string;
|
||||
|
||||
@IsInt()
|
||||
fileSize!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@MaxLength(64, { each: true })
|
||||
tags?: string[];
|
||||
}
|
||||
30
backend/src/contents/dto/upload-content.dto.ts
Normal file
30
backend/src/contents/dto/upload-content.dto.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
} from "class-validator";
|
||||
import { ContentType } from "./create-content.dto";
|
||||
|
||||
export class UploadContentDto {
|
||||
@IsEnum(ContentType)
|
||||
type!: "meme" | "gif" | "video";
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@MaxLength(64, { each: true })
|
||||
tags?: string[];
|
||||
}
|
||||
436
backend/src/contents/repositories/contents.repository.ts
Normal file
436
backend/src/contents/repositories/contents.repository.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import {
|
||||
and,
|
||||
desc,
|
||||
eq,
|
||||
exists,
|
||||
ilike,
|
||||
isNull,
|
||||
lte,
|
||||
type SQL,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
import { DatabaseService } from "../../database/database.service";
|
||||
import {
|
||||
categories,
|
||||
contents,
|
||||
contentsToTags,
|
||||
favorites,
|
||||
tags,
|
||||
users,
|
||||
} from "../../database/schemas";
|
||||
import type { NewContentInDb } from "../../database/schemas/content";
|
||||
|
||||
export interface FindAllOptions {
|
||||
limit: number;
|
||||
offset: number;
|
||||
sortBy?: "trend" | "recent";
|
||||
tag?: string;
|
||||
category?: string;
|
||||
author?: string;
|
||||
query?: string;
|
||||
favoritesOnly?: boolean;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContentsRepository {
|
||||
constructor(private readonly databaseService: DatabaseService) {}
|
||||
|
||||
async findAll(options: FindAllOptions) {
|
||||
const {
|
||||
limit,
|
||||
offset,
|
||||
sortBy,
|
||||
tag,
|
||||
category,
|
||||
author,
|
||||
query,
|
||||
favoritesOnly,
|
||||
userId,
|
||||
} = options;
|
||||
|
||||
let whereClause: SQL | undefined = isNull(contents.deletedAt);
|
||||
|
||||
if (tag) {
|
||||
whereClause = and(
|
||||
whereClause,
|
||||
exists(
|
||||
this.databaseService.db
|
||||
.select()
|
||||
.from(contentsToTags)
|
||||
.innerJoin(tags, eq(contentsToTags.tagId, tags.id))
|
||||
.where(
|
||||
and(eq(contentsToTags.contentId, contents.id), eq(tags.name, tag)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (category) {
|
||||
whereClause = and(
|
||||
whereClause,
|
||||
exists(
|
||||
this.databaseService.db
|
||||
.select()
|
||||
.from(categories)
|
||||
.where(
|
||||
and(
|
||||
eq(contents.categoryId, categories.id),
|
||||
sql`(${categories.id}::text = ${category} OR ${categories.slug} = ${category})`,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (author) {
|
||||
whereClause = and(
|
||||
whereClause,
|
||||
exists(
|
||||
this.databaseService.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(
|
||||
and(
|
||||
eq(contents.userId, users.uuid),
|
||||
sql`(${users.uuid}::text = ${author} OR ${users.username} = ${author})`,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (query) {
|
||||
whereClause = and(whereClause, ilike(contents.title, `%${query}%`));
|
||||
}
|
||||
|
||||
if (favoritesOnly && userId) {
|
||||
whereClause = and(
|
||||
whereClause,
|
||||
exists(
|
||||
this.databaseService.db
|
||||
.select()
|
||||
.from(favorites)
|
||||
.where(
|
||||
and(eq(favorites.contentId, contents.id), eq(favorites.userId, userId)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let orderBy = desc(contents.createdAt);
|
||||
if (sortBy === "trend") {
|
||||
orderBy = desc(sql`${contents.views} + ${contents.usageCount} * 2`);
|
||||
}
|
||||
|
||||
const results = await this.databaseService.db
|
||||
.select({
|
||||
id: contents.id,
|
||||
title: contents.title,
|
||||
slug: contents.slug,
|
||||
type: contents.type,
|
||||
storageKey: contents.storageKey,
|
||||
mimeType: contents.mimeType,
|
||||
fileSize: contents.fileSize,
|
||||
views: contents.views,
|
||||
usageCount: contents.usageCount,
|
||||
favoritesCount:
|
||||
sql<number>`(SELECT count(*) FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id})`.mapWith(
|
||||
Number,
|
||||
),
|
||||
isLiked: userId
|
||||
? sql<boolean>`EXISTS(SELECT 1 FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id} AND ${favorites.userId} = ${userId})`
|
||||
: sql<boolean>`false`,
|
||||
createdAt: contents.createdAt,
|
||||
updatedAt: contents.updatedAt,
|
||||
author: {
|
||||
id: users.uuid,
|
||||
username: users.username,
|
||||
displayName: users.displayName,
|
||||
avatarUrl: users.avatarUrl,
|
||||
},
|
||||
category: {
|
||||
id: categories.id,
|
||||
name: categories.name,
|
||||
slug: categories.slug,
|
||||
},
|
||||
})
|
||||
.from(contents)
|
||||
.leftJoin(users, eq(contents.userId, users.uuid))
|
||||
.leftJoin(categories, eq(contents.categoryId, categories.id))
|
||||
.where(whereClause)
|
||||
.orderBy(orderBy)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const contentIds = results.map((r) => r.id);
|
||||
const tagsForContents = contentIds.length
|
||||
? await this.databaseService.db
|
||||
.select({
|
||||
contentId: contentsToTags.contentId,
|
||||
name: tags.name,
|
||||
})
|
||||
.from(contentsToTags)
|
||||
.innerJoin(tags, eq(contentsToTags.tagId, tags.id))
|
||||
.where(sql`${contentsToTags.contentId} IN ${contentIds}`)
|
||||
: [];
|
||||
|
||||
return results.map((r) => ({
|
||||
...r,
|
||||
tags: tagsForContents.filter((t) => t.contentId === r.id).map((t) => t.name),
|
||||
}));
|
||||
}
|
||||
|
||||
async create(data: NewContentInDb & { userId: string }, tagNames?: string[]) {
|
||||
return await this.databaseService.db.transaction(async (tx) => {
|
||||
const [newContent] = await tx.insert(contents).values(data).returning();
|
||||
|
||||
if (tagNames && tagNames.length > 0) {
|
||||
for (const tagName of tagNames) {
|
||||
const slug = tagName
|
||||
.toLowerCase()
|
||||
.replace(/ /g, "-")
|
||||
.replace(/[^\w-]/g, "");
|
||||
|
||||
let [tag] = await tx
|
||||
.select()
|
||||
.from(tags)
|
||||
.where(eq(tags.slug, slug))
|
||||
.limit(1);
|
||||
|
||||
if (!tag) {
|
||||
[tag] = await tx
|
||||
.insert(tags)
|
||||
.values({
|
||||
name: tagName,
|
||||
slug,
|
||||
userId: data.userId,
|
||||
})
|
||||
.returning();
|
||||
}
|
||||
|
||||
await tx
|
||||
.insert(contentsToTags)
|
||||
.values({
|
||||
contentId: newContent.id,
|
||||
tagId: tag.id,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
|
||||
return newContent;
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(idOrSlug: string, userId?: string) {
|
||||
const [result] = await this.databaseService.db
|
||||
.select({
|
||||
id: contents.id,
|
||||
title: contents.title,
|
||||
slug: contents.slug,
|
||||
type: contents.type,
|
||||
storageKey: contents.storageKey,
|
||||
mimeType: contents.mimeType,
|
||||
fileSize: contents.fileSize,
|
||||
views: contents.views,
|
||||
usageCount: contents.usageCount,
|
||||
favoritesCount:
|
||||
sql<number>`(SELECT count(*) FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id})`.mapWith(
|
||||
Number,
|
||||
),
|
||||
isLiked: userId
|
||||
? sql<boolean>`EXISTS(SELECT 1 FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id} AND ${favorites.userId} = ${userId})`
|
||||
: sql<boolean>`false`,
|
||||
createdAt: contents.createdAt,
|
||||
updatedAt: contents.updatedAt,
|
||||
userId: contents.userId,
|
||||
author: {
|
||||
id: users.uuid,
|
||||
username: users.username,
|
||||
displayName: users.displayName,
|
||||
avatarUrl: users.avatarUrl,
|
||||
},
|
||||
category: {
|
||||
id: categories.id,
|
||||
name: categories.name,
|
||||
slug: categories.slug,
|
||||
},
|
||||
})
|
||||
.from(contents)
|
||||
.leftJoin(users, eq(contents.userId, users.uuid))
|
||||
.leftJoin(categories, eq(contents.categoryId, categories.id))
|
||||
.where(
|
||||
and(
|
||||
isNull(contents.deletedAt),
|
||||
sql`(${contents.id}::text = ${idOrSlug} OR ${contents.slug} = ${idOrSlug})`,
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
const tagsForContent = await this.databaseService.db
|
||||
.select({
|
||||
name: tags.name,
|
||||
})
|
||||
.from(contentsToTags)
|
||||
.innerJoin(tags, eq(contentsToTags.tagId, tags.id))
|
||||
.where(eq(contentsToTags.contentId, result.id));
|
||||
|
||||
return {
|
||||
...result,
|
||||
tags: tagsForContent.map((t) => t.name),
|
||||
};
|
||||
}
|
||||
|
||||
async count(options: {
|
||||
tag?: string;
|
||||
category?: string;
|
||||
author?: string;
|
||||
query?: string;
|
||||
favoritesOnly?: boolean;
|
||||
userId?: string;
|
||||
}) {
|
||||
const { tag, category, author, query, favoritesOnly, userId } = options;
|
||||
|
||||
let whereClause: SQL | undefined = isNull(contents.deletedAt);
|
||||
|
||||
if (tag) {
|
||||
whereClause = and(
|
||||
whereClause,
|
||||
exists(
|
||||
this.databaseService.db
|
||||
.select()
|
||||
.from(contentsToTags)
|
||||
.innerJoin(tags, eq(contentsToTags.tagId, tags.id))
|
||||
.where(
|
||||
and(eq(contentsToTags.contentId, contents.id), eq(tags.name, tag)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (category) {
|
||||
whereClause = and(
|
||||
whereClause,
|
||||
exists(
|
||||
this.databaseService.db
|
||||
.select()
|
||||
.from(categories)
|
||||
.where(
|
||||
and(
|
||||
eq(contents.categoryId, categories.id),
|
||||
sql`(${categories.id}::text = ${category} OR ${categories.slug} = ${category})`,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (author) {
|
||||
whereClause = and(
|
||||
whereClause,
|
||||
exists(
|
||||
this.databaseService.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(
|
||||
and(
|
||||
eq(contents.userId, users.uuid),
|
||||
sql`(${users.uuid}::text = ${author} OR ${users.username} = ${author})`,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (query) {
|
||||
whereClause = and(whereClause, ilike(contents.title, `%${query}%`));
|
||||
}
|
||||
|
||||
if (favoritesOnly && userId) {
|
||||
whereClause = and(
|
||||
whereClause,
|
||||
exists(
|
||||
this.databaseService.db
|
||||
.select()
|
||||
.from(favorites)
|
||||
.where(
|
||||
and(eq(favorites.contentId, contents.id), eq(favorites.userId, userId)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const [result] = await this.databaseService.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(contents)
|
||||
.where(whereClause);
|
||||
|
||||
return Number(result.count);
|
||||
}
|
||||
|
||||
async incrementViews(id: string) {
|
||||
await this.databaseService.db
|
||||
.update(contents)
|
||||
.set({ views: sql`${contents.views} + 1` })
|
||||
.where(eq(contents.id, id));
|
||||
}
|
||||
|
||||
async incrementUsage(id: string) {
|
||||
await this.databaseService.db
|
||||
.update(contents)
|
||||
.set({ usageCount: sql`${contents.usageCount} + 1` })
|
||||
.where(eq(contents.id, id));
|
||||
}
|
||||
|
||||
async softDelete(id: string, userId: string) {
|
||||
const [deleted] = await this.databaseService.db
|
||||
.update(contents)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(and(eq(contents.id, id), eq(contents.userId, userId)))
|
||||
.returning();
|
||||
return deleted;
|
||||
}
|
||||
|
||||
async softDeleteAdmin(id: string) {
|
||||
const [deleted] = await this.databaseService.db
|
||||
.update(contents)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(eq(contents.id, id))
|
||||
.returning();
|
||||
return deleted;
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<typeof contents.$inferInsert>) {
|
||||
const [updated] = await this.databaseService.db
|
||||
.update(contents)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(contents.id, id))
|
||||
.returning();
|
||||
return updated;
|
||||
}
|
||||
|
||||
async findBySlug(slug: string) {
|
||||
const [result] = await this.databaseService.db
|
||||
.select()
|
||||
.from(contents)
|
||||
.where(eq(contents.slug, slug))
|
||||
.limit(1);
|
||||
return result;
|
||||
}
|
||||
|
||||
async purgeSoftDeleted(before: Date) {
|
||||
return await this.databaseService.db
|
||||
.delete(contents)
|
||||
.where(
|
||||
and(
|
||||
sql`${contents.deletedAt} IS NOT NULL`,
|
||||
lte(contents.deletedAt, before),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
}
|
||||
}
|
||||
25
backend/src/crypto/crypto.module.ts
Normal file
25
backend/src/crypto/crypto.module.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { CryptoService } from "./crypto.service";
|
||||
import { EncryptionService } from "./services/encryption.service";
|
||||
import { HashingService } from "./services/hashing.service";
|
||||
import { JwtService } from "./services/jwt.service";
|
||||
import { PostQuantumService } from "./services/post-quantum.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
CryptoService,
|
||||
HashingService,
|
||||
JwtService,
|
||||
EncryptionService,
|
||||
PostQuantumService,
|
||||
],
|
||||
exports: [
|
||||
CryptoService,
|
||||
HashingService,
|
||||
JwtService,
|
||||
EncryptionService,
|
||||
PostQuantumService,
|
||||
],
|
||||
})
|
||||
export class CryptoModule {}
|
||||
187
backend/src/crypto/crypto.service.spec.ts
Normal file
187
backend/src/crypto/crypto.service.spec.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
|
||||
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
|
||||
ml_kem768: {
|
||||
keygen: jest.fn(() => ({
|
||||
publicKey: new Uint8Array(1184),
|
||||
secretKey: new Uint8Array(2400),
|
||||
})),
|
||||
encapsulate: jest.fn((_pk: Uint8Array) => ({
|
||||
cipherText: new Uint8Array(1088),
|
||||
sharedSecret: new Uint8Array(32),
|
||||
})),
|
||||
decapsulate: jest.fn(
|
||||
(_ct: Uint8Array, _sk: Uint8Array) => new Uint8Array(32),
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("jose", () => ({
|
||||
generateSecret: jest.fn().mockResolvedValue(new Uint8Array(32)),
|
||||
CompactEncrypt: jest.fn().mockImplementation(() => ({
|
||||
setProtectedHeader: jest.fn().mockReturnThis(),
|
||||
encrypt: jest.fn().mockResolvedValue("mocked.jwe.token.parts.here"),
|
||||
})),
|
||||
compactDecrypt: jest.fn().mockImplementation((jwe) => {
|
||||
if (jwe === "invalid.jwe.content") {
|
||||
throw new Error("Invalid JWE");
|
||||
}
|
||||
return Promise.resolve({
|
||||
plaintext: new TextEncoder().encode("This is a secret message 🤫"),
|
||||
});
|
||||
}),
|
||||
SignJWT: jest.fn().mockImplementation(() => ({
|
||||
setProtectedHeader: jest.fn().mockReturnThis(),
|
||||
setIssuedAt: jest.fn().mockReturnThis(),
|
||||
setExpirationTime: jest.fn().mockReturnThis(),
|
||||
sign: jest.fn().mockResolvedValue("mocked.jwt.token"),
|
||||
})),
|
||||
jwtVerify: jest.fn().mockImplementation((token) => {
|
||||
if (token === "invalid.token.here") {
|
||||
throw new Error("Invalid token");
|
||||
}
|
||||
return Promise.resolve({
|
||||
payload: { sub: "1234567890", name: "John Doe", admin: true },
|
||||
});
|
||||
}),
|
||||
CompactSign: jest.fn().mockImplementation(() => ({
|
||||
setProtectedHeader: jest.fn().mockReturnThis(),
|
||||
sign: jest.fn().mockResolvedValue("mocked.jws.token"),
|
||||
})),
|
||||
compactVerify: jest.fn().mockImplementation((jws) => {
|
||||
if (jws.includes("tampered") || jws.split(".").length !== 3) {
|
||||
throw new Error("Tampered or invalid content");
|
||||
}
|
||||
const payload =
|
||||
jws === "mocked.jws.token"
|
||||
? "Important document content"
|
||||
: "Original content";
|
||||
return Promise.resolve({
|
||||
payload: new TextEncoder().encode(payload),
|
||||
});
|
||||
}),
|
||||
}));
|
||||
|
||||
import { CryptoService } from "./crypto.service";
|
||||
import { EncryptionService } from "./services/encryption.service";
|
||||
import { HashingService } from "./services/hashing.service";
|
||||
import { JwtService } from "./services/jwt.service";
|
||||
import { PostQuantumService } from "./services/post-quantum.service";
|
||||
|
||||
describe("CryptoService", () => {
|
||||
let service: CryptoService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
CryptoService,
|
||||
HashingService,
|
||||
JwtService,
|
||||
EncryptionService,
|
||||
PostQuantumService,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
get: jest.fn().mockReturnValue("test-secret"),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<CryptoService>(CryptoService);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe("Argon2 Password Hashing", () => {
|
||||
it("should hash and verify a password", async () => {
|
||||
const password = "mySecurePassword123!";
|
||||
const hash = await service.hashPassword(password);
|
||||
expect(hash).toBeDefined();
|
||||
expect(hash).not.toBe(password);
|
||||
|
||||
const isValid = await service.verifyPassword(password, hash);
|
||||
expect(isValid).toBe(true);
|
||||
|
||||
const isInvalid = await service.verifyPassword("wrongPassword", hash);
|
||||
expect(isInvalid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("JWT jose", () => {
|
||||
it("should generate and verify a JWT", async () => {
|
||||
const payload = { sub: "1234567890", name: "John Doe", admin: true };
|
||||
const token = await service.generateJwt(payload);
|
||||
expect(token).toBeDefined();
|
||||
|
||||
const verifiedPayload = await service.verifyJwt(token);
|
||||
expect(verifiedPayload.sub).toBe(payload.sub);
|
||||
expect(verifiedPayload.name).toBe(payload.name);
|
||||
expect(verifiedPayload.admin).toBe(payload.admin);
|
||||
});
|
||||
|
||||
it("should throw for invalid token", async () => {
|
||||
await expect(service.verifyJwt("invalid.token.here")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Encryption/Decryption (JWE)", () => {
|
||||
it("should encrypt and decrypt content", async () => {
|
||||
const content = "This is a secret message 🤫";
|
||||
const jwe = await service.encryptContent(content);
|
||||
expect(jwe).toBeDefined();
|
||||
expect(typeof jwe).toBe("string");
|
||||
expect(jwe.split(".").length).toBe(5); // JWE compact serialization has 5 parts
|
||||
|
||||
const decrypted = await service.decryptContent(jwe);
|
||||
expect(decrypted).toBe(content);
|
||||
});
|
||||
|
||||
it("should fail to decrypt invalid content", async () => {
|
||||
await expect(
|
||||
service.decryptContent("invalid.jwe.content"),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Signature (JWS)", () => {
|
||||
it("should sign and verify content signature", async () => {
|
||||
const content = "Important document content";
|
||||
const jws = await service.signContent(content);
|
||||
expect(jws).toBeDefined();
|
||||
expect(typeof jws).toBe("string");
|
||||
expect(jws.split(".").length).toBe(3); // JWS compact serialization has 3 parts
|
||||
|
||||
const verifiedContent = await service.verifyContentSignature(jws);
|
||||
expect(verifiedContent).toBe(content);
|
||||
});
|
||||
|
||||
it("should fail to verify tampered content", async () => {
|
||||
const content = "Original content";
|
||||
const jws = await service.signContent(content);
|
||||
const _parts = jws.split(".");
|
||||
// Tamper with the payload (middle part)
|
||||
const tamperedJws = "this.is.tampered";
|
||||
|
||||
await expect(service.verifyContentSignature(tamperedJws)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Post-Quantum @noble/post-quantum", () => {
|
||||
it("should generate keypair, encapsulate and decapsulate", () => {
|
||||
const { publicKey, secretKey } = service.generatePostQuantumKeyPair();
|
||||
expect(publicKey).toBeDefined();
|
||||
expect(secretKey).toBeDefined();
|
||||
|
||||
const { cipherText, sharedSecret } = service.encapsulate(publicKey);
|
||||
expect(cipherText).toBeDefined();
|
||||
expect(sharedSecret).toBeDefined();
|
||||
|
||||
const decapsulatedSecret = service.decapsulate(cipherText, secretKey);
|
||||
expect(decapsulatedSecret).toEqual(sharedSecret);
|
||||
});
|
||||
});
|
||||
});
|
||||
79
backend/src/crypto/crypto.service.ts
Normal file
79
backend/src/crypto/crypto.service.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type * as jose from "jose";
|
||||
import { EncryptionService } from "./services/encryption.service";
|
||||
import { HashingService } from "./services/hashing.service";
|
||||
import { JwtService } from "./services/jwt.service";
|
||||
import { PostQuantumService } from "./services/post-quantum.service";
|
||||
|
||||
/**
|
||||
* @deprecated Use HashingService, JwtService, EncryptionService or PostQuantumService directly.
|
||||
* This service acts as a Facade for backward compatibility.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CryptoService {
|
||||
constructor(
|
||||
private readonly hashingService: HashingService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly encryptionService: EncryptionService,
|
||||
private readonly postQuantumService: PostQuantumService,
|
||||
) {}
|
||||
|
||||
async hashEmail(email: string): Promise<string> {
|
||||
return this.hashingService.hashEmail(email);
|
||||
}
|
||||
|
||||
async hashIp(ip: string): Promise<string> {
|
||||
return this.hashingService.hashIp(ip);
|
||||
}
|
||||
|
||||
getPgpEncryptionKey(): string {
|
||||
return this.encryptionService.getPgpEncryptionKey();
|
||||
}
|
||||
|
||||
async hashPassword(password: string): Promise<string> {
|
||||
return this.hashingService.hashPassword(password);
|
||||
}
|
||||
|
||||
async verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
return this.hashingService.verifyPassword(password, hash);
|
||||
}
|
||||
|
||||
async generateJwt(
|
||||
payload: jose.JWTPayload,
|
||||
expiresIn = "2h",
|
||||
): Promise<string> {
|
||||
return this.jwtService.generateJwt(payload, expiresIn);
|
||||
}
|
||||
|
||||
async verifyJwt<T extends jose.JWTPayload>(token: string): Promise<T> {
|
||||
return this.jwtService.verifyJwt<T>(token);
|
||||
}
|
||||
|
||||
async encryptContent(content: string): Promise<string> {
|
||||
return this.encryptionService.encryptContent(content);
|
||||
}
|
||||
|
||||
async decryptContent(jwe: string): Promise<string> {
|
||||
return this.encryptionService.decryptContent(jwe);
|
||||
}
|
||||
|
||||
async signContent(content: string): Promise<string> {
|
||||
return this.encryptionService.signContent(content);
|
||||
}
|
||||
|
||||
async verifyContentSignature(jws: string): Promise<string> {
|
||||
return this.encryptionService.verifyContentSignature(jws);
|
||||
}
|
||||
|
||||
generatePostQuantumKeyPair() {
|
||||
return this.postQuantumService.generatePostQuantumKeyPair();
|
||||
}
|
||||
|
||||
encapsulate(publicKey: Uint8Array) {
|
||||
return this.postQuantumService.encapsulate(publicKey);
|
||||
}
|
||||
|
||||
decapsulate(cipherText: Uint8Array, secretKey: Uint8Array) {
|
||||
return this.postQuantumService.decapsulate(cipherText, secretKey);
|
||||
}
|
||||
}
|
||||
58
backend/src/crypto/services/encryption.service.ts
Normal file
58
backend/src/crypto/services/encryption.service.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import * as jose from "jose";
|
||||
|
||||
@Injectable()
|
||||
export class EncryptionService {
|
||||
private readonly logger = new Logger(EncryptionService.name);
|
||||
private readonly jwtSecret: Uint8Array;
|
||||
private readonly encryptionKey: Uint8Array;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
const secret = this.configService.get<string>("JWT_SECRET");
|
||||
this.jwtSecret = new TextEncoder().encode(
|
||||
secret || "default-secret-change-me-in-production",
|
||||
);
|
||||
|
||||
const encKey = this.configService.get<string>("ENCRYPTION_KEY");
|
||||
if (!encKey) {
|
||||
this.logger.warn(
|
||||
"ENCRYPTION_KEY is not defined, using a default insecure key for development",
|
||||
);
|
||||
}
|
||||
const rawKey = encKey || "default-encryption-key-32-chars-";
|
||||
this.encryptionKey = new TextEncoder().encode(
|
||||
rawKey.padEnd(32, "0").substring(0, 32),
|
||||
);
|
||||
}
|
||||
|
||||
async encryptContent(content: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(content);
|
||||
return new jose.CompactEncrypt(data)
|
||||
.setProtectedHeader({ alg: "dir", enc: "A256GCM" })
|
||||
.encrypt(this.encryptionKey);
|
||||
}
|
||||
|
||||
async decryptContent(jwe: string): Promise<string> {
|
||||
const { plaintext } = await jose.compactDecrypt(jwe, this.encryptionKey);
|
||||
return new TextDecoder().decode(plaintext);
|
||||
}
|
||||
|
||||
async signContent(content: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(content);
|
||||
return new jose.CompactSign(data)
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.sign(this.jwtSecret);
|
||||
}
|
||||
|
||||
async verifyContentSignature(jws: string): Promise<string> {
|
||||
const { payload } = await jose.compactVerify(jws, this.jwtSecret);
|
||||
return new TextDecoder().decode(payload);
|
||||
}
|
||||
|
||||
getPgpEncryptionKey(): string {
|
||||
return (
|
||||
this.configService.get<string>("PGP_ENCRYPTION_KEY") || "default-pgp-key"
|
||||
);
|
||||
}
|
||||
}
|
||||
32
backend/src/crypto/services/hashing.service.ts
Normal file
32
backend/src/crypto/services/hashing.service.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { hash, verify } from "@node-rs/argon2";
|
||||
|
||||
@Injectable()
|
||||
export class HashingService {
|
||||
async hashEmail(email: string): Promise<string> {
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
return this.hashSha256(normalizedEmail);
|
||||
}
|
||||
|
||||
async hashIp(ip: string): Promise<string> {
|
||||
return this.hashSha256(ip);
|
||||
}
|
||||
|
||||
async hashSha256(text: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(text);
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
||||
return Array.from(new Uint8Array(hashBuffer))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
async hashPassword(password: string): Promise<string> {
|
||||
return hash(password, {
|
||||
algorithm: 2,
|
||||
});
|
||||
}
|
||||
|
||||
async verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
return verify(hash, password);
|
||||
}
|
||||
}
|
||||
37
backend/src/crypto/services/jwt.service.ts
Normal file
37
backend/src/crypto/services/jwt.service.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import * as jose from "jose";
|
||||
|
||||
@Injectable()
|
||||
export class JwtService {
|
||||
private readonly logger = new Logger(JwtService.name);
|
||||
private readonly jwtSecret: Uint8Array;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
const secret = this.configService.get<string>("JWT_SECRET");
|
||||
if (!secret) {
|
||||
this.logger.warn(
|
||||
"JWT_SECRET is not defined, using a default insecure secret for development",
|
||||
);
|
||||
}
|
||||
this.jwtSecret = new TextEncoder().encode(
|
||||
secret || "default-secret-change-me-in-production",
|
||||
);
|
||||
}
|
||||
|
||||
async generateJwt(
|
||||
payload: jose.JWTPayload,
|
||||
expiresIn = "2h",
|
||||
): Promise<string> {
|
||||
return new jose.SignJWT(payload)
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(expiresIn)
|
||||
.sign(this.jwtSecret);
|
||||
}
|
||||
|
||||
async verifyJwt<T extends jose.JWTPayload>(token: string): Promise<T> {
|
||||
const { payload } = await jose.jwtVerify(token, this.jwtSecret);
|
||||
return payload as T;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user