Compare commits
85 Commits
77ac960411
...
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
@@ -1,25 +0,0 @@
|
|||||||
name: Backend Tests
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- 'backend/**'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- 'backend/**'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: 9
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
cache: 'pnpm'
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install
|
|
||||||
- name: Run Backend Tests
|
|
||||||
run: pnpm -F @memegoat/backend test
|
|
||||||
111
.gitea/workflows/ci.yml
Normal file
111
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# Pipeline CI/CD pour Gitea Actions (Forgejo)
|
||||||
|
# Compatible avec GitHub Actions pour la portabilité
|
||||||
|
name: CI/CD Pipeline
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
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: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.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
|
||||||
|
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,73 +0,0 @@
|
|||||||
name: Deploy to Production
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- prod
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
|
|
||||||
- name: Install pnpm
|
|
||||||
uses: pnpm/action-setup@v2
|
|
||||||
with:
|
|
||||||
version: 8
|
|
||||||
|
|
||||||
- name: Get pnpm store directory
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITEA_ENV
|
|
||||||
|
|
||||||
- name: Setup pnpm cache
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: ${{ env.STORE_PATH }}
|
|
||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-pnpm-store-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install
|
|
||||||
|
|
||||||
- name: Lint all projects
|
|
||||||
run: pnpm run lint
|
|
||||||
|
|
||||||
- name: Build all projects
|
|
||||||
run: pnpm run build
|
|
||||||
|
|
||||||
- name: Deploy with Docker Compose
|
|
||||||
run: |
|
|
||||||
docker compose -f docker-compose.prod.yml up -d --build
|
|
||||||
env:
|
|
||||||
BACKEND_PORT: ${{ secrets.BACKEND_PORT }}
|
|
||||||
FRONTEND_PORT: ${{ secrets.FRONTEND_PORT }}
|
|
||||||
POSTGRES_HOST: ${{ secrets.POSTGRES_HOST }}
|
|
||||||
POSTGRES_PORT: ${{ secrets.POSTGRES_PORT }}
|
|
||||||
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
|
|
||||||
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
|
||||||
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
|
|
||||||
REDIS_HOST: ${{ secrets.REDIS_HOST }}
|
|
||||||
REDIS_PORT: ${{ secrets.REDIS_PORT }}
|
|
||||||
S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }}
|
|
||||||
S3_PORT: ${{ secrets.S3_PORT }}
|
|
||||||
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
|
|
||||||
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
|
|
||||||
S3_BUCKET_NAME: ${{ secrets.S3_BUCKET_NAME }}
|
|
||||||
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
|
||||||
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
|
|
||||||
PGP_ENCRYPTION_KEY: ${{ secrets.PGP_ENCRYPTION_KEY }}
|
|
||||||
SESSION_PASSWORD: ${{ secrets.SESSION_PASSWORD }}
|
|
||||||
MAIL_HOST: ${{ secrets.MAIL_HOST }}
|
|
||||||
MAIL_PASS: ${{ secrets.MAIL_PASS }}
|
|
||||||
MAIL_USER: ${{ secrets.MAIL_USER }}
|
|
||||||
MAIL_FROM: ${{ secrets.MAIL_FROM }}
|
|
||||||
DOMAIN_NAME: ${{ secrets.DOMAIN_NAME }}
|
|
||||||
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
name: Lint
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- 'frontend/**'
|
|
||||||
- 'backend/**'
|
|
||||||
- 'documentation/**'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- 'frontend/**'
|
|
||||||
- 'backend/**'
|
|
||||||
- 'documentation/**'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: 9
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
cache: 'pnpm'
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install
|
|
||||||
- name: Lint Frontend
|
|
||||||
if: success() || failure()
|
|
||||||
run: pnpm -F @memegoat/frontend lint
|
|
||||||
- name: Lint Backend
|
|
||||||
if: success() || failure()
|
|
||||||
run: pnpm -F @memegoat/backend lint
|
|
||||||
- name: Lint Documentation
|
|
||||||
if: success() || failure()
|
|
||||||
run: pnpm -F @bypass/documentation lint
|
|
||||||
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)
|
||||||
|
- [ ] Performance : Cache (Redis) pour les tendances et recherches fréquentes
|
||||||
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);
|
||||||
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
@@ -1,27 +1,55 @@
|
|||||||
{
|
{
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "postgresql",
|
||||||
"entries": [
|
"entries": [
|
||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1767618753676,
|
"when": 1767618753676,
|
||||||
"tag": "0000_right_sally_floyd",
|
"tag": "0000_right_sally_floyd",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 1,
|
"idx": 1,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1768392191169,
|
"when": 1768392191169,
|
||||||
"tag": "0001_purple_goliath",
|
"tag": "0001_purple_goliath",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 2,
|
"idx": 2,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1768393637823,
|
"when": 1768393637823,
|
||||||
"tag": "0002_redundant_skin",
|
"tag": "0002_redundant_skin",
|
||||||
"breakpoints": true
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
FROM node:22-slim AS base
|
# syntax=docker/dockerfile:1
|
||||||
|
FROM node:22-alpine AS base
|
||||||
ENV PNPM_HOME="/pnpm"
|
ENV PNPM_HOME="/pnpm"
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
@@ -9,10 +10,17 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
|||||||
COPY backend/package.json ./backend/
|
COPY backend/package.json ./backend/
|
||||||
COPY frontend/package.json ./frontend/
|
COPY frontend/package.json ./frontend/
|
||||||
COPY documentation/package.json ./documentation/
|
COPY documentation/package.json ./documentation/
|
||||||
RUN pnpm install --no-frozen-lockfile
|
|
||||||
|
# Utilisation du cache pour pnpm et installation figée
|
||||||
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
# On réinstalle après COPY pour s'assurer que tous les scripts de cycle de vie et les liens sont corrects
|
|
||||||
RUN pnpm install --no-frozen-lockfile
|
# 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 run --filter @memegoat/backend build
|
||||||
RUN pnpm deploy --filter=@memegoat/backend --prod --legacy /app
|
RUN pnpm deploy --filter=@memegoat/backend --prod --legacy /app
|
||||||
RUN cp -r backend/dist /app/dist
|
RUN cp -r backend/dist /app/dist
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignoreUnknown": true,
|
"ignoreUnknown": true,
|
||||||
"includes": ["**", "!node_modules", "!dist", "!build"]
|
"includes": ["**", "!node_modules", "!dist", "!build", "!.migrations"]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@memegoat/backend",
|
"name": "@memegoat/backend",
|
||||||
"version": "0.0.1",
|
"version": "0.1.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -76,14 +76,22 @@
|
|||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"@types/express": "^5.0.0",
|
|
||||||
"@types/multer": "^1.4.12",
|
|
||||||
"@types/jest": "^29.5.14",
|
|
||||||
"@types/node": "^22.10.7",
|
|
||||||
"@types/pg": "^8.11.10",
|
|
||||||
"@types/supertest": "^6.0.2",
|
|
||||||
"typescript": "^5.7.3",
|
"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": {
|
"jest": {
|
||||||
"moduleFileExtensions": [
|
"moduleFileExtensions": [
|
||||||
@@ -99,7 +107,7 @@
|
|||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"node_modules/(?!(jose|@noble)/)"
|
"node_modules/(?!(.pnpm/)?(jose|@noble|uuid)/)"
|
||||||
],
|
],
|
||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)sx?$": "ts-jest"
|
"^.+\\.(t|j)sx?$": "ts-jest"
|
||||||
|
|||||||
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 {}
|
||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
import type { AuthenticatedRequest } from "../common/interfaces/request.interface";
|
import type { AuthenticatedRequest } from "../common/interfaces/request.interface";
|
||||||
import { ApiKeysService } from "./api-keys.service";
|
import { ApiKeysService } from "./api-keys.service";
|
||||||
|
import { CreateApiKeyDto } from "./dto/create-api-key.dto";
|
||||||
|
|
||||||
@Controller("api-keys")
|
@Controller("api-keys")
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@@ -20,13 +21,12 @@ export class ApiKeysController {
|
|||||||
@Post()
|
@Post()
|
||||||
create(
|
create(
|
||||||
@Req() req: AuthenticatedRequest,
|
@Req() req: AuthenticatedRequest,
|
||||||
@Body("name") name: string,
|
@Body() createApiKeyDto: CreateApiKeyDto,
|
||||||
@Body("expiresAt") expiresAt?: string,
|
|
||||||
) {
|
) {
|
||||||
return this.apiKeysService.create(
|
return this.apiKeysService.create(
|
||||||
req.user.sub,
|
req.user.sub,
|
||||||
name,
|
createApiKeyDto.name,
|
||||||
expiresAt ? new Date(expiresAt) : undefined,
|
createApiKeyDto.expiresAt ? new Date(createApiKeyDto.expiresAt) : undefined,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { forwardRef, Module } from "@nestjs/common";
|
import { forwardRef, Module } from "@nestjs/common";
|
||||||
import { AuthModule } from "../auth/auth.module";
|
import { AuthModule } from "../auth/auth.module";
|
||||||
import { CryptoModule } from "../crypto/crypto.module";
|
|
||||||
import { DatabaseModule } from "../database/database.module";
|
|
||||||
import { ApiKeysController } from "./api-keys.controller";
|
import { ApiKeysController } from "./api-keys.controller";
|
||||||
import { ApiKeysService } from "./api-keys.service";
|
import { ApiKeysService } from "./api-keys.service";
|
||||||
import { ApiKeysRepository } from "./repositories/api-keys.repository";
|
import { ApiKeysRepository } from "./repositories/api-keys.repository";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule, forwardRef(() => AuthModule), CryptoModule],
|
imports: [forwardRef(() => AuthModule)],
|
||||||
controllers: [ApiKeysController],
|
controllers: [ApiKeysController],
|
||||||
providers: [ApiKeysService, ApiKeysRepository],
|
providers: [ApiKeysService, ApiKeysRepository],
|
||||||
exports: [ApiKeysService, ApiKeysRepository],
|
exports: [ApiKeysService, ApiKeysRepository],
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
import { CacheModule } from "@nestjs/cache-manager";
|
import { CacheModule } from "@nestjs/cache-manager";
|
||||||
import { Module } from "@nestjs/common";
|
import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common";
|
||||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||||
import { ScheduleModule } from "@nestjs/schedule";
|
import { ScheduleModule } from "@nestjs/schedule";
|
||||||
import { ThrottlerModule } from "@nestjs/throttler";
|
import { ThrottlerModule } from "@nestjs/throttler";
|
||||||
import { redisStore } from "cache-manager-redis-yet";
|
import { redisStore } from "cache-manager-redis-yet";
|
||||||
|
import { AdminModule } from "./admin/admin.module";
|
||||||
import { ApiKeysModule } from "./api-keys/api-keys.module";
|
import { ApiKeysModule } from "./api-keys/api-keys.module";
|
||||||
import { AppController } from "./app.controller";
|
import { AppController } from "./app.controller";
|
||||||
import { AppService } from "./app.service";
|
import { AppService } from "./app.service";
|
||||||
import { AuthModule } from "./auth/auth.module";
|
import { AuthModule } from "./auth/auth.module";
|
||||||
import { CategoriesModule } from "./categories/categories.module";
|
import { CategoriesModule } from "./categories/categories.module";
|
||||||
import { CommonModule } from "./common/common.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 { validateEnv } from "./config/env.schema";
|
||||||
import { ContentsModule } from "./contents/contents.module";
|
import { ContentsModule } from "./contents/contents.module";
|
||||||
import { CryptoModule } from "./crypto/crypto.module";
|
import { CryptoModule } from "./crypto/crypto.module";
|
||||||
@@ -41,6 +44,7 @@ import { UsersModule } from "./users/users.module";
|
|||||||
SessionsModule,
|
SessionsModule,
|
||||||
ReportsModule,
|
ReportsModule,
|
||||||
ApiKeysModule,
|
ApiKeysModule,
|
||||||
|
AdminModule,
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
ThrottlerModule.forRootAsync({
|
ThrottlerModule.forRootAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
@@ -71,4 +75,10 @@ import { UsersModule } from "./users/users.module";
|
|||||||
controllers: [AppController, HealthController],
|
controllers: [AppController, HealthController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule implements NestModule {
|
||||||
|
configure(consumer: MiddlewareConsumer) {
|
||||||
|
consumer
|
||||||
|
.apply(HTTPLoggerMiddleware, CrawlerDetectionMiddleware)
|
||||||
|
.forRoutes("*");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,22 +1,32 @@
|
|||||||
import { forwardRef, Module } from "@nestjs/common";
|
import { forwardRef, Module } from "@nestjs/common";
|
||||||
import { CryptoModule } from "../crypto/crypto.module";
|
|
||||||
import { DatabaseModule } from "../database/database.module";
|
|
||||||
import { SessionsModule } from "../sessions/sessions.module";
|
import { SessionsModule } from "../sessions/sessions.module";
|
||||||
import { UsersModule } from "../users/users.module";
|
import { UsersModule } from "../users/users.module";
|
||||||
import { AuthController } from "./auth.controller";
|
import { AuthController } from "./auth.controller";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
|
import { AuthGuard } from "./guards/auth.guard";
|
||||||
|
import { OptionalAuthGuard } from "./guards/optional-auth.guard";
|
||||||
|
import { RolesGuard } from "./guards/roles.guard";
|
||||||
import { RbacService } from "./rbac.service";
|
import { RbacService } from "./rbac.service";
|
||||||
import { RbacRepository } from "./repositories/rbac.repository";
|
import { RbacRepository } from "./repositories/rbac.repository";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [forwardRef(() => UsersModule), SessionsModule],
|
||||||
forwardRef(() => UsersModule),
|
|
||||||
CryptoModule,
|
|
||||||
SessionsModule,
|
|
||||||
DatabaseModule,
|
|
||||||
],
|
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, RbacService, RbacRepository],
|
providers: [
|
||||||
exports: [AuthService, RbacService, RbacRepository],
|
AuthService,
|
||||||
|
RbacService,
|
||||||
|
RbacRepository,
|
||||||
|
AuthGuard,
|
||||||
|
OptionalAuthGuard,
|
||||||
|
RolesGuard,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
AuthService,
|
||||||
|
RbacService,
|
||||||
|
RbacRepository,
|
||||||
|
AuthGuard,
|
||||||
|
OptionalAuthGuard,
|
||||||
|
RolesGuard,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
jest.mock("uuid", () => ({
|
||||||
|
v4: jest.fn(() => "mocked-uuid"),
|
||||||
|
}));
|
||||||
|
|
||||||
import { Test, TestingModule } from "@nestjs/testing";
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
|
||||||
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
|
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ export class AuthService {
|
|||||||
const user = await this.usersService.findByEmailHash(emailHash);
|
const user = await this.usersService.findByEmailHash(emailHash);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
this.logger.warn(`Login failed: user not found for email hash`);
|
||||||
throw new UnauthorizedException("Invalid credentials");
|
throw new UnauthorizedException("Invalid credentials");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,10 +120,12 @@ export class AuthService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!isPasswordValid) {
|
if (!isPasswordValid) {
|
||||||
|
this.logger.warn(`Login failed: invalid password for user ${user.uuid}`);
|
||||||
throw new UnauthorizedException("Invalid credentials");
|
throw new UnauthorizedException("Invalid credentials");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.isTwoFactorEnabled) {
|
if (user.isTwoFactorEnabled) {
|
||||||
|
this.logger.log(`2FA required for user ${user.uuid}`);
|
||||||
return {
|
return {
|
||||||
message: "2FA required",
|
message: "2FA required",
|
||||||
requires2FA: true,
|
requires2FA: true,
|
||||||
@@ -141,6 +144,7 @@ export class AuthService {
|
|||||||
ip,
|
ip,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.logger.log(`User ${user.uuid} logged in successfully`);
|
||||||
return {
|
return {
|
||||||
message: "User logged in successfully",
|
message: "User logged in successfully",
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
@@ -165,6 +169,9 @@ export class AuthService {
|
|||||||
|
|
||||||
const isValid = authenticator.verify({ token, secret });
|
const isValid = authenticator.verify({ token, secret });
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
|
this.logger.warn(
|
||||||
|
`2FA verification failed for user ${userId}: invalid token`,
|
||||||
|
);
|
||||||
throw new UnauthorizedException("Invalid 2FA token");
|
throw new UnauthorizedException("Invalid 2FA token");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +186,7 @@ export class AuthService {
|
|||||||
ip,
|
ip,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.logger.log(`User ${userId} logged in successfully via 2FA`);
|
||||||
return {
|
return {
|
||||||
message: "User logged in successfully (2FA)",
|
message: "User logged in successfully (2FA)",
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { AuthModule } from "../auth/auth.module";
|
import { AuthModule } from "../auth/auth.module";
|
||||||
import { CryptoModule } from "../crypto/crypto.module";
|
|
||||||
import { DatabaseModule } from "../database/database.module";
|
|
||||||
import { CategoriesController } from "./categories.controller";
|
import { CategoriesController } from "./categories.controller";
|
||||||
import { CategoriesService } from "./categories.service";
|
import { CategoriesService } from "./categories.service";
|
||||||
import { CategoriesRepository } from "./repositories/categories.repository";
|
import { CategoriesRepository } from "./repositories/categories.repository";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule, AuthModule, CryptoModule],
|
imports: [AuthModule],
|
||||||
controllers: [CategoriesController],
|
controllers: [CategoriesController],
|
||||||
providers: [CategoriesService, CategoriesRepository],
|
providers: [CategoriesService, CategoriesRepository],
|
||||||
exports: [CategoriesService, CategoriesRepository],
|
exports: [CategoriesService, CategoriesRepository],
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { IsNotEmpty, IsOptional, IsString } from "class-validator";
|
import { IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator";
|
||||||
|
|
||||||
export class CreateCategoryDto {
|
export class CreateCategoryDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@MaxLength(64)
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@MaxLength(512)
|
||||||
iconUrl?: string;
|
iconUrl?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
import { DatabaseService } from "../../database/database.service";
|
import { DatabaseService } from "../../database/database.service";
|
||||||
import { categories } from "../../database/schemas";
|
import { categories } from "../../database/schemas";
|
||||||
import type { CreateCategoryDto } from "../dto/create-category.dto";
|
import type { CreateCategoryDto } from "../dto/create-category.dto";
|
||||||
@@ -16,6 +16,13 @@ export class CategoriesRepository {
|
|||||||
.orderBy(categories.name);
|
.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) {
|
async findOne(id: string) {
|
||||||
const result = await this.databaseService.db
|
const result = await this.databaseService.db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ import {
|
|||||||
import * as Sentry from "@sentry/nestjs";
|
import * as Sentry from "@sentry/nestjs";
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
|
|
||||||
|
interface RequestWithUser extends Request {
|
||||||
|
user?: {
|
||||||
|
sub?: string;
|
||||||
|
username?: string;
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Catch()
|
@Catch()
|
||||||
export class AllExceptionsFilter implements ExceptionFilter {
|
export class AllExceptionsFilter implements ExceptionFilter {
|
||||||
private readonly logger = new Logger("ExceptionFilter");
|
private readonly logger = new Logger("ExceptionFilter");
|
||||||
@@ -16,7 +24,7 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
|||||||
catch(exception: unknown, host: ArgumentsHost) {
|
catch(exception: unknown, host: ArgumentsHost) {
|
||||||
const ctx = host.switchToHttp();
|
const ctx = host.switchToHttp();
|
||||||
const response = ctx.getResponse<Response>();
|
const response = ctx.getResponse<Response>();
|
||||||
const request = ctx.getRequest<Request>();
|
const request = ctx.getRequest<RequestWithUser>();
|
||||||
|
|
||||||
const status =
|
const status =
|
||||||
exception instanceof HttpException
|
exception instanceof HttpException
|
||||||
@@ -28,6 +36,9 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
|||||||
? exception.getResponse()
|
? exception.getResponse()
|
||||||
: "Internal server error";
|
: "Internal server error";
|
||||||
|
|
||||||
|
const userId = request.user?.sub || request.user?.id;
|
||||||
|
const userPart = userId ? `[User: ${userId}] ` : "";
|
||||||
|
|
||||||
const errorResponse = {
|
const errorResponse = {
|
||||||
statusCode: status,
|
statusCode: status,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@@ -42,12 +53,12 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
|||||||
if (status === HttpStatus.INTERNAL_SERVER_ERROR) {
|
if (status === HttpStatus.INTERNAL_SERVER_ERROR) {
|
||||||
Sentry.captureException(exception);
|
Sentry.captureException(exception);
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`${request.method} ${request.url} - Error: ${exception instanceof Error ? exception.message : "Unknown error"}`,
|
`${userPart}${request.method} ${request.url} - Error: ${exception instanceof Error ? exception.message : "Unknown error"}`,
|
||||||
exception instanceof Error ? exception.stack : "",
|
exception instanceof Error ? exception.stack : "",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`${request.method} ${request.url} - Status: ${status} - Message: ${JSON.stringify(message)}`,
|
`${userPart}${request.method} ${request.url} - Status: ${status} - Message: ${JSON.stringify(message)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface IMediaService {
|
|||||||
processImage(
|
processImage(
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
format?: "webp" | "avif",
|
format?: "webp" | "avif",
|
||||||
|
resize?: { width?: number; height?: number },
|
||||||
): Promise<MediaProcessingResult>;
|
): Promise<MediaProcessingResult>;
|
||||||
processVideo(
|
processVideo(
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
|
|||||||
@@ -33,4 +33,6 @@ export interface IStorageService {
|
|||||||
sourceBucketName?: string,
|
sourceBucketName?: string,
|
||||||
destinationBucketName?: string,
|
destinationBucketName?: string,
|
||||||
): Promise<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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ export const envSchema = z.object({
|
|||||||
MAIL_FROM: z.string().email(),
|
MAIL_FROM: z.string().email(),
|
||||||
|
|
||||||
DOMAIN_NAME: z.string(),
|
DOMAIN_NAME: z.string(),
|
||||||
|
API_URL: z.string().url().optional(),
|
||||||
|
|
||||||
// Sentry
|
// Sentry
|
||||||
SENTRY_DSN: z.string().optional(),
|
SENTRY_DSN: z.string().optional(),
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ import {
|
|||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { FileInterceptor } from "@nestjs/platform-express";
|
import { FileInterceptor } from "@nestjs/platform-express";
|
||||||
import type { Request, Response } from "express";
|
import type { Response } from "express";
|
||||||
|
import { Roles } from "../auth/decorators/roles.decorator";
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
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 type { AuthenticatedRequest } from "../common/interfaces/request.interface";
|
||||||
import { ContentsService } from "./contents.service";
|
import { ContentsService } from "./contents.service";
|
||||||
import { CreateContentDto } from "./dto/create-content.dto";
|
import { CreateContentDto } from "./dto/create-content.dto";
|
||||||
@@ -65,10 +68,12 @@ export class ContentsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("explore")
|
@Get("explore")
|
||||||
|
@UseGuards(OptionalAuthGuard)
|
||||||
@UseInterceptors(CacheInterceptor)
|
@UseInterceptors(CacheInterceptor)
|
||||||
@CacheTTL(60)
|
@CacheTTL(60)
|
||||||
@Header("Cache-Control", "public, max-age=60")
|
@Header("Cache-Control", "public, max-age=60")
|
||||||
explore(
|
explore(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
|
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
|
||||||
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
|
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
|
||||||
@Query("sort") sort?: "trend" | "recent",
|
@Query("sort") sort?: "trend" | "recent",
|
||||||
@@ -78,7 +83,7 @@ export class ContentsController {
|
|||||||
@Query("query") query?: string,
|
@Query("query") query?: string,
|
||||||
@Query("favoritesOnly", new DefaultValuePipe(false), ParseBoolPipe)
|
@Query("favoritesOnly", new DefaultValuePipe(false), ParseBoolPipe)
|
||||||
favoritesOnly?: boolean,
|
favoritesOnly?: boolean,
|
||||||
@Query("userId") userId?: string,
|
@Query("userId") userIdQuery?: string,
|
||||||
) {
|
) {
|
||||||
return this.contentsService.findAll({
|
return this.contentsService.findAll({
|
||||||
limit,
|
limit,
|
||||||
@@ -89,42 +94,57 @@ export class ContentsController {
|
|||||||
author,
|
author,
|
||||||
query,
|
query,
|
||||||
favoritesOnly,
|
favoritesOnly,
|
||||||
userId,
|
userId: userIdQuery || req.user?.sub,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("trends")
|
@Get("trends")
|
||||||
|
@UseGuards(OptionalAuthGuard)
|
||||||
@UseInterceptors(CacheInterceptor)
|
@UseInterceptors(CacheInterceptor)
|
||||||
@CacheTTL(300)
|
@CacheTTL(300)
|
||||||
@Header("Cache-Control", "public, max-age=300")
|
@Header("Cache-Control", "public, max-age=300")
|
||||||
trends(
|
trends(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
|
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
|
||||||
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
|
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
|
||||||
) {
|
) {
|
||||||
return this.contentsService.findAll({ limit, offset, sortBy: "trend" });
|
return this.contentsService.findAll({
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
sortBy: "trend",
|
||||||
|
userId: req.user?.sub,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("recent")
|
@Get("recent")
|
||||||
|
@UseGuards(OptionalAuthGuard)
|
||||||
@UseInterceptors(CacheInterceptor)
|
@UseInterceptors(CacheInterceptor)
|
||||||
@CacheTTL(60)
|
@CacheTTL(60)
|
||||||
@Header("Cache-Control", "public, max-age=60")
|
@Header("Cache-Control", "public, max-age=60")
|
||||||
recent(
|
recent(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
|
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
|
||||||
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
|
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
|
||||||
) {
|
) {
|
||||||
return this.contentsService.findAll({ limit, offset, sortBy: "recent" });
|
return this.contentsService.findAll({
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
sortBy: "recent",
|
||||||
|
userId: req.user?.sub,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":idOrSlug")
|
@Get(":idOrSlug")
|
||||||
|
@UseGuards(OptionalAuthGuard)
|
||||||
@UseInterceptors(CacheInterceptor)
|
@UseInterceptors(CacheInterceptor)
|
||||||
@CacheTTL(3600)
|
@CacheTTL(3600)
|
||||||
@Header("Cache-Control", "public, max-age=3600")
|
@Header("Cache-Control", "public, max-age=3600")
|
||||||
async findOne(
|
async findOne(
|
||||||
@Param("idOrSlug") idOrSlug: string,
|
@Param("idOrSlug") idOrSlug: string,
|
||||||
@Req() req: Request,
|
@Req() req: AuthenticatedRequest,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
) {
|
) {
|
||||||
const content = await this.contentsService.findOne(idOrSlug);
|
const content = await this.contentsService.findOne(idOrSlug, req.user?.sub);
|
||||||
if (!content) {
|
if (!content) {
|
||||||
throw new NotFoundException("Contenu non trouvé");
|
throw new NotFoundException("Contenu non trouvé");
|
||||||
}
|
}
|
||||||
@@ -158,4 +178,11 @@ export class ContentsController {
|
|||||||
remove(@Param("id") id: string, @Req() req: AuthenticatedRequest) {
|
remove(@Param("id") id: string, @Req() req: AuthenticatedRequest) {
|
||||||
return this.contentsService.remove(id, req.user.sub);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { AuthModule } from "../auth/auth.module";
|
import { AuthModule } from "../auth/auth.module";
|
||||||
import { CryptoModule } from "../crypto/crypto.module";
|
|
||||||
import { DatabaseModule } from "../database/database.module";
|
|
||||||
import { MediaModule } from "../media/media.module";
|
import { MediaModule } from "../media/media.module";
|
||||||
import { S3Module } from "../s3/s3.module";
|
import { S3Module } from "../s3/s3.module";
|
||||||
import { ContentsController } from "./contents.controller";
|
import { ContentsController } from "./contents.controller";
|
||||||
@@ -9,7 +7,7 @@ import { ContentsService } from "./contents.service";
|
|||||||
import { ContentsRepository } from "./repositories/contents.repository";
|
import { ContentsRepository } from "./repositories/contents.repository";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule, S3Module, AuthModule, CryptoModule, MediaModule],
|
imports: [S3Module, AuthModule, MediaModule],
|
||||||
controllers: [ContentsController],
|
controllers: [ContentsController],
|
||||||
providers: [ContentsService, ContentsRepository],
|
providers: [ContentsService, ContentsRepository],
|
||||||
exports: [ContentsRepository],
|
exports: [ContentsRepository],
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ describe("ContentsService", () => {
|
|||||||
const mockS3Service = {
|
const mockS3Service = {
|
||||||
getUploadUrl: jest.fn(),
|
getUploadUrl: jest.fn(),
|
||||||
uploadFile: jest.fn(),
|
uploadFile: jest.fn(),
|
||||||
|
getPublicUrl: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockMediaService = {
|
const mockMediaService = {
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export class ContentsService {
|
|||||||
// 3. Upload vers S3
|
// 3. Upload vers S3
|
||||||
const key = `contents/${userId}/${Date.now()}-${uuidv4()}.${processed.extension}`;
|
const key = `contents/${userId}/${Date.now()}-${uuidv4()}.${processed.extension}`;
|
||||||
await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType);
|
await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType);
|
||||||
|
this.logger.log(`File uploaded successfully to S3: ${key}`);
|
||||||
|
|
||||||
// 4. Création en base de données
|
// 4. Création en base de données
|
||||||
return await this.create(userId, {
|
return await this.create(userId, {
|
||||||
@@ -126,7 +127,18 @@ export class ContentsService {
|
|||||||
this.contentsRepository.count(options),
|
this.contentsRepository.count(options),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { data, totalCount };
|
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) {
|
async create(userId: string, data: CreateContentDto) {
|
||||||
@@ -162,12 +174,34 @@ export class ContentsService {
|
|||||||
return deleted;
|
return deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(idOrSlug: string) {
|
async removeAdmin(id: string) {
|
||||||
return this.contentsRepository.findOne(idOrSlug);
|
this.logger.log(`Removing content ${id} by admin`);
|
||||||
|
const deleted = await this.contentsRepository.softDeleteAdmin(id);
|
||||||
|
|
||||||
|
if (deleted) {
|
||||||
|
await this.clearContentsCache();
|
||||||
|
}
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
generateBotHtml(content: { title: string; storageKey: string }): string {
|
||||||
const imageUrl = this.getFileUrl(content.storageKey);
|
const imageUrl = this.s3Service.getPublicUrl(content.storageKey);
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -188,19 +222,6 @@ export class ContentsService {
|
|||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
getFileUrl(storageKey: string): string {
|
|
||||||
const endpoint = this.configService.get("S3_ENDPOINT");
|
|
||||||
const port = this.configService.get("S3_PORT");
|
|
||||||
const protocol =
|
|
||||||
this.configService.get("S3_USE_SSL") === true ? "https" : "http";
|
|
||||||
const bucket = this.configService.get("S3_BUCKET_NAME");
|
|
||||||
|
|
||||||
if (endpoint === "localhost" || endpoint === "127.0.0.1") {
|
|
||||||
return `${protocol}://${endpoint}:${port}/${bucket}/${storageKey}`;
|
|
||||||
}
|
|
||||||
return `${protocol}://${endpoint}/${bucket}/${storageKey}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private generateSlug(text: string): string {
|
private generateSlug(text: string): string {
|
||||||
return text
|
return text
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
|
MaxLength,
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
|
|
||||||
export enum ContentType {
|
export enum ContentType {
|
||||||
@@ -19,14 +20,17 @@ export class CreateContentDto {
|
|||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@MaxLength(255)
|
||||||
title!: string;
|
title!: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@MaxLength(512)
|
||||||
storageKey!: string;
|
storageKey!: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@MaxLength(128)
|
||||||
mimeType!: string;
|
mimeType!: string;
|
||||||
|
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@@ -39,5 +43,6 @@ export class CreateContentDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
|
@MaxLength(64, { each: true })
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
|
IsArray,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
|
MaxLength,
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { ContentType } from "./create-content.dto";
|
import { ContentType } from "./create-content.dto";
|
||||||
|
|
||||||
@@ -13,6 +15,7 @@ export class UploadContentDto {
|
|||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@MaxLength(255)
|
||||||
title!: string;
|
title!: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -20,6 +23,8 @@ export class UploadContentDto {
|
|||||||
categoryId?: string;
|
categoryId?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
|
@MaxLength(64, { each: true })
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,11 +135,20 @@ export class ContentsRepository {
|
|||||||
fileSize: contents.fileSize,
|
fileSize: contents.fileSize,
|
||||||
views: contents.views,
|
views: contents.views,
|
||||||
usageCount: contents.usageCount,
|
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,
|
createdAt: contents.createdAt,
|
||||||
updatedAt: contents.updatedAt,
|
updatedAt: contents.updatedAt,
|
||||||
author: {
|
author: {
|
||||||
id: users.uuid,
|
id: users.uuid,
|
||||||
username: users.username,
|
username: users.username,
|
||||||
|
displayName: users.displayName,
|
||||||
|
avatarUrl: users.avatarUrl,
|
||||||
},
|
},
|
||||||
category: {
|
category: {
|
||||||
id: categories.id,
|
id: categories.id,
|
||||||
@@ -215,7 +224,7 @@ export class ContentsRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(idOrSlug: string) {
|
async findOne(idOrSlug: string, userId?: string) {
|
||||||
const [result] = await this.databaseService.db
|
const [result] = await this.databaseService.db
|
||||||
.select({
|
.select({
|
||||||
id: contents.id,
|
id: contents.id,
|
||||||
@@ -227,11 +236,31 @@ export class ContentsRepository {
|
|||||||
fileSize: contents.fileSize,
|
fileSize: contents.fileSize,
|
||||||
views: contents.views,
|
views: contents.views,
|
||||||
usageCount: contents.usageCount,
|
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,
|
createdAt: contents.createdAt,
|
||||||
updatedAt: contents.updatedAt,
|
updatedAt: contents.updatedAt,
|
||||||
userId: contents.userId,
|
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)
|
.from(contents)
|
||||||
|
.leftJoin(users, eq(contents.userId, users.uuid))
|
||||||
|
.leftJoin(categories, eq(contents.categoryId, categories.id))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
isNull(contents.deletedAt),
|
isNull(contents.deletedAt),
|
||||||
@@ -240,7 +269,20 @@ export class ContentsRepository {
|
|||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
return result;
|
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: {
|
async count(options: {
|
||||||
@@ -353,6 +395,15 @@ export class ContentsRepository {
|
|||||||
return deleted;
|
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 findBySlug(slug: string) {
|
async findBySlug(slug: string) {
|
||||||
const [result] = await this.databaseService.db
|
const [result] = await this.databaseService.db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Global, Module } from "@nestjs/common";
|
||||||
import { CryptoService } from "./crypto.service";
|
import { CryptoService } from "./crypto.service";
|
||||||
import { EncryptionService } from "./services/encryption.service";
|
import { EncryptionService } from "./services/encryption.service";
|
||||||
import { HashingService } from "./services/hashing.service";
|
import { HashingService } from "./services/hashing.service";
|
||||||
import { JwtService } from "./services/jwt.service";
|
import { JwtService } from "./services/jwt.service";
|
||||||
import { PostQuantumService } from "./services/post-quantum.service";
|
import { PostQuantumService } from "./services/post-quantum.service";
|
||||||
|
|
||||||
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
providers: [
|
providers: [
|
||||||
CryptoService,
|
CryptoService,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Global, Module } from "@nestjs/common";
|
||||||
import { ConfigModule } from "@nestjs/config";
|
import { ConfigModule } from "@nestjs/config";
|
||||||
import { DatabaseService } from "./database.service";
|
import { DatabaseService } from "./database.service";
|
||||||
|
|
||||||
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
providers: [DatabaseService],
|
providers: [DatabaseService],
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ export const users = pgTable(
|
|||||||
displayName: varchar("display_name", { length: 32 }),
|
displayName: varchar("display_name", { length: 32 }),
|
||||||
|
|
||||||
username: varchar("username", { length: 32 }).notNull().unique(),
|
username: varchar("username", { length: 32 }).notNull().unique(),
|
||||||
passwordHash: varchar("password_hash", { length: 72 }).notNull(),
|
passwordHash: varchar("password_hash", { length: 100 }).notNull(),
|
||||||
|
avatarUrl: varchar("avatar_url", { length: 512 }),
|
||||||
|
bio: varchar("bio", { length: 255 }),
|
||||||
|
|
||||||
// Sécurité
|
// Sécurité
|
||||||
twoFactorSecret: pgpEncrypted("two_factor_secret"),
|
twoFactorSecret: pgpEncrypted("two_factor_secret"),
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { AuthModule } from "../auth/auth.module";
|
import { AuthModule } from "../auth/auth.module";
|
||||||
import { CryptoModule } from "../crypto/crypto.module";
|
|
||||||
import { DatabaseModule } from "../database/database.module";
|
|
||||||
import { FavoritesController } from "./favorites.controller";
|
import { FavoritesController } from "./favorites.controller";
|
||||||
import { FavoritesService } from "./favorites.service";
|
import { FavoritesService } from "./favorites.service";
|
||||||
import { FavoritesRepository } from "./repositories/favorites.repository";
|
import { FavoritesRepository } from "./repositories/favorites.repository";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule, AuthModule, CryptoModule],
|
imports: [AuthModule],
|
||||||
controllers: [FavoritesController],
|
controllers: [FavoritesController],
|
||||||
providers: [FavoritesService, FavoritesRepository],
|
providers: [FavoritesService, FavoritesRepository],
|
||||||
exports: [FavoritesService, FavoritesRepository],
|
exports: [FavoritesService, FavoritesRepository],
|
||||||
|
|||||||
61
backend/src/media/media.controller.spec.ts
Normal file
61
backend/src/media/media.controller.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { Readable } from "node:stream";
|
||||||
|
import { NotFoundException } from "@nestjs/common";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import type { Response } from "express";
|
||||||
|
import { S3Service } from "../s3/s3.service";
|
||||||
|
import { MediaController } from "./media.controller";
|
||||||
|
|
||||||
|
describe("MediaController", () => {
|
||||||
|
let controller: MediaController;
|
||||||
|
|
||||||
|
const mockS3Service = {
|
||||||
|
getFileInfo: jest.fn(),
|
||||||
|
getFile: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [MediaController],
|
||||||
|
providers: [{ provide: S3Service, useValue: mockS3Service }],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<MediaController>(MediaController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getFile", () => {
|
||||||
|
it("should stream the file and set headers with path containing slashes", async () => {
|
||||||
|
const res = {
|
||||||
|
setHeader: jest.fn(),
|
||||||
|
} as unknown as Response;
|
||||||
|
const stream = new Readable();
|
||||||
|
stream.pipe = jest.fn();
|
||||||
|
const key = "contents/user-id/test.webp";
|
||||||
|
|
||||||
|
mockS3Service.getFileInfo.mockResolvedValue({
|
||||||
|
size: 100,
|
||||||
|
metaData: { "content-type": "image/webp" },
|
||||||
|
});
|
||||||
|
mockS3Service.getFile.mockResolvedValue(stream);
|
||||||
|
|
||||||
|
await controller.getFile(key, res);
|
||||||
|
|
||||||
|
expect(mockS3Service.getFileInfo).toHaveBeenCalledWith(key);
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/webp");
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith("Content-Length", 100);
|
||||||
|
expect(stream.pipe).toHaveBeenCalledWith(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException if file is not found", async () => {
|
||||||
|
mockS3Service.getFileInfo.mockRejectedValue(new Error("Not found"));
|
||||||
|
const res = {} as unknown as Response;
|
||||||
|
|
||||||
|
await expect(controller.getFile("invalid", res)).rejects.toThrow(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
30
backend/src/media/media.controller.ts
Normal file
30
backend/src/media/media.controller.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Controller, Get, NotFoundException, Param, Res } from "@nestjs/common";
|
||||||
|
import type { Response } from "express";
|
||||||
|
import type { BucketItemStat } from "minio";
|
||||||
|
import { S3Service } from "../s3/s3.service";
|
||||||
|
|
||||||
|
@Controller("media")
|
||||||
|
export class MediaController {
|
||||||
|
constructor(private readonly s3Service: S3Service) {}
|
||||||
|
|
||||||
|
@Get("*key")
|
||||||
|
async getFile(@Param("key") key: string, @Res() res: Response) {
|
||||||
|
try {
|
||||||
|
const stats = (await this.s3Service.getFileInfo(key)) as BucketItemStat;
|
||||||
|
const stream = await this.s3Service.getFile(key);
|
||||||
|
|
||||||
|
const contentType =
|
||||||
|
stats.metaData?.["content-type"] ||
|
||||||
|
stats.metadata?.["content-type"] ||
|
||||||
|
"application/octet-stream";
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", contentType);
|
||||||
|
res.setHeader("Content-Length", stats.size);
|
||||||
|
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
||||||
|
|
||||||
|
stream.pipe(res);
|
||||||
|
} catch (_error) {
|
||||||
|
throw new NotFoundException("Fichier non trouvé");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
import { S3Module } from "../s3/s3.module";
|
||||||
|
import { MediaController } from "./media.controller";
|
||||||
import { MediaService } from "./media.service";
|
import { MediaService } from "./media.service";
|
||||||
import { ImageProcessorStrategy } from "./strategies/image-processor.strategy";
|
import { ImageProcessorStrategy } from "./strategies/image-processor.strategy";
|
||||||
import { VideoProcessorStrategy } from "./strategies/video-processor.strategy";
|
import { VideoProcessorStrategy } from "./strategies/video-processor.strategy";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [S3Module],
|
||||||
|
controllers: [MediaController],
|
||||||
providers: [MediaService, ImageProcessorStrategy, VideoProcessorStrategy],
|
providers: [MediaService, ImageProcessorStrategy, VideoProcessorStrategy],
|
||||||
exports: [MediaService],
|
exports: [MediaService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -83,8 +83,9 @@ export class MediaService implements IMediaService {
|
|||||||
async processImage(
|
async processImage(
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
format: "webp" | "avif" = "webp",
|
format: "webp" | "avif" = "webp",
|
||||||
|
resize?: { width?: number; height?: number },
|
||||||
): Promise<MediaProcessingResult> {
|
): Promise<MediaProcessingResult> {
|
||||||
return this.imageProcessor.process(buffer, { format });
|
return this.imageProcessor.process(buffer, { format, resize });
|
||||||
}
|
}
|
||||||
|
|
||||||
async processVideo(
|
async processVideo(
|
||||||
|
|||||||
@@ -13,11 +13,22 @@ export class ImageProcessorStrategy implements IMediaProcessorStrategy {
|
|||||||
|
|
||||||
async process(
|
async process(
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
options: { format: "webp" | "avif" } = { format: "webp" },
|
options: {
|
||||||
|
format: "webp" | "avif";
|
||||||
|
resize?: { width?: number; height?: number };
|
||||||
|
} = { format: "webp" },
|
||||||
): Promise<MediaProcessingResult> {
|
): Promise<MediaProcessingResult> {
|
||||||
try {
|
try {
|
||||||
const { format } = options;
|
const { format, resize } = options;
|
||||||
let pipeline = sharp(buffer);
|
let pipeline = sharp(buffer);
|
||||||
|
|
||||||
|
if (resize) {
|
||||||
|
pipeline = pipeline.resize(resize.width, resize.height, {
|
||||||
|
fit: "cover",
|
||||||
|
position: "center",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const metadata = await pipeline.metadata();
|
const metadata = await pipeline.metadata();
|
||||||
|
|
||||||
if (format === "webp") {
|
if (format === "webp") {
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { IsEnum, IsOptional, IsString, IsUUID } from "class-validator";
|
import {
|
||||||
|
IsEnum,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
MaxLength,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
export enum ReportReason {
|
export enum ReportReason {
|
||||||
INAPPROPRIATE = "inappropriate",
|
INAPPROPRIATE = "inappropriate",
|
||||||
@@ -21,5 +27,6 @@ export class CreateReportDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@MaxLength(1000)
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { forwardRef, Module } from "@nestjs/common";
|
import { forwardRef, Module } from "@nestjs/common";
|
||||||
import { AuthModule } from "../auth/auth.module";
|
import { AuthModule } from "../auth/auth.module";
|
||||||
import { CryptoModule } from "../crypto/crypto.module";
|
|
||||||
import { DatabaseModule } from "../database/database.module";
|
|
||||||
import { ReportsController } from "./reports.controller";
|
import { ReportsController } from "./reports.controller";
|
||||||
import { ReportsService } from "./reports.service";
|
import { ReportsService } from "./reports.service";
|
||||||
import { ReportsRepository } from "./repositories/reports.repository";
|
import { ReportsRepository } from "./repositories/reports.repository";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule, forwardRef(() => AuthModule), CryptoModule],
|
imports: [forwardRef(() => AuthModule)],
|
||||||
controllers: [ReportsController],
|
controllers: [ReportsController],
|
||||||
providers: [ReportsService, ReportsRepository],
|
providers: [ReportsService, ReportsRepository],
|
||||||
exports: [ReportsRepository, ReportsService],
|
exports: [ReportsRepository, ReportsService],
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ describe("ReportsService", () => {
|
|||||||
describe("create", () => {
|
describe("create", () => {
|
||||||
it("should create a report", async () => {
|
it("should create a report", async () => {
|
||||||
const reporterId = "u1";
|
const reporterId = "u1";
|
||||||
const data = { contentId: "c1", reason: "spam" };
|
const data = { contentId: "c1", reason: "spam" } as const;
|
||||||
mockReportsRepository.create.mockResolvedValue({
|
mockReportsRepository.create.mockResolvedValue({
|
||||||
id: "r1",
|
id: "r1",
|
||||||
...data,
|
...data,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ jest.mock("minio");
|
|||||||
|
|
||||||
describe("S3Service", () => {
|
describe("S3Service", () => {
|
||||||
let service: S3Service;
|
let service: S3Service;
|
||||||
let _configService: ConfigService;
|
let configService: ConfigService;
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: Fine for testing purposes
|
// biome-ignore lint/suspicious/noExplicitAny: Fine for testing purposes
|
||||||
let minioClient: any;
|
let minioClient: any;
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ describe("S3Service", () => {
|
|||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<S3Service>(S3Service);
|
service = module.get<S3Service>(S3Service);
|
||||||
_configService = module.get<ConfigService>(ConfigService);
|
configService = module.get<ConfigService>(ConfigService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be defined", () => {
|
it("should be defined", () => {
|
||||||
@@ -185,35 +185,39 @@ describe("S3Service", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("moveFile", () => {
|
describe("getPublicUrl", () => {
|
||||||
it("should move file within default bucket", async () => {
|
it("should use API_URL if provided", () => {
|
||||||
const source = "source.txt";
|
(configService.get as jest.Mock).mockImplementation((key: string) => {
|
||||||
const dest = "dest.txt";
|
if (key === "API_URL") return "https://api.test.com";
|
||||||
await service.moveFile(source, dest);
|
return null;
|
||||||
|
});
|
||||||
expect(minioClient.copyObject).toHaveBeenCalledWith(
|
const url = service.getPublicUrl("test.webp");
|
||||||
"memegoat",
|
expect(url).toBe("https://api.test.com/media/test.webp");
|
||||||
dest,
|
|
||||||
"/memegoat/source.txt",
|
|
||||||
expect.any(Minio.CopyConditions),
|
|
||||||
);
|
|
||||||
expect(minioClient.removeObject).toHaveBeenCalledWith("memegoat", source);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should move file between different buckets", async () => {
|
it("should use DOMAIN_NAME and PORT for localhost", () => {
|
||||||
const source = "source.txt";
|
(configService.get as jest.Mock).mockImplementation(
|
||||||
const dest = "dest.txt";
|
(key: string, def: unknown) => {
|
||||||
const sBucket = "source-bucket";
|
if (key === "API_URL") return null;
|
||||||
const dBucket = "dest-bucket";
|
if (key === "DOMAIN_NAME") return "localhost";
|
||||||
await service.moveFile(source, dest, sBucket, dBucket);
|
if (key === "PORT") return 3000;
|
||||||
|
return def;
|
||||||
expect(minioClient.copyObject).toHaveBeenCalledWith(
|
},
|
||||||
dBucket,
|
|
||||||
dest,
|
|
||||||
`/${sBucket}/${source}`,
|
|
||||||
expect.any(Minio.CopyConditions),
|
|
||||||
);
|
);
|
||||||
expect(minioClient.removeObject).toHaveBeenCalledWith(sBucket, source);
|
const url = service.getPublicUrl("test.webp");
|
||||||
|
expect(url).toBe("http://localhost:3000/media/test.webp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use api.DOMAIN_NAME for production", () => {
|
||||||
|
(configService.get as jest.Mock).mockImplementation(
|
||||||
|
(key: string, def: unknown) => {
|
||||||
|
if (key === "API_URL") return null;
|
||||||
|
if (key === "DOMAIN_NAME") return "memegoat.fr";
|
||||||
|
return def;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const url = service.getPublicUrl("test.webp");
|
||||||
|
expect(url).toBe("https://api.memegoat.fr/media/test.webp");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export class S3Service implements OnModuleInit, IStorageService {
|
|||||||
...metaData,
|
...metaData,
|
||||||
"Content-Type": mimeType,
|
"Content-Type": mimeType,
|
||||||
});
|
});
|
||||||
|
this.logger.log(`File uploaded successfully: ${fileName} to ${bucketName}`);
|
||||||
return fileName;
|
return fileName;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Error uploading file to ${bucketName}: ${error.message}`);
|
this.logger.error(`Error uploading file to ${bucketName}: ${error.message}`);
|
||||||
@@ -113,6 +114,7 @@ export class S3Service implements OnModuleInit, IStorageService {
|
|||||||
async deleteFile(fileName: string, bucketName: string = this.bucketName) {
|
async deleteFile(fileName: string, bucketName: string = this.bucketName) {
|
||||||
try {
|
try {
|
||||||
await this.minioClient.removeObject(bucketName, fileName);
|
await this.minioClient.removeObject(bucketName, fileName);
|
||||||
|
this.logger.log(`File deleted successfully: ${fileName} from ${bucketName}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Error deleting file from ${bucketName}: ${error.message}`,
|
`Error deleting file from ${bucketName}: ${error.message}`,
|
||||||
@@ -155,4 +157,22 @@ export class S3Service implements OnModuleInit, IStorageService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getPublicUrl(storageKey: string): string {
|
||||||
|
const apiUrl = this.configService.get<string>("API_URL");
|
||||||
|
const domain = this.configService.get<string>("DOMAIN_NAME", "localhost");
|
||||||
|
const port = this.configService.get<number>("PORT", 3000);
|
||||||
|
|
||||||
|
let baseUrl: string;
|
||||||
|
|
||||||
|
if (apiUrl) {
|
||||||
|
baseUrl = apiUrl.replace(/\/$/, "");
|
||||||
|
} else if (domain === "localhost" || domain === "127.0.0.1") {
|
||||||
|
baseUrl = `http://${domain}:${port}`;
|
||||||
|
} else {
|
||||||
|
baseUrl = `https://api.${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${baseUrl}/media/${storageKey}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { CryptoModule } from "../crypto/crypto.module";
|
|
||||||
import { DatabaseModule } from "../database/database.module";
|
|
||||||
import { SessionsRepository } from "./repositories/sessions.repository";
|
import { SessionsRepository } from "./repositories/sessions.repository";
|
||||||
import { SessionsService } from "./sessions.service";
|
import { SessionsService } from "./sessions.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule, CryptoModule],
|
|
||||||
providers: [SessionsService, SessionsRepository],
|
providers: [SessionsService, SessionsRepository],
|
||||||
exports: [SessionsService, SessionsRepository],
|
exports: [SessionsService, SessionsRepository],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { AuthModule } from "../auth/auth.module";
|
import { AuthModule } from "../auth/auth.module";
|
||||||
import { CryptoModule } from "../crypto/crypto.module";
|
|
||||||
import { DatabaseModule } from "../database/database.module";
|
|
||||||
import { TagsRepository } from "./repositories/tags.repository";
|
import { TagsRepository } from "./repositories/tags.repository";
|
||||||
import { TagsController } from "./tags.controller";
|
import { TagsController } from "./tags.controller";
|
||||||
import { TagsService } from "./tags.service";
|
import { TagsService } from "./tags.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule, AuthModule, CryptoModule],
|
imports: [AuthModule],
|
||||||
controllers: [TagsController],
|
controllers: [TagsController],
|
||||||
providers: [TagsService, TagsRepository],
|
providers: [TagsService, TagsRepository],
|
||||||
exports: [TagsService, TagsRepository],
|
exports: [TagsService, TagsRepository],
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { IsNotEmpty, IsString } from "class-validator";
|
import { IsNotEmpty, IsString, MaxLength } from "class-validator";
|
||||||
|
|
||||||
export class UpdateConsentDto {
|
export class UpdateConsentDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@MaxLength(16)
|
||||||
termsVersion!: string;
|
termsVersion!: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@MaxLength(16)
|
||||||
privacyVersion!: string;
|
privacyVersion!: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,4 +5,13 @@ export class UpdateUserDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(32)
|
@MaxLength(32)
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
bio?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
avatarUrl?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export class UsersRepository {
|
|||||||
username: users.username,
|
username: users.username,
|
||||||
email: users.email,
|
email: users.email,
|
||||||
displayName: users.displayName,
|
displayName: users.displayName,
|
||||||
|
avatarUrl: users.avatarUrl,
|
||||||
|
bio: users.bio,
|
||||||
status: users.status,
|
status: users.status,
|
||||||
isTwoFactorEnabled: users.isTwoFactorEnabled,
|
isTwoFactorEnabled: users.isTwoFactorEnabled,
|
||||||
createdAt: users.createdAt,
|
createdAt: users.createdAt,
|
||||||
@@ -66,7 +68,9 @@ export class UsersRepository {
|
|||||||
.select({
|
.select({
|
||||||
uuid: users.uuid,
|
uuid: users.uuid,
|
||||||
username: users.username,
|
username: users.username,
|
||||||
|
email: users.email,
|
||||||
displayName: users.displayName,
|
displayName: users.displayName,
|
||||||
|
avatarUrl: users.avatarUrl,
|
||||||
status: users.status,
|
status: users.status,
|
||||||
createdAt: users.createdAt,
|
createdAt: users.createdAt,
|
||||||
})
|
})
|
||||||
@@ -81,6 +85,8 @@ export class UsersRepository {
|
|||||||
uuid: users.uuid,
|
uuid: users.uuid,
|
||||||
username: users.username,
|
username: users.username,
|
||||||
displayName: users.displayName,
|
displayName: users.displayName,
|
||||||
|
avatarUrl: users.avatarUrl,
|
||||||
|
bio: users.bio,
|
||||||
createdAt: users.createdAt,
|
createdAt: users.createdAt,
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Req,
|
Req,
|
||||||
|
UploadedFile,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
import { FileInterceptor } from "@nestjs/platform-express";
|
||||||
import { AuthService } from "../auth/auth.service";
|
import { AuthService } from "../auth/auth.service";
|
||||||
import { Roles } from "../auth/decorators/roles.decorator";
|
import { Roles } from "../auth/decorators/roles.decorator";
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
@@ -74,6 +76,16 @@ export class UsersController {
|
|||||||
return this.usersService.update(req.user.sub, updateUserDto);
|
return this.usersService.update(req.user.sub, updateUserDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("me/avatar")
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
@UseInterceptors(FileInterceptor("file"))
|
||||||
|
updateAvatar(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
return this.usersService.updateAvatar(req.user.sub, file);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch("me/consent")
|
@Patch("me/consent")
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
updateConsent(
|
updateConsent(
|
||||||
@@ -93,6 +105,13 @@ export class UsersController {
|
|||||||
return this.usersService.remove(req.user.sub);
|
return this.usersService.remove(req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete(":uuid")
|
||||||
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
|
@Roles("admin")
|
||||||
|
removeAdmin(@Param("uuid") uuid: string) {
|
||||||
|
return this.usersService.remove(uuid);
|
||||||
|
}
|
||||||
|
|
||||||
// Double Authentification (2FA)
|
// Double Authentification (2FA)
|
||||||
@Post("me/2fa/setup")
|
@Post("me/2fa/setup")
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { forwardRef, Module } from "@nestjs/common";
|
import { forwardRef, Module } from "@nestjs/common";
|
||||||
import { AuthModule } from "../auth/auth.module";
|
import { AuthModule } from "../auth/auth.module";
|
||||||
import { CryptoModule } from "../crypto/crypto.module";
|
import { MediaModule } from "../media/media.module";
|
||||||
import { DatabaseModule } from "../database/database.module";
|
import { S3Module } from "../s3/s3.module";
|
||||||
import { UsersRepository } from "./repositories/users.repository";
|
import { UsersRepository } from "./repositories/users.repository";
|
||||||
import { UsersController } from "./users.controller";
|
import { UsersController } from "./users.controller";
|
||||||
import { UsersService } from "./users.service";
|
import { UsersService } from "./users.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule, CryptoModule, forwardRef(() => AuthModule)],
|
imports: [forwardRef(() => AuthModule), MediaModule, S3Module],
|
||||||
controllers: [UsersController],
|
controllers: [UsersController],
|
||||||
providers: [UsersService, UsersRepository],
|
providers: [UsersService, UsersRepository],
|
||||||
exports: [UsersService, UsersRepository],
|
exports: [UsersService, UsersRepository],
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
jest.mock("uuid", () => ({
|
||||||
|
v4: jest.fn(() => "mocked-uuid"),
|
||||||
|
}));
|
||||||
|
|
||||||
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
|
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
|
||||||
ml_kem768: {
|
ml_kem768: {
|
||||||
keygen: jest.fn(),
|
keygen: jest.fn(),
|
||||||
@@ -12,7 +16,11 @@ jest.mock("jose", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Test, TestingModule } from "@nestjs/testing";
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { RbacService } from "../auth/rbac.service";
|
||||||
|
import { MediaService } from "../media/media.service";
|
||||||
|
import { S3Service } from "../s3/s3.service";
|
||||||
import { UsersRepository } from "./repositories/users.repository";
|
import { UsersRepository } from "./repositories/users.repository";
|
||||||
import { UsersService } from "./users.service";
|
import { UsersService } from "./users.service";
|
||||||
|
|
||||||
@@ -39,6 +47,24 @@ describe("UsersService", () => {
|
|||||||
del: jest.fn(),
|
del: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockRbacService = {
|
||||||
|
getUserRoles: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockMediaService = {
|
||||||
|
scanFile: jest.fn(),
|
||||||
|
processImage: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockS3Service = {
|
||||||
|
uploadFile: jest.fn(),
|
||||||
|
getPublicUrl: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockConfigService = {
|
||||||
|
get: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|
||||||
@@ -47,6 +73,10 @@ describe("UsersService", () => {
|
|||||||
UsersService,
|
UsersService,
|
||||||
{ provide: UsersRepository, useValue: mockUsersRepository },
|
{ provide: UsersRepository, useValue: mockUsersRepository },
|
||||||
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
|
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
|
||||||
|
{ provide: RbacService, useValue: mockRbacService },
|
||||||
|
{ provide: MediaService, useValue: mockMediaService },
|
||||||
|
{ provide: S3Service, useValue: mockS3Service },
|
||||||
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
forwardRef,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
} from "@nestjs/common";
|
||||||
import type { Cache } from "cache-manager";
|
import type { Cache } from "cache-manager";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { RbacService } from "../auth/rbac.service";
|
||||||
|
import type { IMediaService } from "../common/interfaces/media.interface";
|
||||||
|
import type { IStorageService } from "../common/interfaces/storage.interface";
|
||||||
|
import { MediaService } from "../media/media.service";
|
||||||
|
import { S3Service } from "../s3/s3.service";
|
||||||
import { UpdateUserDto } from "./dto/update-user.dto";
|
import { UpdateUserDto } from "./dto/update-user.dto";
|
||||||
import { UsersRepository } from "./repositories/users.repository";
|
import { UsersRepository } from "./repositories/users.repository";
|
||||||
|
|
||||||
@@ -11,6 +23,10 @@ export class UsersService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly usersRepository: UsersRepository,
|
private readonly usersRepository: UsersRepository,
|
||||||
@Inject(CACHE_MANAGER) private cacheManager: Cache,
|
@Inject(CACHE_MANAGER) private cacheManager: Cache,
|
||||||
|
@Inject(forwardRef(() => RbacService))
|
||||||
|
private readonly rbacService: RbacService,
|
||||||
|
@Inject(MediaService) private readonly mediaService: IMediaService,
|
||||||
|
@Inject(S3Service) private readonly s3Service: IStorageService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async clearUserCache(username?: string) {
|
private async clearUserCache(username?: string) {
|
||||||
@@ -33,7 +49,21 @@ export class UsersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async findOneWithPrivateData(uuid: string) {
|
async findOneWithPrivateData(uuid: string) {
|
||||||
return await this.usersRepository.findOneWithPrivateData(uuid);
|
const [user, roles] = await Promise.all([
|
||||||
|
this.usersRepository.findOneWithPrivateData(uuid),
|
||||||
|
this.rbacService.getUserRoles(uuid),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
avatarUrl: user.avatarUrl
|
||||||
|
? this.s3Service.getPublicUrl(user.avatarUrl)
|
||||||
|
: null,
|
||||||
|
role: roles.includes("admin") ? "admin" : "user",
|
||||||
|
roles,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(limit: number, offset: number) {
|
async findAll(limit: number, offset: number) {
|
||||||
@@ -42,11 +72,26 @@ export class UsersService {
|
|||||||
this.usersRepository.countAll(),
|
this.usersRepository.countAll(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { data, totalCount };
|
const processedData = data.map((user) => ({
|
||||||
|
...user,
|
||||||
|
avatarUrl: user.avatarUrl
|
||||||
|
? this.s3Service.getPublicUrl(user.avatarUrl)
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { data: processedData, totalCount };
|
||||||
}
|
}
|
||||||
|
|
||||||
async findPublicProfile(username: string) {
|
async findPublicProfile(username: string) {
|
||||||
return await this.usersRepository.findByUsername(username);
|
const user = await this.usersRepository.findByUsername(username);
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
avatarUrl: user.avatarUrl
|
||||||
|
? this.s3Service.getPublicUrl(user.avatarUrl)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(uuid: string) {
|
async findOne(uuid: string) {
|
||||||
@@ -63,6 +108,48 @@ export class UsersService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateAvatar(uuid: string, file: Express.Multer.File) {
|
||||||
|
this.logger.log(`Updating avatar for user ${uuid}`);
|
||||||
|
|
||||||
|
// Validation du format et de la taille
|
||||||
|
const allowedMimeTypes = ["image/png", "image/jpeg", "image/webp"];
|
||||||
|
if (!allowedMimeTypes.includes(file.mimetype)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Format d'image non supporté. Formats acceptés: png, jpeg, webp.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
throw new BadRequestException("Image trop volumineuse. Limite: 2 Mo.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Scan Antivirus
|
||||||
|
const scanResult = await this.mediaService.scanFile(
|
||||||
|
file.buffer,
|
||||||
|
file.originalname,
|
||||||
|
);
|
||||||
|
if (scanResult.isInfected) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Le fichier est infecté par ${scanResult.virusName}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Traitement (WebP + Redimensionnement 512x512)
|
||||||
|
const processed = await this.mediaService.processImage(file.buffer, "webp", {
|
||||||
|
width: 512,
|
||||||
|
height: 512,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Upload vers S3
|
||||||
|
const key = `avatars/${uuid}/${Date.now()}-${uuidv4()}.${processed.extension}`;
|
||||||
|
await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType);
|
||||||
|
this.logger.log(`Avatar uploaded successfully to S3: ${key}`);
|
||||||
|
|
||||||
|
// 4. Mise à jour de la base de données
|
||||||
|
const user = await this.update(uuid, { avatarUrl: key });
|
||||||
|
return user[0];
|
||||||
|
}
|
||||||
|
|
||||||
async updateConsent(
|
async updateConsent(
|
||||||
uuid: string,
|
uuid: string,
|
||||||
termsVersion: string,
|
termsVersion: string,
|
||||||
|
|||||||
@@ -101,8 +101,8 @@ services:
|
|||||||
ENABLE_CORS: ${ENABLE_CORS:-true}
|
ENABLE_CORS: ${ENABLE_CORS:-true}
|
||||||
CLAMAV_HOST: memegoat-clamav
|
CLAMAV_HOST: memegoat-clamav
|
||||||
CLAMAV_PORT: 3310
|
CLAMAV_PORT: 3310
|
||||||
MAX_IMAGE_SIZE_KB: 512
|
MAX_IMAGE_SIZE_KB: 1024
|
||||||
MAX_GIF_SIZE_KB: 1024
|
MAX_GIF_SIZE_KB: 4096
|
||||||
|
|
||||||
clamav:
|
clamav:
|
||||||
image: clamav/clamav:latest
|
image: clamav/clamav:latest
|
||||||
@@ -127,10 +127,22 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:3000}
|
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-https://api.memegoat.fr}
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
|
||||||
|
documentation:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: documentation/Dockerfile
|
||||||
|
target: runner
|
||||||
|
container_name: memegoat-docs
|
||||||
|
networks:
|
||||||
|
- nw_caddy
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
minio_data:
|
minio_data:
|
||||||
|
|||||||
@@ -1,66 +1,54 @@
|
|||||||
# syntax=docker.io/docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
FROM pnpm/pnpm:20-alpine AS base
|
FROM node:22-alpine AS base
|
||||||
|
ENV PNPM_HOME="/pnpm"
|
||||||
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
# Install dependencies only when needed
|
|
||||||
FROM base AS deps
|
|
||||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
|
||||||
RUN apk add --no-cache libc6-compat
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install dependencies based on the preferred package manager
|
|
||||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* source.config.ts* next.config.* ./
|
|
||||||
RUN \
|
|
||||||
if [ -f pnpm-lock.yaml ]; then pnpm i --frozen-lockfile; \
|
|
||||||
elif [ -f package-lock.json ]; then npm ci; \
|
|
||||||
elif [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
|
||||||
else echo "Lockfile not found." && exit 1; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
|
||||||
# Rebuild the source code only when needed
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /usr/src/app
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||||
|
COPY backend/package.json ./backend/
|
||||||
|
COPY frontend/package.json ./frontend/
|
||||||
|
COPY documentation/package.json ./documentation/
|
||||||
|
|
||||||
|
# Montage du cache pnpm
|
||||||
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Next.js collects completely anonymous telemetry data about general usage.
|
# Deuxième passe avec cache pour les scripts/liens
|
||||||
# Learn more here: https://nextjs.org/telemetry
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
pnpm install --frozen-lockfile
|
||||||
# ENV NEXT_TELEMETRY_DISABLED=1
|
|
||||||
|
|
||||||
RUN \
|
# Build avec cache Next.js
|
||||||
if [ -f pnpm-lock.yaml ]; then pnpm run build; \
|
RUN --mount=type=cache,id=next-docs-cache,target=/usr/src/app/documentation/.next/cache \
|
||||||
elif [ -f package-lock.json ]; then npm run build; \
|
pnpm run --filter @memegoat/documentation build
|
||||||
elif [ -f yarn.lock ]; then yarn run build; \
|
|
||||||
else echo "Lockfile not found." && exit 1; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Production image, copy all the files and run next
|
FROM node:22-alpine AS runner
|
||||||
FROM base AS runner
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
# Uncomment the following line in case you want to disable telemetry during runtime.
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
# ENV NEXT_TELEMETRY_DISABLED=1
|
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /usr/src/app/documentation/public ./documentation/public
|
||||||
|
|
||||||
# Automatically leverage output traces to reduce image size
|
# Automatically leverage output traces to reduce image size
|
||||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /usr/src/app/documentation/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /usr/src/app/documentation/.next/static ./documentation/.next/static
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|
||||||
# server.js is created by next build from the standalone output
|
|
||||||
# https://nextjs.org/docs/pages/api-reference/config/next-config-js/output
|
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
CMD ["node", "server.js"]
|
|
||||||
|
# Note: server.js is created in the standalone output.
|
||||||
|
# In a monorepo, it's often inside a subdirectory matching the package name.
|
||||||
|
CMD ["node", "documentation/server.js"]
|
||||||
|
|||||||
@@ -17,7 +17,15 @@
|
|||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true
|
"recommended": true,
|
||||||
|
"a11y": {
|
||||||
|
"useAriaPropsForRole": "warn",
|
||||||
|
"useSemanticElements": "warn",
|
||||||
|
"useFocusableInteractive": "warn"
|
||||||
|
},
|
||||||
|
"suspicious": {
|
||||||
|
"noUnknownAtRules": "off"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"domains": {
|
"domains": {
|
||||||
"next": "recommended",
|
"next": "recommended",
|
||||||
|
|||||||
@@ -82,6 +82,11 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
|||||||
Récupère les informations détaillées de l'utilisateur connecté. Requiert l'authentification.
|
Récupère les informations détaillées de l'utilisateur connecté. Requiert l'authentification.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion title="GET /users/public/:username">
|
||||||
|
Récupère le profil public d'un utilisateur par son nom d'utilisateur.
|
||||||
|
**Réponse :** `id`, `username`, `displayName`, `avatarUrl`, `createdAt`.
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="GET /users/me/export">
|
<Accordion title="GET /users/me/export">
|
||||||
Extrait l'intégralité des données de l'utilisateur au format JSON (Conformité RGPD).
|
Extrait l'intégralité des données de l'utilisateur au format JSON (Conformité RGPD).
|
||||||
Contient le profil, les contenus et les favoris.
|
Contient le profil, les contenus et les favoris.
|
||||||
@@ -89,7 +94,22 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
|||||||
|
|
||||||
<Accordion title="PATCH /users/me">
|
<Accordion title="PATCH /users/me">
|
||||||
Met à jour les informations du profil.
|
Met à jour les informations du profil.
|
||||||
|
**Corps :**
|
||||||
- `displayName` (string)
|
- `displayName` (string)
|
||||||
|
- `bio` (string)
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion title="POST /users/me/avatar">
|
||||||
|
Met à jour l'avatar de l'utilisateur.
|
||||||
|
**Type :** `multipart/form-data`
|
||||||
|
**Champ :** `file` (Image)
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion title="PATCH /users/me/consent">
|
||||||
|
Met à jour les consentements légaux de l'utilisateur.
|
||||||
|
**Corps :**
|
||||||
|
- `termsVersion` (string)
|
||||||
|
- `privacyVersion` (string)
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="DELETE /users/me">
|
<Accordion title="DELETE /users/me">
|
||||||
@@ -105,9 +125,9 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
|||||||
- `POST /users/me/2fa/disable` : Désactive avec jeton.
|
- `POST /users/me/2fa/disable` : Désactive avec jeton.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="Administration (GET /users/admin)">
|
<Accordion title="Administration (Admin uniquement)">
|
||||||
Liste tous les utilisateurs. Réservé aux administrateurs.
|
- `GET /users/admin` : Liste tous les utilisateurs (avec pagination `limit`, `offset`).
|
||||||
**Params :** `limit`, `offset`.
|
- `DELETE /users/:uuid` : Supprime définitivement un utilisateur par son UUID.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</Accordions>
|
</Accordions>
|
||||||
|
|
||||||
@@ -118,12 +138,15 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
|||||||
Recherche et filtre les contenus. Ces endpoints sont mis en cache (Redis + Navigateur).
|
Recherche et filtre les contenus. Ces endpoints sont mis en cache (Redis + Navigateur).
|
||||||
|
|
||||||
**Query Params :**
|
**Query Params :**
|
||||||
|
- `limit` (number) : Défaut 10.
|
||||||
|
- `offset` (number) : Défaut 0.
|
||||||
- `sort` : `trend` | `recent` (uniquement sur `/explore`)
|
- `sort` : `trend` | `recent` (uniquement sur `/explore`)
|
||||||
- `tag` (string)
|
- `tag` (string) : Filtrer par tag.
|
||||||
- `category` (slug ou id)
|
- `category` (slug ou id) : Filtrer par catégorie.
|
||||||
- `author` (username)
|
- `author` (username) : Filtrer par auteur.
|
||||||
- `query` (titre)
|
- `query` (titre) : Recherche textuelle.
|
||||||
- `favoritesOnly` (bool)
|
- `favoritesOnly` (bool) : Ne montrer que les favoris de l'utilisateur connecté.
|
||||||
|
- `userId` (uuid) : Filtrer les contenus d'un utilisateur spécifique.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="GET /contents/:idOrSlug">
|
<Accordion title="GET /contents/:idOrSlug">
|
||||||
@@ -133,8 +156,13 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
|||||||
Si l'User-Agent correspond à un robot d'indexation (Googlebot, Twitterbot, etc.), l'API retourne un rendu HTML minimal contenant les méta-tags **OpenGraph** et **Twitter Cards** pour un partage optimal. Pour les autres clients, les données sont retournées en JSON.
|
Si l'User-Agent correspond à un robot d'indexation (Googlebot, Twitterbot, etc.), l'API retourne un rendu HTML minimal contenant les méta-tags **OpenGraph** et **Twitter Cards** pour un partage optimal. Pour les autres clients, les données sont retournées en JSON.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion title="POST /contents">
|
||||||
|
Crée une entrée de contenu (sans upload de fichier direct). Utile pour référencer des URLs externes.
|
||||||
|
**Corps :** `title`, `description`, `url`, `type`, `categoryId`, `tags`.
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="POST /contents/upload">
|
<Accordion title="POST /contents/upload">
|
||||||
Upload un fichier avec traitement automatique.
|
Upload un fichier avec traitement automatique par le serveur.
|
||||||
**Type :** `multipart/form-data`
|
**Type :** `multipart/form-data`
|
||||||
|
|
||||||
**Champs :**
|
**Champs :**
|
||||||
@@ -145,6 +173,11 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
|||||||
- `tags`? : string[]
|
- `tags`? : string[]
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion title="POST /contents/upload-url">
|
||||||
|
Génère une URL présignée pour un upload direct vers S3.
|
||||||
|
**Query Param :** `fileName` (string).
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="POST /contents/:id/view | /use">
|
<Accordion title="POST /contents/:id/view | /use">
|
||||||
Incrémente les statistiques de vue ou d'utilisation.
|
Incrémente les statistiques de vue ou d'utilisation.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
@@ -152,6 +185,10 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
|||||||
<Accordion title="DELETE /contents/:id">
|
<Accordion title="DELETE /contents/:id">
|
||||||
Supprime un contenu (Soft Delete). Doit être l'auteur.
|
Supprime un contenu (Soft Delete). Doit être l'auteur.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion title="DELETE /contents/:id/admin">
|
||||||
|
Supprime définitivement un contenu. **Réservé aux administrateurs.**
|
||||||
|
</Accordion>
|
||||||
</Accordions>
|
</Accordions>
|
||||||
|
|
||||||
### 📂 Catégories, ⭐ Favoris, 🚩 Signalements
|
### 📂 Catégories, ⭐ Favoris, 🚩 Signalements
|
||||||
@@ -159,19 +196,23 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
|||||||
<Accordions>
|
<Accordions>
|
||||||
<Accordion title="Catégories (/categories)">
|
<Accordion title="Catégories (/categories)">
|
||||||
- `GET /categories` : Liste toutes les catégories.
|
- `GET /categories` : Liste toutes les catégories.
|
||||||
|
- `GET /categories/:id` : Détails d'une catégorie.
|
||||||
- `POST /categories` : Création (Admin uniquement).
|
- `POST /categories` : Création (Admin uniquement).
|
||||||
|
- `PATCH /categories/:id` : Mise à jour (Admin uniquement).
|
||||||
|
- `DELETE /categories/:id` : Suppression (Admin uniquement).
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="Favoris (/favorites)">
|
<Accordion title="Favoris (/favorites)">
|
||||||
- `GET /favorites` : Liste les favoris de l'utilisateur.
|
Requiert l'authentification.
|
||||||
|
- `GET /favorites` : Liste les favoris de l'utilisateur (avec pagination `limit`, `offset`).
|
||||||
- `POST /favorites/:contentId` : Ajoute un favori.
|
- `POST /favorites/:contentId` : Ajoute un favori.
|
||||||
- `DELETE /favorites/:contentId` : Retire un favori.
|
- `DELETE /favorites/:contentId` : Retire un favori.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="Signalements (/reports)">
|
<Accordion title="Signalements (/reports)">
|
||||||
- `POST /reports` : Signale un contenu ou un tag.
|
- `POST /reports` : Signale un contenu ou un tag.
|
||||||
- `GET /reports` : Liste (Modérateurs).
|
- `GET /reports` : Liste des signalements (Pagination `limit`, `offset`). **Admin/Modérateurs**.
|
||||||
- `PATCH /reports/:id/status` : Gère le workflow.
|
- `PATCH /reports/:id/status` : Change le statut (`pending`, `resolved`, `dismissed`). **Admin/Modérateurs**.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</Accordions>
|
</Accordions>
|
||||||
|
|
||||||
@@ -185,7 +226,23 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
|||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="Tags (/tags)">
|
<Accordion title="Tags (/tags)">
|
||||||
- `GET /tags` : Recherche de tags populaires ou récents.
|
- `GET /tags` : Recherche de tags.
|
||||||
**Params :** `query`, `sort`, `limit`.
|
- **Params :** `query` (recherche), `sort` (`popular` | `recent`), `limit`, `offset`.
|
||||||
|
</Accordion>
|
||||||
|
</Accordions>
|
||||||
|
|
||||||
|
### 🛠️ Système & Médias
|
||||||
|
|
||||||
|
<Accordions>
|
||||||
|
<Accordion title="Santé (/health)">
|
||||||
|
- `GET /health` : Vérifie l'état de l'API et de la connexion à la base de données.
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion title="Médias (/media)">
|
||||||
|
- `GET /media/*key` : Accès direct aux fichiers stockés sur S3. Supporte la mise en cache agressive.
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion title="Administration (/admin)">
|
||||||
|
- `GET /admin/stats` : Récupère les statistiques globales de la plateforme. **Admin uniquement**.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</Accordions>
|
</Accordions>
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ Le système utilise plusieurs méthodes d'authentification sécurisées pour ré
|
|||||||
<Card title="Double Authentification" description="Support TOTP natif avec secret chiffré PGP pour une sécurité maximale." />
|
<Card title="Double Authentification" description="Support TOTP natif avec secret chiffré PGP pour une sécurité maximale." />
|
||||||
</Cards>
|
</Cards>
|
||||||
|
|
||||||
### Webhooks / Services Externes
|
### Stockage & Médias (S3)
|
||||||
|
|
||||||
Liste des intégrations tierces.
|
Memegoat utilise une architecture de stockage d'objets compatible S3 (MinIO). Les interactions se font de deux manières :
|
||||||
|
|
||||||
|
1. **Proxification Backend** : Pour l'accès public via `/media/*`.
|
||||||
|
2. **URLs Présignées** : Pour l'upload sécurisé direct depuis le client (via `/contents/upload-url`).
|
||||||
|
|
||||||
|
### Notifications (Mail)
|
||||||
|
|
||||||
|
Le système intègre un service d'envoi d'emails (SMTP) pour les notifications critiques et la gestion des comptes.
|
||||||
|
|||||||
@@ -35,10 +35,13 @@ erDiagram
|
|||||||
string username
|
string username
|
||||||
string email
|
string email
|
||||||
string display_name
|
string display_name
|
||||||
|
string avatar_url
|
||||||
|
string bio
|
||||||
string status
|
string status
|
||||||
}
|
}
|
||||||
CONTENT {
|
CONTENT {
|
||||||
string title
|
string title
|
||||||
|
string slug
|
||||||
string type
|
string type
|
||||||
string storage_key
|
string storage_key
|
||||||
}
|
}
|
||||||
@@ -82,6 +85,8 @@ erDiagram
|
|||||||
bytea email
|
bytea email
|
||||||
varchar email_hash
|
varchar email_hash
|
||||||
varchar display_name
|
varchar display_name
|
||||||
|
varchar avatar_url
|
||||||
|
varchar bio
|
||||||
varchar password_hash
|
varchar password_hash
|
||||||
user_status status
|
user_status status
|
||||||
bytea two_factor_secret
|
bytea two_factor_secret
|
||||||
@@ -100,6 +105,7 @@ erDiagram
|
|||||||
uuid category_id FK
|
uuid category_id FK
|
||||||
content_type type
|
content_type type
|
||||||
varchar title
|
varchar title
|
||||||
|
varchar slug
|
||||||
varchar storage_key
|
varchar storage_key
|
||||||
varchar mime_type
|
varchar mime_type
|
||||||
integer file_size
|
integer file_size
|
||||||
@@ -233,6 +239,8 @@ erDiagram
|
|||||||
varchar email_hash "UNIQUE, INDEXED"
|
varchar email_hash "UNIQUE, INDEXED"
|
||||||
varchar username "UNIQUE, NOT NULL"
|
varchar username "UNIQUE, NOT NULL"
|
||||||
varchar password_hash "NOT NULL"
|
varchar password_hash "NOT NULL"
|
||||||
|
varchar avatar_url "NULLABLE"
|
||||||
|
varchar bio "NULLABLE"
|
||||||
bytea two_factor_secret "ENCRYPTED"
|
bytea two_factor_secret "ENCRYPTED"
|
||||||
boolean is_two_factor_enabled "DEFAULT false"
|
boolean is_two_factor_enabled "DEFAULT false"
|
||||||
timestamp gdpr_accepted_at "NULLABLE"
|
timestamp gdpr_accepted_at "NULLABLE"
|
||||||
@@ -241,6 +249,7 @@ erDiagram
|
|||||||
contents {
|
contents {
|
||||||
uuid id "DEFAULT gen_random_uuid()"
|
uuid id "DEFAULT gen_random_uuid()"
|
||||||
uuid user_id "REFERENCES users(uuid)"
|
uuid user_id "REFERENCES users(uuid)"
|
||||||
|
varchar slug "UNIQUE, NOT NULL"
|
||||||
varchar storage_key "UNIQUE, NOT NULL"
|
varchar storage_key "UNIQUE, NOT NULL"
|
||||||
integer file_size "NOT NULL"
|
integer file_size "NOT NULL"
|
||||||
timestamp deleted_at "SOFT DELETE"
|
timestamp deleted_at "SOFT DELETE"
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ const withMDX = createMDX();
|
|||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
output: 'standalone',
|
output: "standalone",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withMDX(config);
|
export default withMDX(config);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@memegoat/documentation",
|
"name": "@memegoat/documentation",
|
||||||
"version": "0.0.1",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Code,
|
||||||
|
Database,
|
||||||
|
Scale,
|
||||||
|
Server,
|
||||||
|
Shield,
|
||||||
|
Zap,
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Shield, Scale, Zap, Database, Server, Code, ArrowRight } from "lucide-react";
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
@@ -33,8 +41,9 @@ export default function HomePage() {
|
|||||||
La Bible Technique de MemeGoat 🐐
|
La Bible Technique de MemeGoat 🐐
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-6 text-xl leading-8 text-muted-foreground">
|
<p className="mt-6 text-xl leading-8 text-muted-foreground">
|
||||||
Parce que partager des MEME de qualité demande une infrastructure qui ne broute pas.
|
Parce que partager des MEME de qualité demande une infrastructure qui ne
|
||||||
Découvrez comment nous avons bâti le futur du rire avec une pointe de sérieux (mais pas trop).
|
broute pas. Découvrez comment nous avons bâti le futur du rire avec une
|
||||||
|
pointe de sérieux (mais pas trop).
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-12 flex flex-col sm:flex-row items-center justify-center gap-4 sm:gap-x-6">
|
<div className="mt-12 flex flex-col sm:flex-row items-center justify-center gap-4 sm:gap-x-6">
|
||||||
<Link
|
<Link
|
||||||
@@ -58,7 +67,9 @@ export default function HomePage() {
|
|||||||
<section className="py-24 bg-fd-background/50 border-y border-border">
|
<section className="py-24 bg-fd-background/50 border-y border-border">
|
||||||
<div className="container px-6 lg:px-8 mx-auto">
|
<div className="container px-6 lg:px-8 mx-auto">
|
||||||
<div className="mx-auto max-w-2xl lg:text-center mb-16">
|
<div className="mx-auto max-w-2xl lg:text-center mb-16">
|
||||||
<h2 className="text-base font-bold leading-7 text-primary uppercase tracking-widest">Les Fondations</h2>
|
<h2 className="text-base font-bold leading-7 text-primary uppercase tracking-widest">
|
||||||
|
Les Fondations
|
||||||
|
</h2>
|
||||||
<p className="mt-2 text-4xl font-extrabold tracking-tight text-foreground sm:text-5xl">
|
<p className="mt-2 text-4xl font-extrabold tracking-tight text-foreground sm:text-5xl">
|
||||||
Une plateforme bâtie pour tenir
|
Une plateforme bâtie pour tenir
|
||||||
</p>
|
</p>
|
||||||
@@ -68,21 +79,27 @@ export default function HomePage() {
|
|||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
name: "Coffre-Fort Bêêêê-ton",
|
name: "Coffre-Fort Bêêêê-ton",
|
||||||
description: "Chiffrement PGP au repos et hachage Argon2id. Vos données sont plus en sécurité ici que dans un enclos fermé à double tour.",
|
description:
|
||||||
|
"Chiffrement PGP au repos et hachage Argon2id. Vos données sont plus en sécurité ici que dans un enclos fermé à double tour.",
|
||||||
icon: Shield,
|
icon: Shield,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "RGPD & Relax",
|
name: "RGPD & Relax",
|
||||||
description: "Droit à l'oubli et portabilité. On respecte votre vie privée autant que vous respectez un bon mème bien placé.",
|
description:
|
||||||
|
"Droit à l'oubli et portabilité. On respecte votre vie privée autant que vous respectez un bon mème bien placé.",
|
||||||
icon: Scale,
|
icon: Scale,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Vitesse Turbo-Chèvre",
|
name: "Vitesse Turbo-Chèvre",
|
||||||
description: "Transcodage WebP/WebM instantané. Vos GIFs se chargent plus vite que votre ombre, même sur le vieux smartphone de mamie.",
|
description:
|
||||||
|
"Transcodage WebP/WebM instantané. Vos GIFs se chargent plus vite que votre ombre, même sur le vieux smartphone de mamie.",
|
||||||
icon: Zap,
|
icon: Zap,
|
||||||
},
|
},
|
||||||
].map((feature) => (
|
].map((feature) => (
|
||||||
<div key={feature.name} className="flex flex-col border border-border p-10 rounded-3xl bg-card hover:border-primary/50 transition-all shadow-md hover:shadow-xl group">
|
<div
|
||||||
|
key={feature.name}
|
||||||
|
className="flex flex-col border border-border p-10 rounded-3xl bg-card hover:border-primary/50 transition-all shadow-md hover:shadow-xl group"
|
||||||
|
>
|
||||||
<dt className="flex items-center gap-x-4 text-xl font-bold leading-7 text-foreground">
|
<dt className="flex items-center gap-x-4 text-xl font-bold leading-7 text-foreground">
|
||||||
<div className="p-3 rounded-xl bg-primary/10 group-hover:bg-primary group-hover:text-primary-foreground transition-colors">
|
<div className="p-3 rounded-xl bg-primary/10 group-hover:bg-primary group-hover:text-primary-foreground transition-colors">
|
||||||
<feature.icon className="h-6 w-6" aria-hidden="true" />
|
<feature.icon className="h-6 w-6" aria-hidden="true" />
|
||||||
@@ -103,7 +120,9 @@ export default function HomePage() {
|
|||||||
<section className="py-24 sm:py-32 overflow-hidden">
|
<section className="py-24 sm:py-32 overflow-hidden">
|
||||||
<div className="container px-6 lg:px-8 mx-auto">
|
<div className="container px-6 lg:px-8 mx-auto">
|
||||||
<div className="mx-auto max-w-2xl lg:text-center mb-20">
|
<div className="mx-auto max-w-2xl lg:text-center mb-20">
|
||||||
<h2 className="text-base font-bold leading-7 text-primary uppercase tracking-widest text-center">Stack Technique</h2>
|
<h2 className="text-base font-bold leading-7 text-primary uppercase tracking-widest text-center">
|
||||||
|
Stack Technique
|
||||||
|
</h2>
|
||||||
<p className="mt-2 text-4xl font-extrabold tracking-tight text-foreground sm:text-5xl text-center">
|
<p className="mt-2 text-4xl font-extrabold tracking-tight text-foreground sm:text-5xl text-center">
|
||||||
Technologies de pointe
|
Technologies de pointe
|
||||||
</p>
|
</p>
|
||||||
@@ -111,13 +130,22 @@ export default function HomePage() {
|
|||||||
<div className="mx-auto max-w-5xl">
|
<div className="mx-auto max-w-5xl">
|
||||||
<div className="grid grid-cols-2 gap-6 md:grid-cols-4">
|
<div className="grid grid-cols-2 gap-6 md:grid-cols-4">
|
||||||
{[
|
{[
|
||||||
{ name: "Next.js 16", icon: Code, color: "hover:text-black dark:hover:text-white" },
|
{
|
||||||
|
name: "Next.js 16",
|
||||||
|
icon: Code,
|
||||||
|
color: "hover:text-black dark:hover:text-white",
|
||||||
|
},
|
||||||
{ name: "NestJS 11", icon: Server, color: "hover:text-red-500" },
|
{ name: "NestJS 11", icon: Server, color: "hover:text-red-500" },
|
||||||
{ name: "PostgreSQL 15", icon: Database, color: "hover:text-blue-500" },
|
{ name: "PostgreSQL 15", icon: Database, color: "hover:text-blue-500" },
|
||||||
{ name: "Chèvre-Power", icon: Zap, color: "hover:text-orange-500" },
|
{ name: "Chèvre-Power", icon: Zap, color: "hover:text-orange-500" },
|
||||||
].map((tech) => (
|
].map((tech) => (
|
||||||
<div key={tech.name} className="flex flex-col items-center p-8 border border-border rounded-2xl bg-card/50 hover:bg-card hover:border-primary transition-all group">
|
<div
|
||||||
<tech.icon className={`h-12 w-12 text-muted-foreground mb-4 transition-colors ${tech.color}`} />
|
key={tech.name}
|
||||||
|
className="flex flex-col items-center p-8 border border-border rounded-2xl bg-card/50 hover:bg-card hover:border-primary transition-all group"
|
||||||
|
>
|
||||||
|
<tech.icon
|
||||||
|
className={`h-12 w-12 text-muted-foreground mb-4 transition-colors ${tech.color}`}
|
||||||
|
/>
|
||||||
<span className="font-bold text-lg">{tech.name}</span>
|
<span className="font-bold text-lg">{tech.name}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -131,31 +159,49 @@ export default function HomePage() {
|
|||||||
<div className="container px-6 lg:px-8 mx-auto">
|
<div className="container px-6 lg:px-8 mx-auto">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 items-center">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 items-center">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl font-extrabold mb-6 italic text-primary">"On ne rigole pas avec le rire. Surtout quand il s'agit de vous." 🐐</h2>
|
<h2 className="text-3xl font-extrabold mb-6 italic text-primary">
|
||||||
|
"On ne rigole pas avec le rire. Surtout quand il s'agit de vous." 🐐
|
||||||
|
</h2>
|
||||||
<p className="text-lg text-muted-foreground mb-8">
|
<p className="text-lg text-muted-foreground mb-8">
|
||||||
Memegoat n'est pas qu'un site pour scroller à l'infini. C'est un site qui as pour but de partager des MEME de qualité sans surconsommer les ressources mondiales avec des fichiers beaucoup trop gros et des requêtes trop nombreuses.
|
Memegoat n'est pas qu'un site pour scroller à l'infini. C'est un site
|
||||||
|
qui as pour but de partager des MEME de qualité sans surconsommer les
|
||||||
|
ressources mondiales avec des fichiers beaucoup trop gros et des
|
||||||
|
requêtes trop nombreuses.
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/docs/index"
|
href="/docs/index"
|
||||||
className="inline-flex items-center text-primary font-bold hover:gap-3 transition-all gap-2 text-lg"
|
className="inline-flex items-center text-primary font-bold hover:gap-3 transition-all gap-2 text-lg"
|
||||||
>
|
>
|
||||||
Plongez dans le code (garanti sans crottin) <ArrowRight className="h-6 w-6" />
|
Plongez dans le code (garanti sans crottin){" "}
|
||||||
|
<ArrowRight className="h-6 w-6" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<Link href="/docs/database" className="p-6 border border-border rounded-2xl hover:bg-accent transition-colors font-bold flex flex-col gap-2">
|
<Link
|
||||||
|
href="/docs/database"
|
||||||
|
className="p-6 border border-border rounded-2xl hover:bg-accent transition-colors font-bold flex flex-col gap-2"
|
||||||
|
>
|
||||||
<Database className="h-6 w-6 text-primary" />
|
<Database className="h-6 w-6 text-primary" />
|
||||||
Modèle de Données
|
Modèle de Données
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/docs/security" className="p-6 border border-border rounded-2xl hover:bg-accent transition-colors font-bold flex flex-col gap-2">
|
<Link
|
||||||
|
href="/docs/security"
|
||||||
|
className="p-6 border border-border rounded-2xl hover:bg-accent transition-colors font-bold flex flex-col gap-2"
|
||||||
|
>
|
||||||
<Shield className="h-6 w-6 text-primary" />
|
<Shield className="h-6 w-6 text-primary" />
|
||||||
Sécurité PGP
|
Sécurité PGP
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/docs/api-reference" className="p-6 border border-border rounded-2xl hover:bg-accent transition-colors font-bold flex flex-col gap-2">
|
<Link
|
||||||
|
href="/docs/api-reference"
|
||||||
|
className="p-6 border border-border rounded-2xl hover:bg-accent transition-colors font-bold flex flex-col gap-2"
|
||||||
|
>
|
||||||
<Code className="h-6 w-6 text-primary" />
|
<Code className="h-6 w-6 text-primary" />
|
||||||
Référence API
|
Référence API
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/docs/deployment" className="p-6 border border-border rounded-2xl hover:bg-accent transition-colors font-bold flex flex-col gap-2">
|
<Link
|
||||||
|
href="/docs/deployment"
|
||||||
|
className="p-6 border border-border rounded-2xl hover:bg-accent transition-colors font-bold flex flex-col gap-2"
|
||||||
|
>
|
||||||
<Server className="h-6 w-6 text-primary" />
|
<Server className="h-6 w-6 text-primary" />
|
||||||
Déploiement
|
Déploiement
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { Image } from "fumadocs-core/framework";
|
||||||
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
|
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
|
||||||
import {Image} from "fumadocs-core/framework";
|
|
||||||
|
|
||||||
export function baseOptions(): BaseLayoutProps {
|
export function baseOptions(): BaseLayoutProps {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
|
import { Accordion, Accordions } from "fumadocs-ui/components/accordion";
|
||||||
|
import { Callout } from "fumadocs-ui/components/callout";
|
||||||
|
import { Card, Cards } from "fumadocs-ui/components/card";
|
||||||
|
import { Step, Steps } from "fumadocs-ui/components/steps";
|
||||||
|
import { Tab, Tabs } from "fumadocs-ui/components/tabs";
|
||||||
import defaultMdxComponents from "fumadocs-ui/mdx";
|
import defaultMdxComponents from "fumadocs-ui/mdx";
|
||||||
import type { MDXComponents } from "mdx/types";
|
import type { MDXComponents } from "mdx/types";
|
||||||
import { Mermaid } from "@/components/mdx/mermaid";
|
import { Mermaid } from "@/components/mdx/mermaid";
|
||||||
import { Card, Cards } from "fumadocs-ui/components/card";
|
|
||||||
import { Callout } from "fumadocs-ui/components/callout";
|
|
||||||
import { Step, Steps } from "fumadocs-ui/components/steps";
|
|
||||||
import { Tabs, Tab } from "fumadocs-ui/components/tabs";
|
|
||||||
import { Accordion, Accordions } from "fumadocs-ui/components/accordion";
|
|
||||||
|
|
||||||
export function getMDXComponents(components?: MDXComponents): MDXComponents {
|
export function getMDXComponents(components?: MDXComponents): MDXComponents {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# syntax=docker.io/docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
FROM node:22-alpine AS base
|
FROM node:22-alpine AS base
|
||||||
ENV PNPM_HOME="/pnpm"
|
ENV PNPM_HOME="/pnpm"
|
||||||
@@ -11,11 +11,20 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
|||||||
COPY backend/package.json ./backend/
|
COPY backend/package.json ./backend/
|
||||||
COPY frontend/package.json ./frontend/
|
COPY frontend/package.json ./frontend/
|
||||||
COPY documentation/package.json ./documentation/
|
COPY documentation/package.json ./documentation/
|
||||||
RUN pnpm install --no-frozen-lockfile
|
|
||||||
|
# Montage du cache pnpm
|
||||||
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
# On réinstalle après COPY pour s'assurer que tous les scripts de cycle de vie et les liens sont corrects
|
|
||||||
RUN pnpm install --no-frozen-lockfile
|
# Deuxième passe avec cache pour les scripts/liens
|
||||||
RUN pnpm run --filter @memegoat/frontend build
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Build avec cache Next.js
|
||||||
|
RUN --mount=type=cache,id=next-cache,target=/usr/src/app/frontend/.next/cache \
|
||||||
|
pnpm run --filter @memegoat/frontend build
|
||||||
|
|
||||||
FROM node:22-alpine AS runner
|
FROM node:22-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -18,6 +18,11 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
|
"a11y": {
|
||||||
|
"useAriaPropsForRole": "warn",
|
||||||
|
"useSemanticElements": "warn",
|
||||||
|
"useFocusableInteractive": "warn"
|
||||||
|
},
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
"noUnknownAtRules": "off"
|
"noUnknownAtRules": "off"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
"style": "new-york",
|
"style": "new-york",
|
||||||
"rsc": true,
|
"rsc": true,
|
||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "",
|
"config": "",
|
||||||
"css": "src/app/globals.css",
|
"css": "src/app/globals.css",
|
||||||
"baseColor": "stone",
|
"baseColor": "stone",
|
||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
"prefix": ""
|
"prefix": ""
|
||||||
},
|
},
|
||||||
"iconLibrary": "lucide",
|
"iconLibrary": "lucide",
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/components",
|
"components": "@/components",
|
||||||
"utils": "@/lib/utils",
|
"utils": "@/lib/utils",
|
||||||
"ui": "@/components/ui",
|
"ui": "@/components/ui",
|
||||||
"lib": "@/lib",
|
"lib": "@/lib",
|
||||||
"hooks": "@/hooks"
|
"hooks": "@/hooks"
|
||||||
},
|
},
|
||||||
"registries": {}
|
"registries": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@memegoat/frontend",
|
"name": "@memegoat/frontend",
|
||||||
"version": "0.0.1",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
@@ -1,124 +1,128 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import { useAuth } from "@/providers/auth-provider";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardFooter,
|
CardFooter,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useAuth } from "@/providers/auth-provider";
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
email: z.string().email({ message: "Email invalide" }),
|
email: z.string().email({ message: "Email invalide" }),
|
||||||
password: z.string().min(6, { message: "Le mot de passe doit faire au moins 6 caractères" }),
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(6, { message: "Le mot de passe doit faire au moins 6 caractères" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
type LoginFormValues = z.infer<typeof loginSchema>;
|
type LoginFormValues = z.infer<typeof loginSchema>;
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
|
||||||
const form = useForm<LoginFormValues>({
|
const form = useForm<LoginFormValues>({
|
||||||
resolver: zodResolver(loginSchema),
|
resolver: zodResolver(loginSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onSubmit(values: LoginFormValues) {
|
async function onSubmit(values: LoginFormValues) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await login(values.email, values.password);
|
await login(values.email, values.password);
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
// Error is handled in useAuth via toast
|
// Error is handled in useAuth via toast
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-zinc-950 p-4">
|
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-zinc-950 p-4">
|
||||||
<div className="w-full max-w-md space-y-4">
|
<div className="w-full max-w-md space-y-4">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="inline-flex items-center text-sm text-muted-foreground hover:text-primary transition-colors"
|
className="inline-flex items-center text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
Retour à l'accueil
|
Retour à l'accueil
|
||||||
</Link>
|
</Link>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl">Connexion</CardTitle>
|
<CardTitle className="text-2xl">Connexion</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Entrez vos identifiants pour accéder à votre compte MemeGoat.
|
Entrez vos identifiants pour accéder à votre compte MemeGoat.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="email"
|
name="email"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Email</FormLabel>
|
<FormLabel>Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="goat@example.com" {...field} />
|
<Input placeholder="goat@example.com" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="password"
|
name="password"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Mot de passe</FormLabel>
|
<FormLabel>Mot de passe</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="password" placeholder="••••••••" {...field} />
|
<Input type="password" placeholder="••••••••" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Button type="submit" className="w-full" disabled={loading}>
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
{loading ? "Connexion en cours..." : "Se connecter"}
|
{loading ? "Connexion en cours..." : "Se connecter"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex flex-col space-y-2">
|
<CardFooter className="flex flex-col space-y-2">
|
||||||
<p className="text-sm text-center text-muted-foreground">
|
<p className="text-sm text-center text-muted-foreground">
|
||||||
Vous n'avez pas de compte ?{" "}
|
Vous n'avez pas de compte ?{" "}
|
||||||
<Link href="/register" className="text-primary hover:underline font-medium">
|
<Link
|
||||||
S'inscrire
|
href="/register"
|
||||||
</Link>
|
className="text-primary hover:underline font-medium"
|
||||||
</p>
|
>
|
||||||
</CardFooter>
|
S'inscrire
|
||||||
</Card>
|
</Link>
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</CardFooter>
|
||||||
);
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,153 +1,157 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import { useAuth } from "@/providers/auth-provider";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardFooter,
|
CardFooter,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useAuth } from "@/providers/auth-provider";
|
||||||
|
|
||||||
const registerSchema = z.object({
|
const registerSchema = z.object({
|
||||||
username: z.string().min(3, { message: "Le pseudo doit faire au moins 3 caractères" }),
|
username: z
|
||||||
email: z.string().email({ message: "Email invalide" }),
|
.string()
|
||||||
password: z.string().min(6, { message: "Le mot de passe doit faire au moins 6 caractères" }),
|
.min(3, { message: "Le pseudo doit faire au moins 3 caractères" }),
|
||||||
displayName: z.string().optional(),
|
email: z.string().email({ message: "Email invalide" }),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(6, { message: "Le mot de passe doit faire au moins 6 caractères" }),
|
||||||
|
displayName: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type RegisterFormValues = z.infer<typeof registerSchema>;
|
type RegisterFormValues = z.infer<typeof registerSchema>;
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const { register } = useAuth();
|
const { register } = useAuth();
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
|
||||||
const form = useForm<RegisterFormValues>({
|
const form = useForm<RegisterFormValues>({
|
||||||
resolver: zodResolver(registerSchema),
|
resolver: zodResolver(registerSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
username: "",
|
username: "",
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: "",
|
||||||
displayName: "",
|
displayName: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onSubmit(values: RegisterFormValues) {
|
async function onSubmit(values: RegisterFormValues) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await register(values);
|
await register(values);
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
// Error handled in useAuth
|
// Error handled in useAuth
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-zinc-950 p-4">
|
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-zinc-950 p-4">
|
||||||
<div className="w-full max-w-md space-y-4">
|
<div className="w-full max-w-md space-y-4">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="inline-flex items-center text-sm text-muted-foreground hover:text-primary transition-colors"
|
className="inline-flex items-center text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
Retour à l'accueil
|
Retour à l'accueil
|
||||||
</Link>
|
</Link>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl">Inscription</CardTitle>
|
<CardTitle className="text-2xl">Inscription</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Rejoignez la communauté MemeGoat dès aujourd'hui.
|
Rejoignez la communauté MemeGoat dès aujourd'hui.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="username"
|
name="username"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Pseudo</FormLabel>
|
<FormLabel>Pseudo</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="supergoat" {...field} />
|
<Input placeholder="supergoat" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="email"
|
name="email"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Email</FormLabel>
|
<FormLabel>Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="goat@example.com" {...field} />
|
<Input placeholder="goat@example.com" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="displayName"
|
name="displayName"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Nom d'affichage (Optionnel)</FormLabel>
|
<FormLabel>Nom d'affichage (Optionnel)</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="Le Roi des Chèvres" {...field} />
|
<Input placeholder="Le Roi des Chèvres" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="password"
|
name="password"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Mot de passe</FormLabel>
|
<FormLabel>Mot de passe</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="password" placeholder="••••••••" {...field} />
|
<Input type="password" placeholder="••••••••" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Button type="submit" className="w-full" disabled={loading}>
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
{loading ? "Création du compte..." : "S'inscrire"}
|
{loading ? "Création du compte..." : "S'inscrire"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex flex-col space-y-2">
|
<CardFooter className="flex flex-col space-y-2">
|
||||||
<p className="text-sm text-center text-muted-foreground">
|
<p className="text-sm text-center text-muted-foreground">
|
||||||
Vous avez déjà un compte ?{" "}
|
Vous avez déjà un compte ?{" "}
|
||||||
<Link href="/login" className="text-primary hover:underline font-medium">
|
<Link href="/login" className="text-primary hover:underline font-medium">
|
||||||
Se connecter
|
Se connecter
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,60 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
import * as React from "react";
|
||||||
import { ContentService } from "@/services/content.service";
|
|
||||||
import { ContentCard } from "@/components/content-card";
|
import { ContentCard } from "@/components/content-card";
|
||||||
import type { Content } from "@/types/content";
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { ViewCounter } from "@/components/view-counter";
|
||||||
|
import { ContentService } from "@/services/content.service";
|
||||||
|
import type { Content } from "@/types/content";
|
||||||
|
|
||||||
export default function MemeModal({ params }: { params: Promise<{ slug: string }> }) {
|
export default function MemeModal({
|
||||||
const { slug } = React.use(params);
|
params,
|
||||||
const router = useRouter();
|
}: {
|
||||||
const [content, setContent] = React.useState<Content | null>(null);
|
params: Promise<{ slug: string }>;
|
||||||
const [loading, setLoading] = React.useState(true);
|
}) {
|
||||||
|
const { slug } = React.use(params);
|
||||||
|
const router = useRouter();
|
||||||
|
const [content, setContent] = React.useState<Content | null>(null);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
ContentService.getOne(slug)
|
ContentService.getOne(slug)
|
||||||
.then(setContent)
|
.then(setContent)
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [slug]);
|
}, [slug]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(open) => !open && router.back()}>
|
<Dialog open onOpenChange={(open) => !open && router.back()}>
|
||||||
<DialogContent className="max-w-3xl p-0 overflow-hidden bg-transparent border-none">
|
<DialogContent className="max-w-3xl p-0 overflow-hidden bg-transparent border-none">
|
||||||
<DialogTitle className="sr-only">{content?.title || "Détail du mème"}</DialogTitle>
|
<DialogTitle className="sr-only">
|
||||||
<DialogDescription className="sr-only">Affiche le mème en grand avec ses détails</DialogDescription>
|
{content?.title || "Détail du mème"}
|
||||||
{loading ? (
|
</DialogTitle>
|
||||||
<div className="h-[500px] flex items-center justify-center bg-zinc-950/50 rounded-lg">
|
<DialogDescription className="sr-only">
|
||||||
<Spinner className="h-10 w-10 text-white" />
|
Affiche le mème en grand avec ses détails
|
||||||
</div>
|
</DialogDescription>
|
||||||
) : content ? (
|
{loading ? (
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden">
|
<div className="h-[500px] flex items-center justify-center bg-zinc-950/50 rounded-lg">
|
||||||
<ContentCard content={content} />
|
<Spinner className="h-10 w-10 text-white" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : content ? (
|
||||||
<div className="p-8 bg-white dark:bg-zinc-900 rounded-lg text-center">
|
<div className="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden">
|
||||||
<p>Impossible de charger ce mème.</p>
|
<ViewCounter contentId={content.id} />
|
||||||
</div>
|
<ContentCard content={content} />
|
||||||
)}
|
</div>
|
||||||
</DialogContent>
|
) : (
|
||||||
</Dialog>
|
<div className="p-8 bg-white dark:bg-zinc-900 rounded-lg text-center">
|
||||||
);
|
<p>Impossible de charger ce mème.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export default function Default() {
|
export default function Default() {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,42 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
interface UseInfiniteScrollOptions {
|
interface UseInfiniteScrollOptions {
|
||||||
threshold?: number;
|
threshold?: number;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onLoadMore: () => void;
|
onLoadMore: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useInfiniteScroll({
|
export function useInfiniteScroll({
|
||||||
threshold = 1.0,
|
threshold = 1.0,
|
||||||
hasMore,
|
hasMore,
|
||||||
loading,
|
loading,
|
||||||
onLoadMore,
|
onLoadMore,
|
||||||
}: UseInfiniteScrollOptions) {
|
}: UseInfiniteScrollOptions) {
|
||||||
const loaderRef = React.useRef<HTMLDivElement>(null);
|
const loaderRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
if (entries[0].isIntersecting && hasMore && !loading) {
|
if (entries[0].isIntersecting && hasMore && !loading) {
|
||||||
onLoadMore();
|
onLoadMore();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ threshold }
|
{ threshold },
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentLoader = loaderRef.current;
|
const currentLoader = loaderRef.current;
|
||||||
if (currentLoader) {
|
if (currentLoader) {
|
||||||
observer.observe(currentLoader);
|
observer.observe(currentLoader);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (currentLoader) {
|
if (currentLoader) {
|
||||||
observer.unobserve(currentLoader);
|
observer.unobserve(currentLoader);
|
||||||
}
|
}
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
};
|
};
|
||||||
}, [onLoadMore, hasMore, loading, threshold]);
|
}, [onLoadMore, hasMore, loading, threshold]);
|
||||||
|
|
||||||
return { loaderRef };
|
return { loaderRef };
|
||||||
}
|
}
|
||||||
|
|||||||
83
frontend/src/app/(dashboard)/admin/categories/page.tsx
Normal file
83
frontend/src/app/(dashboard)/admin/categories/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { CategoryService } from "@/services/category.service";
|
||||||
|
import type { Category } from "@/types/content";
|
||||||
|
|
||||||
|
export default function AdminCategoriesPage() {
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
CategoryService.getAll()
|
||||||
|
.then(setCategories)
|
||||||
|
.catch((err) => console.error(err))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">
|
||||||
|
Catégories ({categories.length})
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border bg-card">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Nom</TableHead>
|
||||||
|
<TableHead>Slug</TableHead>
|
||||||
|
<TableHead>Description</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
/* biome-ignore lint/suspicious/noArrayIndexKey: skeleton items don't have unique IDs */
|
||||||
|
<TableRow key={i}>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-[150px]" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-[150px]" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-[250px]" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : categories.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={3} className="text-center h-24">
|
||||||
|
Aucune catégorie trouvée.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
categories.map((category) => (
|
||||||
|
<TableRow key={category.id}>
|
||||||
|
<TableCell className="font-medium whitespace-nowrap">
|
||||||
|
{category.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap">{category.slug}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{category.description || "Aucune description"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
frontend/src/app/(dashboard)/admin/contents/page.tsx
Normal file
152
frontend/src/app/(dashboard)/admin/contents/page.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { fr } from "date-fns/locale";
|
||||||
|
import { Download, Eye, Image as ImageIcon, Trash2, Video } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { ContentService } from "@/services/content.service";
|
||||||
|
import type { Content } from "@/types/content";
|
||||||
|
|
||||||
|
export default function AdminContentsPage() {
|
||||||
|
const [contents, setContents] = useState<Content[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ContentService.getExplore({ limit: 20 })
|
||||||
|
.then((res) => {
|
||||||
|
setContents(res.data);
|
||||||
|
setTotalCount(res.totalCount);
|
||||||
|
})
|
||||||
|
.catch((err) => console.error(err))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm("Êtes-vous sûr de vouloir supprimer ce contenu ?")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ContentService.removeAdmin(id);
|
||||||
|
setContents(contents.filter((c) => c.id !== id));
|
||||||
|
setTotalCount((prev) => prev - 1);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">
|
||||||
|
Contenus ({totalCount})
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border bg-card">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Contenu</TableHead>
|
||||||
|
<TableHead>Catégorie</TableHead>
|
||||||
|
<TableHead>Auteur</TableHead>
|
||||||
|
<TableHead>Stats</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead className="w-[50px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
/* biome-ignore lint/suspicious/noArrayIndexKey: skeleton items don't have unique IDs */
|
||||||
|
<TableRow key={i}>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-10 w-[200px]" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-[100px]" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-[100px]" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-[80px]" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-[100px]" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : contents.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center h-24">
|
||||||
|
Aucun contenu trouvé.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
contents.map((content) => (
|
||||||
|
<TableRow key={content.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded bg-muted">
|
||||||
|
{content.type === "image" ? (
|
||||||
|
<ImageIcon className="h-5 w-5 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<Video className="h-5 w-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold">{content.title}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{content.type} • {content.mimeType}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{content.category?.name || "Sans catégorie"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>@{content.author.username}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col gap-1 text-xs">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Eye className="h-3 w-3" /> {content.views}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Download className="h-3 w-3" /> {content.usageCount}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap">
|
||||||
|
{format(new Date(content.createdAt), "dd/MM/yyyy", { locale: fr })}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDelete(content.id)}
|
||||||
|
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
frontend/src/app/(dashboard)/admin/page.tsx
Normal file
85
frontend/src/app/(dashboard)/admin/page.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AlertCircle, FileText, LayoutGrid, Users } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { type AdminStats, adminService } from "@/services/admin.service";
|
||||||
|
|
||||||
|
export default function AdminDashboardPage() {
|
||||||
|
const [stats, setStats] = useState<AdminStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adminService
|
||||||
|
.getStats()
|
||||||
|
.then(setStats)
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
setError("Impossible de charger les statistiques.");
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[50vh] flex-col items-center justify-center gap-4 text-center">
|
||||||
|
<AlertCircle className="h-12 w-12 text-destructive" />
|
||||||
|
<p className="text-xl font-semibold">{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{
|
||||||
|
title: "Utilisateurs",
|
||||||
|
value: stats?.users,
|
||||||
|
icon: Users,
|
||||||
|
href: "/admin/users",
|
||||||
|
color: "text-blue-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Contenus",
|
||||||
|
value: stats?.contents,
|
||||||
|
icon: FileText,
|
||||||
|
href: "/admin/contents",
|
||||||
|
color: "text-green-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Catégories",
|
||||||
|
value: stats?.categories,
|
||||||
|
icon: LayoutGrid,
|
||||||
|
href: "/admin/categories",
|
||||||
|
color: "text-purple-500",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 space-y-8 p-4 pt-6 md:p-8">
|
||||||
|
<div className="flex items-center justify-between space-y-2">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Dashboard Admin</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{statCards.map((card) => (
|
||||||
|
<Link key={card.title} href={card.href}>
|
||||||
|
<Card className="hover:bg-accent transition-colors cursor-pointer">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">{card.title}</CardTitle>
|
||||||
|
<card.icon className={`h-4 w-4 ${card.color}`} />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton className="h-8 w-20" />
|
||||||
|
) : (
|
||||||
|
<div className="text-2xl font-bold">{card.value}</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
frontend/src/app/(dashboard)/admin/users/page.tsx
Normal file
141
frontend/src/app/(dashboard)/admin/users/page.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { fr } from "date-fns/locale";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { UserService } from "@/services/user.service";
|
||||||
|
import type { User } from "@/types/user";
|
||||||
|
|
||||||
|
export default function AdminUsersPage() {
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
UserService.getUsersAdmin()
|
||||||
|
.then((res) => {
|
||||||
|
setUsers(res.data);
|
||||||
|
setTotalCount(res.totalCount);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDelete = async (uuid: string) => {
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
"Êtes-vous sûr de vouloir supprimer cet utilisateur ? Cette action est irréversible.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await UserService.removeUserAdmin(uuid);
|
||||||
|
setUsers(users.filter((u) => u.uuid !== uuid));
|
||||||
|
setTotalCount((prev) => prev - 1);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">
|
||||||
|
Utilisateurs ({totalCount})
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border bg-card">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Utilisateur</TableHead>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead>Rôle</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Date d'inscription</TableHead>
|
||||||
|
<TableHead className="w-[50px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
/* biome-ignore lint/suspicious/noArrayIndexKey: skeleton items don't have unique IDs */
|
||||||
|
<TableRow key={i}>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-[150px]" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-[200px]" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-[50px]" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-[80px]" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-[100px]" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center h-24">
|
||||||
|
Aucun utilisateur trouvé.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
users.map((user) => (
|
||||||
|
<TableRow key={user.uuid}>
|
||||||
|
<TableCell className="font-medium whitespace-nowrap">
|
||||||
|
{user.displayName || user.username}
|
||||||
|
<div className="text-xs text-muted-foreground">@{user.username}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{user.email}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={user.role === "admin" ? "default" : "secondary"}>
|
||||||
|
{user.role}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={user.status === "active" ? "success" : "destructive"}>
|
||||||
|
{user.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap">
|
||||||
|
{format(new Date(user.createdAt), "PPP", { locale: fr })}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDelete(user.uuid)}
|
||||||
|
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,31 +1,30 @@
|
|||||||
import * as React from "react";
|
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { CategoryContent } from "@/components/category-content";
|
import { CategoryContent } from "@/components/category-content";
|
||||||
import { CategoryService } from "@/services/category.service";
|
import { CategoryService } from "@/services/category.service";
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ slug: string }>
|
params: Promise<{ slug: string }>;
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
try {
|
try {
|
||||||
const categories = await CategoryService.getAll();
|
const categories = await CategoryService.getAll();
|
||||||
const category = categories.find(c => c.slug === slug);
|
const category = categories.find((c) => c.slug === slug);
|
||||||
return {
|
return {
|
||||||
title: `${category?.name || slug} | MemeGoat`,
|
title: `${category?.name || slug} | MemeGoat`,
|
||||||
description: `Découvrez tous les mèmes de la catégorie ${category?.name || slug} sur MemeGoat.`,
|
description: `Découvrez tous les mèmes de la catégorie ${category?.name || slug} sur MemeGoat.`,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
return { title: `Catégorie : ${slug} | MemeGoat` };
|
return { title: `Catégorie : ${slug} | MemeGoat` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function CategoryPage({
|
export default async function CategoryPage({
|
||||||
params
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ slug: string }>
|
params: Promise<{ slug: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
return <CategoryContent slug={slug} />;
|
return <CategoryContent slug={slug} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,54 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import { LayoutGrid } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import * as React from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { CategoryService } from "@/services/category.service";
|
import { CategoryService } from "@/services/category.service";
|
||||||
import type { Category } from "@/types/content";
|
import type { Category } from "@/types/content";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { LayoutGrid } from "lucide-react";
|
|
||||||
|
|
||||||
export default function CategoriesPage() {
|
export default function CategoriesPage() {
|
||||||
const [categories, setCategories] = React.useState<Category[]>([]);
|
const [categories, setCategories] = React.useState<Category[]>([]);
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
CategoryService.getAll()
|
CategoryService.getAll()
|
||||||
.then(setCategories)
|
.then(setCategories)
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto py-8 px-4">
|
<div className="max-w-4xl mx-auto py-8 px-4">
|
||||||
<div className="flex items-center gap-2 mb-8">
|
<div className="flex items-center gap-2 mb-8">
|
||||||
<LayoutGrid className="h-6 w-6" />
|
<LayoutGrid className="h-6 w-6" />
|
||||||
<h1 className="text-3xl font-bold">Catégories</h1>
|
<h1 className="text-3xl font-bold">Catégories</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
|
||||||
{loading ? (
|
{loading
|
||||||
Array.from({ length: 6 }).map((_, i) => (
|
? Array.from({ length: 6 }).map((_, i) => (
|
||||||
<Card key={i} className="animate-pulse">
|
/* biome-ignore lint/suspicious/noArrayIndexKey: skeleton items don't have unique IDs */
|
||||||
<CardHeader className="h-24 bg-zinc-100 dark:bg-zinc-800 rounded-t-lg" />
|
<Card key={`skeleton-${i}`} className="animate-pulse">
|
||||||
<CardContent className="h-12" />
|
<CardHeader className="h-24 bg-zinc-100 dark:bg-zinc-800 rounded-t-lg" />
|
||||||
</Card>
|
<CardContent className="h-12" />
|
||||||
))
|
</Card>
|
||||||
) : (
|
))
|
||||||
categories.map((category) => (
|
: categories.map((category) => (
|
||||||
<Link key={category.id} href={`/category/${category.slug}`}>
|
<Link key={category.id} href={`/category/${category.slug}`}>
|
||||||
<Card className="hover:border-primary transition-colors cursor-pointer group h-full">
|
<Card className="hover:border-primary transition-colors cursor-pointer group h-full">
|
||||||
<CardHeader className="bg-zinc-50 dark:bg-zinc-900 group-hover:bg-primary/5 transition-colors">
|
<CardHeader className="bg-zinc-50 dark:bg-zinc-900 group-hover:bg-primary/5 transition-colors">
|
||||||
<CardTitle className="text-lg">{category.name}</CardTitle>
|
<CardTitle className="text-lg">{category.name}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{category.description || `Découvrez tous les mèmes de la catégorie ${category.name}.`}
|
{category.description ||
|
||||||
</p>
|
`Découvrez tous les mèmes de la catégorie ${category.name}.`}
|
||||||
</CardContent>
|
</p>
|
||||||
</Card>
|
</CardContent>
|
||||||
</Link>
|
</Card>
|
||||||
))
|
</Link>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
70
frontend/src/app/(dashboard)/help/page.tsx
Normal file
70
frontend/src/app/(dashboard)/help/page.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { HelpCircle } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
|
||||||
|
export default function HelpPage() {
|
||||||
|
const faqs = [
|
||||||
|
{
|
||||||
|
question: "Comment puis-je publier un mème ?",
|
||||||
|
answer:
|
||||||
|
"Pour publier un mème, vous devez être connecté à votre compte. Cliquez sur le bouton 'Publier' dans la barre latérale, choisissez votre fichier (image ou GIF), donnez-lui un titre et une catégorie, puis validez.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Quels formats de fichiers sont acceptés ?",
|
||||||
|
answer:
|
||||||
|
"Nous acceptons les images au format PNG, JPEG, WebP et les GIF animés. La taille maximale recommandée est de 2 Mo.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Comment fonctionnent les favoris ?",
|
||||||
|
answer:
|
||||||
|
"En cliquant sur l'icône de cœur sur un mème, vous l'ajoutez à vos favoris. Vous pouvez retrouver tous vos mèmes favoris dans l'onglet 'Mes Favoris' de votre profil.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Puis-je supprimer un mème que j'ai publié ?",
|
||||||
|
answer:
|
||||||
|
"Oui, vous pouvez supprimer vos propres mèmes en vous rendant sur votre profil, en sélectionnant le mème et en cliquant sur l'option de suppression.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Comment fonctionne le système de recherche ?",
|
||||||
|
answer:
|
||||||
|
"Vous pouvez rechercher des mèmes par titre en utilisant la barre de recherche dans la colonne de droite. Vous pouvez également filtrer par catégories ou par tags populaires.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto py-12 px-4">
|
||||||
|
<div className="flex items-center gap-3 mb-8">
|
||||||
|
<div className="bg-primary/10 p-3 rounded-xl">
|
||||||
|
<HelpCircle className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold">Centre d'aide</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-zinc-900 border rounded-2xl p-6 shadow-sm mb-12">
|
||||||
|
<h2 className="text-xl font-semibold mb-6">Foire Aux Questions</h2>
|
||||||
|
<Accordion type="single" collapsible className="w-full">
|
||||||
|
{faqs.map((faq, index) => (
|
||||||
|
<AccordionItem key={faq.question} value={`item-${index}`}>
|
||||||
|
<AccordionTrigger className="text-left">{faq.question}</AccordionTrigger>
|
||||||
|
<AccordionContent className="text-muted-foreground leading-relaxed">
|
||||||
|
{faq.answer}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<h2 className="text-lg font-medium">Vous ne trouvez pas de réponse ?</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
N'hésitez pas à nous contacter sur nos réseaux sociaux ou par email.
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-primary">contact@memegoat.fr</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,37 +1,47 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { SidebarProvider, SidebarTrigger, SidebarInset } from "@/components/ui/sidebar";
|
|
||||||
import { AppSidebar } from "@/components/app-sidebar";
|
import { AppSidebar } from "@/components/app-sidebar";
|
||||||
import { SearchSidebar } from "@/components/search-sidebar";
|
|
||||||
import { MobileFilters } from "@/components/mobile-filters";
|
import { MobileFilters } from "@/components/mobile-filters";
|
||||||
|
import { SearchSidebar } from "@/components/search-sidebar";
|
||||||
|
import {
|
||||||
|
SidebarInset,
|
||||||
|
SidebarProvider,
|
||||||
|
SidebarTrigger,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
import { UserNavMobile } from "@/components/user-nav-mobile";
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
modal,
|
modal,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
modal: React.ReactNode;
|
modal: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<React.Suspense fallback={null}>
|
||||||
<AppSidebar />
|
<SidebarProvider>
|
||||||
<SidebarInset className="flex flex-row overflow-hidden">
|
<AppSidebar />
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
<SidebarInset className="flex flex-row overflow-hidden">
|
||||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4 lg:hidden">
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
<SidebarTrigger />
|
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4 lg:hidden sticky top-0 bg-background z-40">
|
||||||
<div className="flex-1" />
|
<SidebarTrigger />
|
||||||
</header>
|
<div className="flex-1 flex justify-center">
|
||||||
<main className="flex-1 overflow-y-auto bg-zinc-50 dark:bg-zinc-950">
|
<span className="font-bold text-primary text-lg">MemeGoat</span>
|
||||||
{children}
|
</div>
|
||||||
{modal}
|
<UserNavMobile />
|
||||||
</main>
|
</header>
|
||||||
<React.Suspense fallback={null}>
|
<main className="flex-1 overflow-y-auto bg-zinc-50 dark:bg-zinc-950">
|
||||||
<MobileFilters />
|
{children}
|
||||||
</React.Suspense>
|
{modal}
|
||||||
</div>
|
</main>
|
||||||
<React.Suspense fallback={null}>
|
<React.Suspense fallback={null}>
|
||||||
<SearchSidebar />
|
<MobileFilters />
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
</SidebarInset>
|
</div>
|
||||||
</SidebarProvider>
|
<React.Suspense fallback={null}>
|
||||||
);
|
<SearchSidebar />
|
||||||
|
</React.Suspense>
|
||||||
|
</SidebarInset>
|
||||||
|
</SidebarProvider>
|
||||||
|
</React.Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { ContentSkeleton } from "@/components/content-skeleton";
|
import { ContentSkeleton } from "@/components/content-skeleton";
|
||||||
|
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto py-8 px-4 space-y-8">
|
<div className="max-w-2xl mx-auto py-8 px-4 space-y-8">
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{[...Array(3)].map((_, i) => (
|
{[...Array(3)].map((_, i) => (
|
||||||
<ContentSkeleton key={i} />
|
/* biome-ignore lint/suspicious/noArrayIndexKey: skeleton items don't have unique IDs */
|
||||||
))}
|
<ContentSkeleton key={`loading-skeleton-${i}`} />
|
||||||
</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,90 +1,100 @@
|
|||||||
import * as React from "react";
|
|
||||||
import type { Metadata } from "next";
|
|
||||||
import { ContentService } from "@/services/content.service";
|
|
||||||
import { ContentCard } from "@/components/content-card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { ChevronLeft } from "lucide-react";
|
import { ChevronLeft } from "lucide-react";
|
||||||
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { ContentCard } from "@/components/content-card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ViewCounter } from "@/components/view-counter";
|
||||||
|
import { ContentService } from "@/services/content.service";
|
||||||
|
|
||||||
export const revalidate = 3600; // ISR: Revalider toutes les heures
|
export const revalidate = 3600; // ISR: Revalider toutes les heures
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ slug: string }>
|
params: Promise<{ slug: string }>;
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
try {
|
try {
|
||||||
const content = await ContentService.getOne(slug);
|
const content = await ContentService.getOne(slug);
|
||||||
return {
|
return {
|
||||||
title: `${content.title} | MemeGoat`,
|
title: `${content.title} | MemeGoat`,
|
||||||
description: content.description || `Regardez ce mème : ${content.title}`,
|
description: content.description || `Regardez ce mème : ${content.title}`,
|
||||||
openGraph: {
|
openGraph: {
|
||||||
images: [content.thumbnailUrl || content.url],
|
images: [content.thumbnailUrl || content.url],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
return { title: "Mème non trouvé | MemeGoat" };
|
return { title: "Mème non trouvé | MemeGoat" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function MemePage({
|
export default async function MemePage({
|
||||||
params
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ slug: string }>
|
params: Promise<{ slug: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = await ContentService.getOne(slug);
|
const content = await ContentService.getOne(slug);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto py-8 px-4">
|
<div className="max-w-4xl mx-auto py-8 px-4">
|
||||||
<Link href="/" className="inline-flex items-center text-sm mb-6 hover:text-primary transition-colors">
|
<ViewCounter contentId={content.id} />
|
||||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
<Link
|
||||||
Retour au flux
|
href="/"
|
||||||
</Link>
|
className="inline-flex items-center text-sm mb-6 hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||||
<div className="lg:col-span-2">
|
Retour au flux
|
||||||
<ContentCard content={content} />
|
</Link>
|
||||||
</div>
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
<div className="space-y-6">
|
<div className="lg:col-span-2">
|
||||||
<div className="bg-white dark:bg-zinc-900 p-6 rounded-xl shadow-sm border">
|
<ContentCard content={content} />
|
||||||
<h2 className="font-bold text-lg mb-4">À propos de ce mème</h2>
|
</div>
|
||||||
<div className="space-y-4 text-sm">
|
|
||||||
<div>
|
<div className="space-y-6">
|
||||||
<p className="text-muted-foreground">Publié par</p>
|
<div className="bg-white dark:bg-zinc-900 p-6 rounded-xl shadow-sm border">
|
||||||
<p className="font-medium">{content.author.displayName || content.author.username}</p>
|
<h2 className="font-bold text-lg mb-4">À propos de ce mème</h2>
|
||||||
</div>
|
<div className="space-y-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground">Date</p>
|
<p className="text-muted-foreground">Publié par</p>
|
||||||
<p className="font-medium">{new Date(content.createdAt).toLocaleDateString('fr-FR', {
|
<p className="font-medium">
|
||||||
day: 'numeric',
|
{content.author.displayName || content.author.username}
|
||||||
month: 'long',
|
</p>
|
||||||
year: 'numeric'
|
</div>
|
||||||
})}</p>
|
<div>
|
||||||
</div>
|
<p className="text-muted-foreground">Date</p>
|
||||||
{content.description && (
|
<p className="font-medium">
|
||||||
<div>
|
{new Date(content.createdAt).toLocaleDateString("fr-FR", {
|
||||||
<p className="text-muted-foreground">Description</p>
|
day: "numeric",
|
||||||
<p>{content.description}</p>
|
month: "long",
|
||||||
</div>
|
year: "numeric",
|
||||||
)}
|
})}
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{content.description && (
|
||||||
<div className="bg-white dark:bg-zinc-900 p-6 rounded-xl shadow-sm border text-center">
|
<div>
|
||||||
<p className="text-sm text-muted-foreground mb-4">Envie de créer votre propre mème ?</p>
|
<p className="text-muted-foreground">Description</p>
|
||||||
<Button className="w-full">Utiliser ce template</Button>
|
<p>{content.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
} catch (error) {
|
<div className="bg-white dark:bg-zinc-900 p-6 rounded-xl shadow-sm border text-center">
|
||||||
notFound();
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
}
|
Envie de créer votre propre mème ?
|
||||||
|
</p>
|
||||||
|
<Button className="w-full">Utiliser ce template</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} catch (_error) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
import * as React from "react";
|
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import * as React from "react";
|
||||||
import { HomeContent } from "@/components/home-content";
|
import { HomeContent } from "@/components/home-content";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "MemeGoat | La meilleure plateforme de mèmes pour les chèvres",
|
title: "MemeGoat | La meilleure plateforme de mèmes pour les chèvres",
|
||||||
description: "Explorez, créez et partagez les meilleurs mèmes de la communauté. Rejoignez le troupeau sur MemeGoat.",
|
description:
|
||||||
|
"Explorez, créez et partagez les meilleurs mèmes de la communauté. Rejoignez le troupeau sur MemeGoat.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
<React.Suspense fallback={
|
<React.Suspense
|
||||||
<div className="flex items-center justify-center p-12">
|
fallback={
|
||||||
<Spinner className="h-8 w-8 text-primary" />
|
<div className="flex items-center justify-center p-12">
|
||||||
</div>
|
<Spinner className="h-8 w-8 text-primary" />
|
||||||
}>
|
</div>
|
||||||
<HomeContent />
|
}
|
||||||
</React.Suspense>
|
>
|
||||||
);
|
<HomeContent />
|
||||||
|
</React.Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,82 +1,179 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Calendar, Camera, LogIn, LogOut, Settings } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useAuth } from "@/providers/auth-provider";
|
import { toast } from "sonner";
|
||||||
import { ContentList } from "@/components/content-list";
|
import { ContentList } from "@/components/content-list";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { useAuth } from "@/providers/auth-provider";
|
||||||
import { ContentService } from "@/services/content.service";
|
import { ContentService } from "@/services/content.service";
|
||||||
import { FavoriteService } from "@/services/favorite.service";
|
import { FavoriteService } from "@/services/favorite.service";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { UserService } from "@/services/user.service";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Settings, LogOut, Calendar } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const { user, isAuthenticated, isLoading, logout } = useAuth();
|
const { user, isAuthenticated, isLoading, logout, refreshUser } = useAuth();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const tab = searchParams.get("tab") || "memes";
|
||||||
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
if (isLoading) return null;
|
const handleAvatarClick = () => {
|
||||||
if (!isAuthenticated || !user) {
|
fileInputRef.current?.click();
|
||||||
redirect("/login");
|
};
|
||||||
}
|
|
||||||
|
|
||||||
const fetchMyMemes = React.useCallback((params: { limit: number; offset: number }) =>
|
const handleFileChange = async (
|
||||||
ContentService.getExplore({ ...params, author: user.username }),
|
event: React.ChangeEvent<HTMLInputElement>,
|
||||||
[user.username]);
|
) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
const fetchMyFavorites = React.useCallback((params: { limit: number; offset: number }) =>
|
try {
|
||||||
FavoriteService.list(params),
|
await UserService.updateAvatar(file);
|
||||||
[]);
|
toast.success("Avatar mis à jour avec succès !");
|
||||||
|
await refreshUser?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error("Erreur lors de la mise à jour de l'avatar.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
const fetchMyMemes = React.useCallback(
|
||||||
<div className="max-w-4xl mx-auto py-8 px-4">
|
(params: { limit: number; offset: number }) =>
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-8 border shadow-sm mb-8">
|
ContentService.getExplore({ ...params, author: user?.username }),
|
||||||
<div className="flex flex-col md:flex-row items-center gap-8">
|
[user?.username],
|
||||||
<Avatar className="h-32 w-32 border-4 border-primary/10">
|
);
|
||||||
<AvatarImage src={user.avatarUrl} alt={user.username} />
|
|
||||||
<AvatarFallback className="text-4xl">
|
|
||||||
{user.username.slice(0, 2).toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="flex-1 text-center md:text-left space-y-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">{user.displayName || user.username}</h1>
|
|
||||||
<p className="text-muted-foreground">@{user.username}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap justify-center md:justify-start gap-4 text-sm text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Calendar className="h-4 w-4" />
|
|
||||||
Membre depuis {new Date(user.createdAt).toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap justify-center md:justify-start gap-2">
|
|
||||||
<Button asChild variant="outline" size="sm">
|
|
||||||
<Link href="/settings">
|
|
||||||
<Settings className="h-4 w-4 mr-2" />
|
|
||||||
Paramètres
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => logout()} className="text-red-500 hover:text-red-600 hover:bg-red-50">
|
|
||||||
<LogOut className="h-4 w-4 mr-2" />
|
|
||||||
Déconnexion
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs defaultValue="memes" className="w-full">
|
const fetchMyFavorites = React.useCallback(
|
||||||
<TabsList className="grid w-full grid-cols-2 mb-8">
|
(params: { limit: number; offset: number }) => FavoriteService.list(params),
|
||||||
<TabsTrigger value="memes">Mes Mèmes</TabsTrigger>
|
[],
|
||||||
<TabsTrigger value="favorites">Mes Favoris</TabsTrigger>
|
);
|
||||||
</TabsList>
|
|
||||||
<TabsContent value="memes">
|
if (isLoading) {
|
||||||
<ContentList fetchFn={fetchMyMemes} />
|
return (
|
||||||
</TabsContent>
|
<div className="flex h-[400px] items-center justify-center">
|
||||||
<TabsContent value="favorites">
|
<Spinner className="h-8 w-8 text-primary" />
|
||||||
<ContentList fetchFn={fetchMyFavorites} />
|
</div>
|
||||||
</TabsContent>
|
);
|
||||||
</Tabs>
|
}
|
||||||
</div>
|
|
||||||
);
|
if (!isAuthenticated || !user) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto py-8 px-4 text-center">
|
||||||
|
<Card className="p-12">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="mx-auto bg-primary/10 p-4 rounded-full w-fit mb-4">
|
||||||
|
<LogIn className="h-8 w-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>Profil inaccessible</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Vous devez être connecté pour voir votre profil, vos mèmes et vos
|
||||||
|
favoris.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button asChild className="w-full sm:w-auto">
|
||||||
|
<Link href="/login">Se connecter</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto py-8 px-4">
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-8 border shadow-sm mb-8">
|
||||||
|
<div className="flex flex-col md:flex-row items-center gap-8">
|
||||||
|
<div className="relative group">
|
||||||
|
<Avatar className="h-32 w-32 border-4 border-primary/10">
|
||||||
|
<AvatarImage src={user.avatarUrl} alt={user.username} />
|
||||||
|
<AvatarFallback className="text-4xl">
|
||||||
|
{user.username.slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAvatarClick}
|
||||||
|
className="absolute inset-0 flex items-center justify-center bg-black/40 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<Camera className="h-8 w-8" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-center md:text-left space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">
|
||||||
|
{user.displayName || user.username}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">@{user.username}</p>
|
||||||
|
</div>
|
||||||
|
{user.bio && (
|
||||||
|
<p className="max-w-md text-sm leading-relaxed">{user.bio}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap justify-center md:justify-start gap-4 text-sm text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
Membre depuis{" "}
|
||||||
|
{new Date(user.createdAt).toLocaleDateString("fr-FR", {
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap justify-center md:justify-start gap-2">
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link href="/settings">
|
||||||
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
|
Paramètres
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => logout()}
|
||||||
|
className="text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4 mr-2" />
|
||||||
|
Déconnexion
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs value={tab} className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-2 mb-8">
|
||||||
|
<TabsTrigger value="memes" asChild>
|
||||||
|
<Link href="/profile?tab=memes">Mes Mèmes</Link>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="favorites" asChild>
|
||||||
|
<Link href="/profile?tab=favorites">Mes Favoris</Link>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="memes">
|
||||||
|
<ContentList fetchFn={fetchMyMemes} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="favorites">
|
||||||
|
<ContentList fetchFn={fetchMyFavorites} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
"use client";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { ContentList } from "@/components/content-list";
|
import { HomeContent } from "@/components/home-content";
|
||||||
import { ContentService } from "@/services/content.service";
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Nouveautés",
|
||||||
|
description: "Les tout derniers mèmes fraîchement débarqués sur MemeGoat.",
|
||||||
|
};
|
||||||
|
|
||||||
export default function RecentPage() {
|
export default function RecentPage() {
|
||||||
const fetchFn = React.useCallback((params: { limit: number; offset: number }) =>
|
return (
|
||||||
ContentService.getRecent(params.limit, params.offset),
|
<React.Suspense
|
||||||
[]);
|
fallback={
|
||||||
|
<div className="p-8 text-center">Chargement des nouveautés...</div>
|
||||||
return <ContentList fetchFn={fetchFn} title="Nouveaux Mèmes" />;
|
}
|
||||||
|
>
|
||||||
|
<HomeContent defaultSort="recent" />
|
||||||
|
</React.Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user