Compare commits
47 Commits
65f8860cc0
...
v0.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||
| f10c444957 | |||
| 17c2cea366 | |||
| cb6d87eafd | |||
| 570576435c | |||
| c19d86a0cb | |||
| 6756cf6bc7 | |||
| de537e5947 | |||
| 9097a3e9b5 |
@@ -1,8 +1,12 @@
|
||||
name: Backend Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'backend/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'backend/**'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -14,9 +18,19 @@ jobs:
|
||||
version: 9
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
node-version: 20
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> "${GITEA_OUTPUT:-$GITHUB_OUTPUT}"
|
||||
- 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: Install dependencies
|
||||
run: pnpm install
|
||||
run: pnpm install --frozen-lockfile --prefer-offline
|
||||
- name: Run Backend Tests
|
||||
run: pnpm -F @memegoat/backend test
|
||||
|
||||
@@ -1,61 +1,63 @@
|
||||
name: Deploy to Production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- prod
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
validate:
|
||||
name: Validate Build & Lint
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
component: [backend, frontend, documentation]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITEA_ENV
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> "${GITEA_OUTPUT:-$GITHUB_OUTPUT}"
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
run: pnpm install --frozen-lockfile --prefer-offline
|
||||
|
||||
- name: Lint - Backend
|
||||
run: pnpm run lint:back
|
||||
- name: Lint ${{ matrix.component }}
|
||||
run: pnpm -F @memegoat/${{ matrix.component }} lint
|
||||
|
||||
- name: Build - Backend
|
||||
run: pnpm run build:back
|
||||
- name: Build ${{ matrix.component }}
|
||||
run: pnpm -F @memegoat/${{ matrix.component }} build
|
||||
env:
|
||||
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
|
||||
|
||||
- name: Lint - Frontend
|
||||
run: pnpm run lint:front
|
||||
|
||||
- name: Build - Frontend
|
||||
run: pnpm run build:front
|
||||
|
||||
- name: Lint - Documentation
|
||||
run: pnpm run lint:docs
|
||||
|
||||
- name: Build - Documentation
|
||||
run: pnpm run build:docs
|
||||
deploy:
|
||||
name: Deploy to Production
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy with Docker Compose
|
||||
run: |
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
- 'backend/**'
|
||||
- 'documentation/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
- 'backend/**'
|
||||
- 'documentation/**'
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
component: [backend, frontend, documentation]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
@@ -16,16 +25,19 @@ jobs:
|
||||
version: 9
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
node-version: 20
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> "${GITEA_OUTPUT:-$GITHUB_OUTPUT}"
|
||||
- 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: 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
|
||||
run: pnpm install --frozen-lockfile --prefer-offline
|
||||
- name: Lint ${{ matrix.component }}
|
||||
run: pnpm -F @memegoat/${{ matrix.component }} lint
|
||||
|
||||
225
.output.txt
Normal file
225
.output.txt
Normal file
@@ -0,0 +1,225 @@
|
||||
{
|
||||
"name": "@memegoat/source",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"build": "pnpm run build:back && pnpm run build:front && pnpm run build:docs",
|
||||
"build:front": "pnpm run -F @memegoat/frontend build",
|
||||
"build:back": "pnpm run -F @memegoat/backend build",
|
||||
"build:docs": "pnpm run -F @memegoat/documentation build",
|
||||
"lint": "pnpm run lint:back && pnpm run lint:front && pnpm run lint:docs",
|
||||
"lint:back": "pnpm run -F @memegoat/backend lint",
|
||||
"lint:front": "pnpm run -F @memegoat/frontend lint",
|
||||
"lint:docs": "pnpm run -F @memegoat/documentation lint",
|
||||
"test": "pnpm run test:back && pnpm run test:front",
|
||||
"test:back": "pnpm run -F @memegoat/backend test",
|
||||
"test:front": "pnpm run -F @memegoat/frontend test",
|
||||
"format": "pnpm run format:back && pnpm run format:front && pnpm run format:docs",
|
||||
"format:back": "pnpm run -F @memegoat/backend format",
|
||||
"format:front": "pnpm run -F @memegoat/frontend format",
|
||||
"format:docs": "pnpm run -F @memegoat/documentation format",
|
||||
"upgrade": "pnpm dlx taze minor"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": {
|
||||
"name": "Mathis HERRIOT",
|
||||
"email": "mherriot.pro@proton.me",
|
||||
"role": "Author"
|
||||
},
|
||||
"license": "AGPL-3.0-only",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.11"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "@memegoat/backend",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"files": [
|
||||
"dist",
|
||||
".migrations",
|
||||
"drizzle.config.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"lint": "biome check",
|
||||
"lint:write": "biome check --write",
|
||||
"format": "biome format --write",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs-modules/mailer": "^2.0.2",
|
||||
"@nestjs/cache-manager": "^3.1.0",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/mapped-types": "^2.1.0",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/schedule": "^6.1.0",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@noble/post-quantum": "^0.5.4",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@sentry/nestjs": "^10.32.1",
|
||||
"@sentry/profiling-node": "^10.32.1",
|
||||
"cache-manager": "^7.2.7",
|
||||
"cache-manager-redis-yet": "^5.1.5",
|
||||
"clamscan": "^2.4.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"helmet": "^8.1.0",
|
||||
"iron-session": "^8.0.4",
|
||||
"jose": "^6.1.3",
|
||||
"minio": "^8.0.6",
|
||||
"nodemailer": "^7.0.12",
|
||||
"otplib": "^12.0.1",
|
||||
"pg": "^8.16.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"sharp": "^0.34.5",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^4.3.5",
|
||||
"drizzle-kit": "^0.31.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"globals": "^16.0.0",
|
||||
"jest": "^30.0.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/fluent-ffmpeg": "^2.1.28",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/nodemailer": "^7.0.4",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/sharp": "^0.32.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"drizzle-kit": "^0.31.8"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node",
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!(.pnpm/)?(jose|@noble|uuid)/)"
|
||||
],
|
||||
"transform": {
|
||||
"^.+\\.(t|j)sx?$": "ts-jest"
|
||||
},
|
||||
"moduleNameMapper": {
|
||||
"^@noble/post-quantum/(.*)$": "<rootDir>/../node_modules/@noble/post-quantum/$1",
|
||||
"^@noble/hashes/(.*)$": "<rootDir>/../node_modules/@noble/hashes/$1"
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "@memegoat/frontend",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "biome check",
|
||||
"format": "biome format --write"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "16.1.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.3",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "19.2.3",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-resizable-panels": "^4.4.1",
|
||||
"recharts": "2.15.4",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.11",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -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 PATH="$PNPM_HOME:$PATH"
|
||||
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 frontend/package.json ./frontend/
|
||||
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 . .
|
||||
# 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 deploy --filter=@memegoat/backend --prod --legacy /app
|
||||
RUN cp -r backend/dist /app/dist
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@memegoat/backend",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -107,7 +107,7 @@
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node",
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!(jose|@noble)/)"
|
||||
"node_modules/(?!(.pnpm/)?(jose|@noble|uuid)/)"
|
||||
],
|
||||
"transform": {
|
||||
"^.+\\.(t|j)sx?$": "ts-jest"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { Roles } from "../auth/decorators/roles.decorator";
|
||||
import { AdminService } from "./admin.service";
|
||||
|
||||
@Controller("admin")
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { UsersModule } from "../users/users.module";
|
||||
import { ContentsModule } from "../contents/contents.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";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { UsersRepository } from "../users/repositories/users.repository";
|
||||
import { ContentsRepository } from "../contents/repositories/contents.repository";
|
||||
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 {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
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 { ApiKeysService } from "./api-keys.service";
|
||||
import { ApiKeysRepository } from "./repositories/api-keys.repository";
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule, forwardRef(() => AuthModule), CryptoModule],
|
||||
imports: [forwardRef(() => AuthModule)],
|
||||
controllers: [ApiKeysController],
|
||||
providers: [ApiKeysService, ApiKeysRepository],
|
||||
exports: [ApiKeysService, ApiKeysRepository],
|
||||
|
||||
@@ -4,14 +4,15 @@ import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { ThrottlerModule } from "@nestjs/throttler";
|
||||
import { redisStore } from "cache-manager-redis-yet";
|
||||
import { ApiKeysModule } from "./api-keys/api-keys.module";
|
||||
import { AdminModule } from "./admin/admin.module";
|
||||
import { ApiKeysModule } from "./api-keys/api-keys.module";
|
||||
import { AppController } from "./app.controller";
|
||||
import { AppService } from "./app.service";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { CategoriesModule } from "./categories/categories.module";
|
||||
import { CommonModule } from "./common/common.module";
|
||||
import { CrawlerDetectionMiddleware } from "./common/middlewares/crawler-detection.middleware";
|
||||
import { HTTPLoggerMiddleware } from "./common/middlewares/http-logger.middleware";
|
||||
import { validateEnv } from "./config/env.schema";
|
||||
import { ContentsModule } from "./contents/contents.module";
|
||||
import { CryptoModule } from "./crypto/crypto.module";
|
||||
@@ -76,6 +77,8 @@ import { UsersModule } from "./users/users.module";
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(CrawlerDetectionMiddleware).forRoutes("*");
|
||||
consumer
|
||||
.apply(HTTPLoggerMiddleware, CrawlerDetectionMiddleware)
|
||||
.forRoutes("*");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
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 { UsersModule } from "../users/users.module";
|
||||
import { AuthController } from "./auth.controller";
|
||||
@@ -12,12 +10,7 @@ import { RbacService } from "./rbac.service";
|
||||
import { RbacRepository } from "./repositories/rbac.repository";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
forwardRef(() => UsersModule),
|
||||
CryptoModule,
|
||||
SessionsModule,
|
||||
DatabaseModule,
|
||||
],
|
||||
imports: [forwardRef(() => UsersModule), SessionsModule],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
jest.mock("uuid", () => ({
|
||||
v4: jest.fn(() => "mocked-uuid"),
|
||||
}));
|
||||
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
|
||||
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
|
||||
|
||||
@@ -110,6 +110,7 @@ export class AuthService {
|
||||
const user = await this.usersService.findByEmailHash(emailHash);
|
||||
|
||||
if (!user) {
|
||||
this.logger.warn(`Login failed: user not found for email hash`);
|
||||
throw new UnauthorizedException("Invalid credentials");
|
||||
}
|
||||
|
||||
@@ -119,10 +120,12 @@ export class AuthService {
|
||||
);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
this.logger.warn(`Login failed: invalid password for user ${user.uuid}`);
|
||||
throw new UnauthorizedException("Invalid credentials");
|
||||
}
|
||||
|
||||
if (user.isTwoFactorEnabled) {
|
||||
this.logger.log(`2FA required for user ${user.uuid}`);
|
||||
return {
|
||||
message: "2FA required",
|
||||
requires2FA: true,
|
||||
@@ -141,6 +144,7 @@ export class AuthService {
|
||||
ip,
|
||||
);
|
||||
|
||||
this.logger.log(`User ${user.uuid} logged in successfully`);
|
||||
return {
|
||||
message: "User logged in successfully",
|
||||
access_token: accessToken,
|
||||
@@ -165,6 +169,9 @@ export class AuthService {
|
||||
|
||||
const isValid = authenticator.verify({ token, secret });
|
||||
if (!isValid) {
|
||||
this.logger.warn(
|
||||
`2FA verification failed for user ${userId}: invalid token`,
|
||||
);
|
||||
throw new UnauthorizedException("Invalid 2FA token");
|
||||
}
|
||||
|
||||
@@ -179,6 +186,7 @@ export class AuthService {
|
||||
ip,
|
||||
);
|
||||
|
||||
this.logger.log(`User ${userId} logged in successfully via 2FA`);
|
||||
return {
|
||||
message: "User logged in successfully (2FA)",
|
||||
access_token: accessToken,
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
} from "@nestjs/common";
|
||||
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { getIronSession } from "iron-session";
|
||||
import { JwtService } from "../../crypto/services/jwt.service";
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { CryptoModule } from "../crypto/crypto.module";
|
||||
import { DatabaseModule } from "../database/database.module";
|
||||
import { CategoriesController } from "./categories.controller";
|
||||
import { CategoriesService } from "./categories.service";
|
||||
import { CategoriesRepository } from "./repositories/categories.repository";
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule, AuthModule, CryptoModule],
|
||||
imports: [AuthModule],
|
||||
controllers: [CategoriesController],
|
||||
providers: [CategoriesService, CategoriesRepository],
|
||||
exports: [CategoriesService, CategoriesRepository],
|
||||
|
||||
@@ -9,6 +9,14 @@ import {
|
||||
import * as Sentry from "@sentry/nestjs";
|
||||
import { Request, Response } from "express";
|
||||
|
||||
interface RequestWithUser extends Request {
|
||||
user?: {
|
||||
sub?: string;
|
||||
username?: string;
|
||||
id?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@Catch()
|
||||
export class AllExceptionsFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger("ExceptionFilter");
|
||||
@@ -16,7 +24,7 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const request = ctx.getRequest<RequestWithUser>();
|
||||
|
||||
const status =
|
||||
exception instanceof HttpException
|
||||
@@ -28,6 +36,9 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
||||
? exception.getResponse()
|
||||
: "Internal server error";
|
||||
|
||||
const userId = request.user?.sub || request.user?.id;
|
||||
const userPart = userId ? `[User: ${userId}] ` : "";
|
||||
|
||||
const errorResponse = {
|
||||
statusCode: status,
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -42,12 +53,12 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
||||
if (status === HttpStatus.INTERNAL_SERVER_ERROR) {
|
||||
Sentry.captureException(exception);
|
||||
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 : "",
|
||||
);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`${request.method} ${request.url} - Status: ${status} - Message: ${JSON.stringify(message)}`,
|
||||
`${userPart}${request.method} ${request.url} - Status: ${status} - Message: ${JSON.stringify(message)}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,4 +33,6 @@ export interface IStorageService {
|
||||
sourceBucketName?: string,
|
||||
destinationBucketName?: string,
|
||||
): Promise<string>;
|
||||
|
||||
getPublicUrl(storageKey: string): string;
|
||||
}
|
||||
|
||||
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(),
|
||||
|
||||
DOMAIN_NAME: z.string(),
|
||||
API_URL: z.string().url().optional(),
|
||||
|
||||
// Sentry
|
||||
SENTRY_DSN: z.string().optional(),
|
||||
|
||||
@@ -19,11 +19,11 @@ import {
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
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 { OptionalAuthGuard } from "../auth/guards/optional-auth.guard";
|
||||
import { RolesGuard } from "../auth/guards/roles.guard";
|
||||
import { Roles } from "../auth/decorators/roles.decorator";
|
||||
import type { AuthenticatedRequest } from "../common/interfaces/request.interface";
|
||||
import { ContentsService } from "./contents.service";
|
||||
import { CreateContentDto } from "./dto/create-content.dto";
|
||||
@@ -144,10 +144,7 @@ export class ContentsController {
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const content = await this.contentsService.findOne(
|
||||
idOrSlug,
|
||||
req.user?.sub,
|
||||
);
|
||||
const content = await this.contentsService.findOne(idOrSlug, req.user?.sub);
|
||||
if (!content) {
|
||||
throw new NotFoundException("Contenu non trouvé");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
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 { S3Module } from "../s3/s3.module";
|
||||
import { ContentsController } from "./contents.controller";
|
||||
@@ -9,7 +7,7 @@ import { ContentsService } from "./contents.service";
|
||||
import { ContentsRepository } from "./repositories/contents.repository";
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule, S3Module, AuthModule, CryptoModule, MediaModule],
|
||||
imports: [S3Module, AuthModule, MediaModule],
|
||||
controllers: [ContentsController],
|
||||
providers: [ContentsService, ContentsRepository],
|
||||
exports: [ContentsRepository],
|
||||
|
||||
@@ -30,6 +30,7 @@ describe("ContentsService", () => {
|
||||
const mockS3Service = {
|
||||
getUploadUrl: jest.fn(),
|
||||
uploadFile: jest.fn(),
|
||||
getPublicUrl: jest.fn(),
|
||||
};
|
||||
|
||||
const mockMediaService = {
|
||||
|
||||
@@ -100,6 +100,7 @@ export class ContentsService {
|
||||
// 3. Upload vers S3
|
||||
const key = `contents/${userId}/${Date.now()}-${uuidv4()}.${processed.extension}`;
|
||||
await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType);
|
||||
this.logger.log(`File uploaded successfully to S3: ${key}`);
|
||||
|
||||
// 4. Création en base de données
|
||||
return await this.create(userId, {
|
||||
@@ -128,11 +129,11 @@ export class ContentsService {
|
||||
|
||||
const processedData = data.map((content) => ({
|
||||
...content,
|
||||
url: this.getFileUrl(content.storageKey),
|
||||
url: this.s3Service.getPublicUrl(content.storageKey),
|
||||
author: {
|
||||
...content.author,
|
||||
avatarUrl: content.author?.avatarUrl
|
||||
? this.getFileUrl(content.author.avatarUrl)
|
||||
? this.s3Service.getPublicUrl(content.author.avatarUrl)
|
||||
: null,
|
||||
},
|
||||
}));
|
||||
@@ -189,18 +190,18 @@ export class ContentsService {
|
||||
|
||||
return {
|
||||
...content,
|
||||
url: this.getFileUrl(content.storageKey),
|
||||
url: this.s3Service.getPublicUrl(content.storageKey),
|
||||
author: {
|
||||
...content.author,
|
||||
avatarUrl: content.author?.avatarUrl
|
||||
? this.getFileUrl(content.author.avatarUrl)
|
||||
? this.s3Service.getPublicUrl(content.author.avatarUrl)
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
generateBotHtml(content: { title: string; storageKey: string }): string {
|
||||
const imageUrl = this.getFileUrl(content.storageKey);
|
||||
const imageUrl = this.s3Service.getPublicUrl(content.storageKey);
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -221,19 +222,6 @@ export class ContentsService {
|
||||
</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 {
|
||||
return text
|
||||
.toLowerCase()
|
||||
|
||||
@@ -135,9 +135,10 @@ export class ContentsRepository {
|
||||
fileSize: contents.fileSize,
|
||||
views: contents.views,
|
||||
usageCount: contents.usageCount,
|
||||
favoritesCount: sql<number>`(SELECT count(*) FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id})`.mapWith(
|
||||
Number,
|
||||
),
|
||||
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`,
|
||||
@@ -235,9 +236,10 @@ export class ContentsRepository {
|
||||
fileSize: contents.fileSize,
|
||||
views: contents.views,
|
||||
usageCount: contents.usageCount,
|
||||
favoritesCount: sql<number>`(SELECT count(*) FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id})`.mapWith(
|
||||
Number,
|
||||
),
|
||||
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`,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { CryptoService } from "./crypto.service";
|
||||
import { EncryptionService } from "./services/encryption.service";
|
||||
import { HashingService } from "./services/hashing.service";
|
||||
import { JwtService } from "./services/jwt.service";
|
||||
import { PostQuantumService } from "./services/post-quantum.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
CryptoService,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { DatabaseService } from "./database.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [DatabaseService],
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { CryptoModule } from "../crypto/crypto.module";
|
||||
import { DatabaseModule } from "../database/database.module";
|
||||
import { FavoritesController } from "./favorites.controller";
|
||||
import { FavoritesService } from "./favorites.service";
|
||||
import { FavoritesRepository } from "./repositories/favorites.repository";
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule, AuthModule, CryptoModule],
|
||||
imports: [AuthModule],
|
||||
controllers: [FavoritesController],
|
||||
providers: [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 { S3Module } from "../s3/s3.module";
|
||||
import { MediaController } from "./media.controller";
|
||||
import { MediaService } from "./media.service";
|
||||
import { ImageProcessorStrategy } from "./strategies/image-processor.strategy";
|
||||
import { VideoProcessorStrategy } from "./strategies/video-processor.strategy";
|
||||
|
||||
@Module({
|
||||
imports: [S3Module],
|
||||
controllers: [MediaController],
|
||||
providers: [MediaService, ImageProcessorStrategy, VideoProcessorStrategy],
|
||||
exports: [MediaService],
|
||||
})
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { CryptoModule } from "../crypto/crypto.module";
|
||||
import { DatabaseModule } from "../database/database.module";
|
||||
import { ReportsController } from "./reports.controller";
|
||||
import { ReportsService } from "./reports.service";
|
||||
import { ReportsRepository } from "./repositories/reports.repository";
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule, forwardRef(() => AuthModule), CryptoModule],
|
||||
imports: [forwardRef(() => AuthModule)],
|
||||
controllers: [ReportsController],
|
||||
providers: [ReportsService, ReportsRepository],
|
||||
exports: [ReportsRepository, ReportsService],
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("ReportsService", () => {
|
||||
describe("create", () => {
|
||||
it("should create a report", async () => {
|
||||
const reporterId = "u1";
|
||||
const data = { contentId: "c1", reason: "spam" };
|
||||
const data = { contentId: "c1", reason: "spam" } as const;
|
||||
mockReportsRepository.create.mockResolvedValue({
|
||||
id: "r1",
|
||||
...data,
|
||||
|
||||
@@ -7,7 +7,7 @@ jest.mock("minio");
|
||||
|
||||
describe("S3Service", () => {
|
||||
let service: S3Service;
|
||||
let _configService: ConfigService;
|
||||
let configService: ConfigService;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Fine for testing purposes
|
||||
let minioClient: any;
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("S3Service", () => {
|
||||
}).compile();
|
||||
|
||||
service = module.get<S3Service>(S3Service);
|
||||
_configService = module.get<ConfigService>(ConfigService);
|
||||
configService = module.get<ConfigService>(ConfigService);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
@@ -185,35 +185,39 @@ describe("S3Service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveFile", () => {
|
||||
it("should move file within default bucket", async () => {
|
||||
const source = "source.txt";
|
||||
const dest = "dest.txt";
|
||||
await service.moveFile(source, dest);
|
||||
|
||||
expect(minioClient.copyObject).toHaveBeenCalledWith(
|
||||
"memegoat",
|
||||
dest,
|
||||
"/memegoat/source.txt",
|
||||
expect.any(Minio.CopyConditions),
|
||||
);
|
||||
expect(minioClient.removeObject).toHaveBeenCalledWith("memegoat", source);
|
||||
describe("getPublicUrl", () => {
|
||||
it("should use API_URL if provided", () => {
|
||||
(configService.get as jest.Mock).mockImplementation((key: string) => {
|
||||
if (key === "API_URL") return "https://api.test.com";
|
||||
return null;
|
||||
});
|
||||
const url = service.getPublicUrl("test.webp");
|
||||
expect(url).toBe("https://api.test.com/media/test.webp");
|
||||
});
|
||||
|
||||
it("should move file between different buckets", async () => {
|
||||
const source = "source.txt";
|
||||
const dest = "dest.txt";
|
||||
const sBucket = "source-bucket";
|
||||
const dBucket = "dest-bucket";
|
||||
await service.moveFile(source, dest, sBucket, dBucket);
|
||||
|
||||
expect(minioClient.copyObject).toHaveBeenCalledWith(
|
||||
dBucket,
|
||||
dest,
|
||||
`/${sBucket}/${source}`,
|
||||
expect.any(Minio.CopyConditions),
|
||||
it("should use DOMAIN_NAME and PORT for localhost", () => {
|
||||
(configService.get as jest.Mock).mockImplementation(
|
||||
(key: string, def: unknown) => {
|
||||
if (key === "API_URL") return null;
|
||||
if (key === "DOMAIN_NAME") return "localhost";
|
||||
if (key === "PORT") return 3000;
|
||||
return def;
|
||||
},
|
||||
);
|
||||
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,
|
||||
"Content-Type": mimeType,
|
||||
});
|
||||
this.logger.log(`File uploaded successfully: ${fileName} to ${bucketName}`);
|
||||
return fileName;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error uploading file to ${bucketName}: ${error.message}`);
|
||||
@@ -113,6 +114,7 @@ export class S3Service implements OnModuleInit, IStorageService {
|
||||
async deleteFile(fileName: string, bucketName: string = this.bucketName) {
|
||||
try {
|
||||
await this.minioClient.removeObject(bucketName, fileName);
|
||||
this.logger.log(`File deleted successfully: ${fileName} from ${bucketName}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Error deleting file from ${bucketName}: ${error.message}`,
|
||||
@@ -155,4 +157,22 @@ export class S3Service implements OnModuleInit, IStorageService {
|
||||
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 { CryptoModule } from "../crypto/crypto.module";
|
||||
import { DatabaseModule } from "../database/database.module";
|
||||
import { SessionsRepository } from "./repositories/sessions.repository";
|
||||
import { SessionsService } from "./sessions.service";
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule, CryptoModule],
|
||||
providers: [SessionsService, SessionsRepository],
|
||||
exports: [SessionsService, SessionsRepository],
|
||||
})
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
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 { TagsController } from "./tags.controller";
|
||||
import { TagsService } from "./tags.service";
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule, AuthModule, CryptoModule],
|
||||
imports: [AuthModule],
|
||||
controllers: [TagsController],
|
||||
providers: [TagsService, TagsRepository],
|
||||
exports: [TagsService, TagsRepository],
|
||||
|
||||
@@ -68,6 +68,7 @@ export class UsersRepository {
|
||||
.select({
|
||||
uuid: users.uuid,
|
||||
username: users.username,
|
||||
email: users.email,
|
||||
displayName: users.displayName,
|
||||
avatarUrl: users.avatarUrl,
|
||||
status: users.status,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
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 { S3Module } from "../s3/s3.module";
|
||||
import { UsersRepository } from "./repositories/users.repository";
|
||||
@@ -9,13 +7,7 @@ import { UsersController } from "./users.controller";
|
||||
import { UsersService } from "./users.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
DatabaseModule,
|
||||
CryptoModule,
|
||||
forwardRef(() => AuthModule),
|
||||
MediaModule,
|
||||
S3Module,
|
||||
],
|
||||
imports: [forwardRef(() => AuthModule), MediaModule, S3Module],
|
||||
controllers: [UsersController],
|
||||
providers: [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", () => ({
|
||||
ml_kem768: {
|
||||
keygen: jest.fn(),
|
||||
@@ -12,7 +16,11 @@ jest.mock("jose", () => ({
|
||||
}));
|
||||
|
||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
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 { UsersService } from "./users.service";
|
||||
|
||||
@@ -39,6 +47,24 @@ describe("UsersService", () => {
|
||||
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 () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
@@ -47,6 +73,10 @@ describe("UsersService", () => {
|
||||
UsersService,
|
||||
{ provide: UsersRepository, useValue: mockUsersRepository },
|
||||
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
|
||||
{ provide: RbacService, useValue: mockRbacService },
|
||||
{ provide: MediaService, useValue: mockMediaService },
|
||||
{ provide: S3Service, useValue: mockS3Service },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -58,7 +58,9 @@ export class UsersService {
|
||||
|
||||
return {
|
||||
...user,
|
||||
avatarUrl: user.avatarUrl ? this.getFileUrl(user.avatarUrl) : null,
|
||||
avatarUrl: user.avatarUrl
|
||||
? this.s3Service.getPublicUrl(user.avatarUrl)
|
||||
: null,
|
||||
role: roles.includes("admin") ? "admin" : "user",
|
||||
roles,
|
||||
};
|
||||
@@ -72,7 +74,9 @@ export class UsersService {
|
||||
|
||||
const processedData = data.map((user) => ({
|
||||
...user,
|
||||
avatarUrl: user.avatarUrl ? this.getFileUrl(user.avatarUrl) : null,
|
||||
avatarUrl: user.avatarUrl
|
||||
? this.s3Service.getPublicUrl(user.avatarUrl)
|
||||
: null,
|
||||
}));
|
||||
|
||||
return { data: processedData, totalCount };
|
||||
@@ -84,7 +88,9 @@ export class UsersService {
|
||||
|
||||
return {
|
||||
...user,
|
||||
avatarUrl: user.avatarUrl ? this.getFileUrl(user.avatarUrl) : null,
|
||||
avatarUrl: user.avatarUrl
|
||||
? this.s3Service.getPublicUrl(user.avatarUrl)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -114,9 +120,7 @@ export class UsersService {
|
||||
}
|
||||
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
throw new BadRequestException(
|
||||
"Image trop volumineuse. Limite: 2 Mo.",
|
||||
);
|
||||
throw new BadRequestException("Image trop volumineuse. Limite: 2 Mo.");
|
||||
}
|
||||
|
||||
// 1. Scan Antivirus
|
||||
@@ -139,6 +143,7 @@ export class UsersService {
|
||||
// 3. Upload vers S3
|
||||
const key = `avatars/${uuid}/${Date.now()}-${uuidv4()}.${processed.extension}`;
|
||||
await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType);
|
||||
this.logger.log(`Avatar uploaded successfully to S3: ${key}`);
|
||||
|
||||
// 4. Mise à jour de la base de données
|
||||
const user = await this.update(uuid, { avatarUrl: key });
|
||||
@@ -193,17 +198,4 @@ export class UsersService {
|
||||
async remove(uuid: string) {
|
||||
return await this.usersRepository.softDeleteUserAndContents(uuid);
|
||||
}
|
||||
|
||||
private 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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,8 +101,8 @@ services:
|
||||
ENABLE_CORS: ${ENABLE_CORS:-true}
|
||||
CLAMAV_HOST: memegoat-clamav
|
||||
CLAMAV_PORT: 3310
|
||||
MAX_IMAGE_SIZE_KB: 512
|
||||
MAX_GIF_SIZE_KB: 1024
|
||||
MAX_IMAGE_SIZE_KB: 1024
|
||||
MAX_GIF_SIZE_KB: 4096
|
||||
|
||||
clamav:
|
||||
image: clamav/clamav:latest
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# syntax=docker.io/docker/dockerfile:1
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:22-alpine AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
@@ -11,11 +11,20 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY backend/package.json ./backend/
|
||||
COPY frontend/package.json ./frontend/
|
||||
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 . .
|
||||
# On réinstalle après COPY pour s'assurer que tous les scripts de cycle de vie et les liens sont corrects
|
||||
RUN pnpm install --no-frozen-lockfile
|
||||
RUN pnpm run --filter @memegoat/documentation build
|
||||
|
||||
# Deuxième passe avec cache pour les scripts/liens
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# Build avec cache Next.js
|
||||
RUN --mount=type=cache,id=next-docs-cache,target=/usr/src/app/documentation/.next/cache \
|
||||
pnpm run --filter @memegoat/documentation build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# syntax=docker.io/docker/dockerfile:1
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:22-alpine AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
@@ -11,11 +11,20 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY backend/package.json ./backend/
|
||||
COPY frontend/package.json ./frontend/
|
||||
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 . .
|
||||
# On réinstalle après COPY pour s'assurer que tous les scripts de cycle de vie et les liens sont corrects
|
||||
RUN pnpm install --no-frozen-lockfile
|
||||
RUN pnpm run --filter @memegoat/frontend build
|
||||
|
||||
# Deuxième passe avec cache pour les scripts/liens
|
||||
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
|
||||
WORKDIR /app
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@memegoat/frontend",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { ViewCounter } from "@/components/view-counter";
|
||||
import { ContentService } from "@/services/content.service";
|
||||
import type { Content } from "@/types/content";
|
||||
|
||||
@@ -45,6 +46,7 @@ export default function MemeModal({
|
||||
</div>
|
||||
) : content ? (
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden">
|
||||
<ViewCounter contentId={content.id} />
|
||||
<ContentCard content={content} />
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { CategoryService } from "@/services/category.service";
|
||||
import type { Category } from "@/types/content";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function AdminCategoriesPage() {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
@@ -20,14 +20,16 @@ export default function AdminCategoriesPage() {
|
||||
useEffect(() => {
|
||||
CategoryService.getAll()
|
||||
.then(setCategories)
|
||||
.catch(err => console.error(err))
|
||||
.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>
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
Catégories ({categories.length})
|
||||
</h2>
|
||||
</div>
|
||||
<div className="rounded-md border bg-card">
|
||||
<Table>
|
||||
@@ -41,10 +43,17 @@ export default function AdminCategoriesPage() {
|
||||
<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>
|
||||
<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 ? (
|
||||
@@ -56,7 +65,9 @@ export default function AdminCategoriesPage() {
|
||||
) : (
|
||||
categories.map((category) => (
|
||||
<TableRow key={category.id}>
|
||||
<TableCell className="font-medium whitespace-nowrap">{category.name}</TableCell>
|
||||
<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"}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
"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,
|
||||
@@ -11,12 +17,6 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { ContentService } from "@/services/content.service";
|
||||
import type { Content } from "@/types/content";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Eye, Download, Image as ImageIcon, Video, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function AdminContentsPage() {
|
||||
const [contents, setContents] = useState<Content[]>([]);
|
||||
@@ -27,9 +27,9 @@ export default function AdminContentsPage() {
|
||||
ContentService.getExplore({ limit: 20 })
|
||||
.then((res) => {
|
||||
setContents(res.data);
|
||||
setTotalCount(res.total);
|
||||
setTotalCount(res.totalCount);
|
||||
})
|
||||
.catch(err => console.error(err))
|
||||
.catch((err) => console.error(err))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
@@ -38,8 +38,8 @@ export default function AdminContentsPage() {
|
||||
|
||||
try {
|
||||
await ContentService.removeAdmin(id);
|
||||
setContents(contents.filter(c => c.id !== id));
|
||||
setTotalCount(prev => prev - 1);
|
||||
setContents(contents.filter((c) => c.id !== id));
|
||||
setTotalCount((prev) => prev - 1);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
@@ -48,7 +48,9 @@ export default function AdminContentsPage() {
|
||||
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>
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
Contenus ({totalCount})
|
||||
</h2>
|
||||
</div>
|
||||
<div className="rounded-md border bg-card">
|
||||
<Table>
|
||||
@@ -65,12 +67,23 @@ export default function AdminContentsPage() {
|
||||
<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>
|
||||
<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 ? (
|
||||
@@ -93,16 +106,18 @@ export default function AdminContentsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold">{content.title}</div>
|
||||
<div className="text-xs text-muted-foreground">{content.type} • {content.mimeType}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{content.type} • {content.mimeType}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{content.category.name}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@{content.author.username}
|
||||
<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">
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"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 { adminService, type AdminStats } from "@/services/admin.service";
|
||||
import { Users, FileText, LayoutGrid, AlertCircle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
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);
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
"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,
|
||||
@@ -11,12 +17,6 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { UserService } from "@/services/user.service";
|
||||
import type { User } from "@/types/user";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
@@ -29,19 +29,24 @@ export default function AdminUsersPage() {
|
||||
setUsers(res.data);
|
||||
setTotalCount(res.totalCount);
|
||||
})
|
||||
.catch(err => {
|
||||
.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;
|
||||
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);
|
||||
setUsers(users.filter((u) => u.uuid !== uuid));
|
||||
setTotalCount((prev) => prev - 1);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
@@ -50,7 +55,9 @@ export default function AdminUsersPage() {
|
||||
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>
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
Utilisateurs ({totalCount})
|
||||
</h2>
|
||||
</div>
|
||||
<div className="rounded-md border bg-card">
|
||||
<Table>
|
||||
@@ -67,12 +74,23 @@ export default function AdminUsersPage() {
|
||||
<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>
|
||||
<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 ? (
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { HelpCircle } from "lucide-react";
|
||||
|
||||
export default function HelpPage() {
|
||||
const faqs = [
|
||||
@@ -48,10 +48,8 @@ export default function HelpPage() {
|
||||
<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={index} value={`item-${index}`}>
|
||||
<AccordionTrigger className="text-left">
|
||||
{faq.question}
|
||||
</AccordionTrigger>
|
||||
<AccordionItem key={faq.question} value={`item-${index}`}>
|
||||
<AccordionTrigger className="text-left">{faq.question}</AccordionTrigger>
|
||||
<AccordionContent className="text-muted-foreground leading-relaxed">
|
||||
{faq.answer}
|
||||
</AccordionContent>
|
||||
@@ -65,7 +63,7 @@ export default function HelpPage() {
|
||||
<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.local</p>
|
||||
<p className="font-semibold text-primary">contact@memegoat.fr</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
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
|
||||
@@ -40,6 +41,7 @@ export default async function MemePage({
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto py-8 px-4">
|
||||
<ViewCounter contentId={content.id} />
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center text-sm mb-6 hover:text-primary transition-colors"
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 { toast } from "sonner";
|
||||
import { ContentList } from "@/components/content-list";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -20,7 +21,6 @@ import { useAuth } from "@/providers/auth-provider";
|
||||
import { ContentService } from "@/services/content.service";
|
||||
import { FavoriteService } from "@/services/favorite.service";
|
||||
import { UserService } from "@/services/user.service";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user, isAuthenticated, isLoading, logout, refreshUser } = useAuth();
|
||||
@@ -32,7 +32,9 @@ export default function ProfilePage() {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleFileChange = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
|
||||
@@ -9,7 +9,11 @@ export const metadata: Metadata = {
|
||||
|
||||
export default function RecentPage() {
|
||||
return (
|
||||
<React.Suspense fallback={<div className="p-8 text-center">Chargement des nouveautés...</div>}>
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<div className="p-8 text-center">Chargement des nouveautés...</div>
|
||||
}
|
||||
>
|
||||
<HomeContent defaultSort="recent" />
|
||||
</React.Suspense>
|
||||
);
|
||||
|
||||
@@ -24,8 +24,8 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useAuth } from "@/providers/auth-provider";
|
||||
import { UserService } from "@/services/user.service";
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function SettingsPage() {
|
||||
if (user) {
|
||||
form.reset({
|
||||
displayName: user.displayName || "",
|
||||
bio: (user as any).bio || "",
|
||||
bio: user.bio || "",
|
||||
});
|
||||
}
|
||||
}, [user, form]);
|
||||
@@ -107,7 +107,8 @@ export default function SettingsPage() {
|
||||
<CardHeader>
|
||||
<CardTitle>Informations personnelles</CardTitle>
|
||||
<CardDescription>
|
||||
Mettez à jour vos informations publiques. Ces données seront visibles par les autres utilisateurs.
|
||||
Mettez à jour vos informations publiques. Ces données seront visibles par
|
||||
les autres utilisateurs.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -117,7 +118,11 @@ export default function SettingsPage() {
|
||||
<FormItem>
|
||||
<FormLabel>Nom d'utilisateur</FormLabel>
|
||||
<FormControl>
|
||||
<Input value={user.username} disabled className="bg-zinc-50 dark:bg-zinc-900" />
|
||||
<Input
|
||||
value={user.username}
|
||||
disabled
|
||||
className="bg-zinc-50 dark:bg-zinc-900"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Le nom d'utilisateur ne peut pas être modifié.
|
||||
|
||||
@@ -9,7 +9,9 @@ export const metadata: Metadata = {
|
||||
|
||||
export default function TrendsPage() {
|
||||
return (
|
||||
<React.Suspense fallback={<div className="p-8 text-center">Chargement des tendances...</div>}>
|
||||
<React.Suspense
|
||||
fallback={<div className="p-8 text-center">Chargement des tendances...</div>}
|
||||
>
|
||||
<HomeContent defaultSort="trend" />
|
||||
</React.Suspense>
|
||||
);
|
||||
|
||||
@@ -31,8 +31,7 @@ export const metadata: Metadata = {
|
||||
url: "https://memegoat.local",
|
||||
siteName: "MemeGoat",
|
||||
title: "MemeGoat | Partagez vos meilleurs mèmes",
|
||||
description:
|
||||
"La plateforme ultime pour les mèmes. Rejoignez le troupeau !",
|
||||
description: "La plateforme ultime pour les mèmes. Rejoignez le troupeau !",
|
||||
images: [
|
||||
{
|
||||
url: "/memegoat-og.png",
|
||||
|
||||
@@ -228,7 +228,7 @@ export function AppSidebar() {
|
||||
<span className="truncate font-semibold">
|
||||
{user.displayName || user.username}
|
||||
</span>
|
||||
<span className="truncate text-xs">{user.email}</span>
|
||||
<span className="truncate text-xs">{user.role}</span>
|
||||
</div>
|
||||
<ChevronRight className="ml-auto size-4 group-data-[collapsible=icon]:hidden" />
|
||||
</SidebarMenuButton>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
CardHeader,
|
||||
} from "@/components/ui/card";
|
||||
import { useAuth } from "@/providers/auth-provider";
|
||||
import { ContentService } from "@/services/content.service";
|
||||
import { FavoriteService } from "@/services/favorite.service";
|
||||
import type { Content } from "@/types/content";
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { CategoryService } from "@/services/category.service";
|
||||
import type { Category } from "@/types/content";
|
||||
import { TagService } from "@/services/tag.service";
|
||||
import type { Category, Tag } from "@/types/content";
|
||||
|
||||
export function MobileFilters() {
|
||||
const router = useRouter();
|
||||
@@ -24,12 +25,16 @@ export function MobileFilters() {
|
||||
const pathname = usePathname();
|
||||
|
||||
const [categories, setCategories] = React.useState<Category[]>([]);
|
||||
const [popularTags, setPopularTags] = React.useState<Tag[]>([]);
|
||||
const [query, setQuery] = React.useState(searchParams.get("query") || "");
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
CategoryService.getAll().then(setCategories).catch(console.error);
|
||||
TagService.getAll({ limit: 10, sort: "popular" })
|
||||
.then(setPopularTags)
|
||||
.catch(console.error);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
@@ -127,19 +132,25 @@ export function MobileFilters() {
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3">Tags populaires</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{["funny", "coding", "cat", "dog", "work", "relatable", "gaming"].map(
|
||||
(tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant={searchParams.get("tag") === tag ? "default" : "outline"}
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
updateSearch("tag", searchParams.get("tag") === tag ? null : tag)
|
||||
}
|
||||
>
|
||||
#{tag}
|
||||
</Badge>
|
||||
),
|
||||
{popularTags.map((tag) => (
|
||||
<Badge
|
||||
key={tag.id}
|
||||
variant={
|
||||
searchParams.get("tag") === tag.name ? "default" : "outline"
|
||||
}
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
updateSearch(
|
||||
"tag",
|
||||
searchParams.get("tag") === tag.name ? null : tag.name,
|
||||
)
|
||||
}
|
||||
>
|
||||
#{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
{popularTags.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">Aucun tag trouvé.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,8 @@ import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { CategoryService } from "@/services/category.service";
|
||||
import type { Category } from "@/types/content";
|
||||
import { TagService } from "@/services/tag.service";
|
||||
import type { Category, Tag } from "@/types/content";
|
||||
|
||||
export function SearchSidebar() {
|
||||
const router = useRouter();
|
||||
@@ -16,10 +17,14 @@ export function SearchSidebar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
const [categories, setCategories] = React.useState<Category[]>([]);
|
||||
const [popularTags, setPopularTags] = React.useState<Tag[]>([]);
|
||||
const [query, setQuery] = React.useState(searchParams.get("query") || "");
|
||||
|
||||
React.useEffect(() => {
|
||||
CategoryService.getAll().then(setCategories).catch(console.error);
|
||||
TagService.getAll({ limit: 10, sort: "popular" })
|
||||
.then(setPopularTags)
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
const updateSearch = React.useCallback(
|
||||
@@ -116,19 +121,23 @@ export function SearchSidebar() {
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3">Tags populaires</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{["funny", "coding", "cat", "dog", "work", "relatable", "gaming"].map(
|
||||
(tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant={searchParams.get("tag") === tag ? "default" : "outline"}
|
||||
className="cursor-pointer hover:bg-secondary"
|
||||
onClick={() =>
|
||||
updateSearch("tag", searchParams.get("tag") === tag ? null : tag)
|
||||
}
|
||||
>
|
||||
#{tag}
|
||||
</Badge>
|
||||
),
|
||||
{popularTags.map((tag) => (
|
||||
<Badge
|
||||
key={tag.id}
|
||||
variant={searchParams.get("tag") === tag.name ? "default" : "outline"}
|
||||
className="cursor-pointer hover:bg-secondary"
|
||||
onClick={() =>
|
||||
updateSearch(
|
||||
"tag",
|
||||
searchParams.get("tag") === tag.name ? null : tag.name,
|
||||
)
|
||||
}
|
||||
>
|
||||
#{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
{popularTags.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">Aucun tag trouvé.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -53,8 +53,6 @@ function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
|
||||
@@ -26,6 +26,7 @@ function ButtonGroup({
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: standard pattern for button groups
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
|
||||
@@ -117,6 +117,7 @@ function Carousel({
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/useSemanticElements: standard pattern for carousels */}
|
||||
<div
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
@@ -156,6 +157,7 @@ function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: standard pattern for carousel items
|
||||
<div
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
|
||||
@@ -83,6 +83,7 @@ function Field({
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: standard pattern for field components
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
|
||||
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: standard pattern for input groups
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
@@ -62,6 +63,7 @@ function InputGroupAddon({
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: standard pattern for input groups
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
|
||||
@@ -68,7 +68,7 @@ function InputOTPSlot({
|
||||
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||
<div data-slot="input-otp-separator" aria-hidden="true" {...props}>
|
||||
<MinusIcon />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: standard pattern for item groups
|
||||
<div
|
||||
role="list"
|
||||
data-slot="item-group"
|
||||
|
||||
@@ -82,6 +82,7 @@ function SidebarProvider({
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
// biome-ignore lint/suspicious/noDocumentCookie: persistence of sidebar state
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
[setOpenProp, open],
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { LogIn, User as UserIcon } from "lucide-react";
|
||||
import { LogIn } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/providers/auth-provider";
|
||||
|
||||
23
frontend/src/components/view-counter.tsx
Normal file
23
frontend/src/components/view-counter.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { ContentService } from "@/services/content.service";
|
||||
|
||||
interface ViewCounterProps {
|
||||
contentId: string;
|
||||
}
|
||||
|
||||
export function ViewCounter({ contentId }: ViewCounterProps) {
|
||||
const hasIncremented = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasIncremented.current) {
|
||||
ContentService.incrementViews(contentId).catch((err) => {
|
||||
console.error("Failed to increment views:", err);
|
||||
});
|
||||
hasIncremented.current = true;
|
||||
}
|
||||
}, [contentId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -8,6 +8,23 @@ const api = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
// Interceptor for Server-Side Rendering to pass cookies
|
||||
api.interceptors.request.use(async (config) => {
|
||||
if (typeof window === "undefined") {
|
||||
try {
|
||||
const { cookies } = await import("next/headers");
|
||||
const cookieStore = await cookies();
|
||||
const cookieHeader = cookieStore.toString();
|
||||
if (cookieHeader) {
|
||||
config.headers.Cookie = cookieHeader;
|
||||
}
|
||||
} catch (_error) {
|
||||
// Fail silently if cookies() is not available (e.g. during build)
|
||||
}
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Système anti-spam rudimentaire pour les erreurs répétitives
|
||||
const errorCache = new Map<string, number>();
|
||||
const SPAM_THRESHOLD_MS = 2000; // 2 secondes de silence après une erreur sur le même endpoint
|
||||
@@ -19,14 +36,35 @@ api.interceptors.response.use(
|
||||
errorCache.delete(url);
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// Handle Token Refresh (401 Unauthorized)
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
!originalRequest._retry &&
|
||||
!originalRequest.url?.includes("/auth/refresh") &&
|
||||
!originalRequest.url?.includes("/auth/login")
|
||||
) {
|
||||
originalRequest._retry = true;
|
||||
try {
|
||||
await api.post("/auth/refresh");
|
||||
return api(originalRequest);
|
||||
} catch (refreshError) {
|
||||
// If refresh fails, we might want to redirect to login on the client
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
const url = error.config?.url || "unknown";
|
||||
const now = Date.now();
|
||||
const lastErrorTime = errorCache.get(url);
|
||||
|
||||
if (lastErrorTime && now - lastErrorTime < SPAM_THRESHOLD_MS) {
|
||||
// Ignorer l'erreur si elle se produit trop rapidement (déjà signalée)
|
||||
// On retourne une promesse qui ne se résout jamais ou on rejette avec une marque spéciale
|
||||
return new Promise(() => {});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { api } from "./api";
|
||||
import api from "@/lib/api";
|
||||
|
||||
export interface AdminStats {
|
||||
users: number;
|
||||
|
||||
@@ -14,9 +14,15 @@ export const FavoriteService = {
|
||||
limit: number;
|
||||
offset: number;
|
||||
}): Promise<PaginatedResponse<Content>> {
|
||||
const { data } = await api.get<PaginatedResponse<Content>>("/favorites", {
|
||||
params,
|
||||
});
|
||||
const { data } = await api.get<PaginatedResponse<Content>>(
|
||||
"/contents/explore",
|
||||
{
|
||||
params: {
|
||||
...params,
|
||||
favoritesOnly: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
16
frontend/src/services/tag.service.ts
Normal file
16
frontend/src/services/tag.service.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import api from "@/lib/api";
|
||||
import type { Tag } from "@/types/content";
|
||||
|
||||
export const TagService = {
|
||||
async getAll(
|
||||
params: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
query?: string;
|
||||
sort?: "popular" | "recent";
|
||||
} = {},
|
||||
): Promise<Tag[]> {
|
||||
const { data } = await api.get<Tag[]>("/tags", { params });
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@@ -13,7 +13,7 @@ export interface RegisterPayload {
|
||||
export interface AuthStatus {
|
||||
isAuthenticated: boolean;
|
||||
user: null | {
|
||||
id: string;
|
||||
uuid: string;
|
||||
username: string;
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
id?: string;
|
||||
uuid: string;
|
||||
username: string;
|
||||
email: string;
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
bio?: string;
|
||||
role: "user" | "admin";
|
||||
role?: "user" | "admin";
|
||||
status?: "active" | "verification" | "suspended" | "pending" | "deleted";
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
|
||||
30
frontend/todo.md
Normal file
30
frontend/todo.md
Normal file
@@ -0,0 +1,30 @@
|
||||
Réalisation du frontend :
|
||||
|
||||
# Exigences
|
||||
|
||||
- Responsive dans tout les formats tailwindcss
|
||||
- Accessibilité A11Y
|
||||
- Implémentation réel uniquement
|
||||
- Site en français
|
||||
- SEO parfaitement réalisé, robot.txt, sitemap.xml...
|
||||
- Utilisation des composants shadcn/ui
|
||||
- Réalisation d'une page d'erreur customisé
|
||||
- Utilisation des fonctionalités de NextJS suivantes :
|
||||
- Nested routes
|
||||
- Dynamic routes
|
||||
- Route groups
|
||||
- Private folders
|
||||
- Parralel and intercepted routes
|
||||
- Prefetching pages
|
||||
- Streaming pages
|
||||
- Server and Client Components
|
||||
- Cache Components
|
||||
- Image optimization
|
||||
- Incremental Static Regeneration
|
||||
- Custom hooks
|
||||
- Axios
|
||||
|
||||
Toute l'application est basé sur un système dashboard/sidebar intégrant le routing.
|
||||
La page principale est la page de navigation du contennu.
|
||||
En mode desktop nous retrouvons la sidebar à gauche, le contennu en scroll infini au milieu et les paramètres de recherche sur la droite.
|
||||
En mode mobile la sidebar est replié, les paramètres de recherche sont représenté comme une icône de filtrage flotante en haut à droite
|
||||
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"name": "@memegoat/source",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.0",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"version:get": "cmake -P version.cmake GET",
|
||||
"version:set": "cmake -P version.cmake SET",
|
||||
"build": "pnpm run build:back && pnpm run build:front && pnpm run build:docs",
|
||||
"build:front": "pnpm run -F @memegoat/frontend build",
|
||||
"build:back": "pnpm run -F @memegoat/backend build",
|
||||
|
||||
84
version.cmake
Normal file
84
version.cmake
Normal file
@@ -0,0 +1,84 @@
|
||||
# version.cmake - Script pour gérer la version SemVer de manière centralisée
|
||||
|
||||
# Usage: cmake -P version.cmake [GET|SET] [new_version]
|
||||
|
||||
set(PACKAGE_JSON_FILES
|
||||
"${CMAKE_CURRENT_LIST_DIR}/package.json"
|
||||
"${CMAKE_CURRENT_LIST_DIR}/backend/package.json"
|
||||
"${CMAKE_CURRENT_LIST_DIR}/frontend/package.json"
|
||||
)
|
||||
|
||||
# Fonction pour lire la version depuis le package.json racine
|
||||
function(get_current_version OUT_VAR)
|
||||
file(READ "${CMAKE_CURRENT_LIST_DIR}/package.json" ROOT_JSON)
|
||||
string(JSON CURRENT_VERSION GET "${ROOT_JSON}" "version")
|
||||
set(${OUT_VAR} ${CURRENT_VERSION} PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
# Fonction pour créer un tag git
|
||||
function(create_git_tag VERSION)
|
||||
find_package(Git QUIET)
|
||||
if(GIT_FOUND)
|
||||
execute_process(
|
||||
COMMAND ${GIT_EXECUTABLE} tag -a "v${VERSION}" -m "Release v${VERSION}"
|
||||
WORKING_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}"
|
||||
RESULT_VARIABLE TAG_RESULT
|
||||
)
|
||||
if(TAG_RESULT EQUAL 0)
|
||||
message(STATUS "Tag v${VERSION} créé avec succès")
|
||||
else()
|
||||
message(WARNING "Échec de la création du tag v${VERSION}. Il existe peut-être déjà.")
|
||||
endif()
|
||||
else()
|
||||
message(WARNING "Git non trouvé, impossible de créer le tag.")
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
# Fonction pour mettre à jour la version dans tous les fichiers package.json
|
||||
function(set_new_version NEW_VERSION)
|
||||
foreach(JSON_FILE ${PACKAGE_JSON_FILES})
|
||||
if(EXISTS "${JSON_FILE}")
|
||||
message(STATUS "Mise à jour de ${JSON_FILE} vers la version ${NEW_VERSION}")
|
||||
file(READ "${JSON_FILE}" CONTENT)
|
||||
# Utilisation de string(JSON ...) pour modifier la version si disponible (CMake >= 3.19)
|
||||
# Sinon on peut utiliser une regex simple pour package.json
|
||||
string(REGEX REPLACE "\"version\": \"[^\"]+\"" "\"version\": \"${NEW_VERSION}\"" NEW_CONTENT "${CONTENT}")
|
||||
file(WRITE "${JSON_FILE}" "${NEW_CONTENT}")
|
||||
else()
|
||||
message(WARNING "Fichier non trouvé: ${JSON_FILE}")
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
# Demander à l'utilisateur s'il veut tagger (ou le faire par défaut si spécifié)
|
||||
create_git_tag(${NEW_VERSION})
|
||||
endfunction()
|
||||
|
||||
# Logique principale
|
||||
set(ARG_OFFSET 0)
|
||||
while(ARG_OFFSET LESS CMAKE_ARGC)
|
||||
if("${CMAKE_ARGV${ARG_OFFSET}}" STREQUAL "-P")
|
||||
math(EXPR COMMAND_INDEX "${ARG_OFFSET} + 2")
|
||||
math(EXPR VERSION_INDEX "${ARG_OFFSET} + 3")
|
||||
break()
|
||||
endif()
|
||||
math(EXPR ARG_OFFSET "${ARG_OFFSET} + 1")
|
||||
endwhile()
|
||||
|
||||
if(NOT DEFINED COMMAND_INDEX OR COMMAND_INDEX GREATER_EQUAL CMAKE_ARGC)
|
||||
message(FATAL_ERROR "Usage: cmake -P version.cmake [GET|SET] [new_version]")
|
||||
endif()
|
||||
|
||||
set(COMMAND "${CMAKE_ARGV${COMMAND_INDEX}}")
|
||||
|
||||
if("${COMMAND}" STREQUAL "GET")
|
||||
get_current_version(VERSION)
|
||||
message("${VERSION}")
|
||||
elseif("${COMMAND}" STREQUAL "SET")
|
||||
if(VERSION_INDEX GREATER_EQUAL CMAKE_ARGC)
|
||||
message(FATAL_ERROR "Veuillez spécifier la nouvelle version: cmake -P version.cmake SET 0.0.0")
|
||||
endif()
|
||||
set(NEW_VERSION "${CMAKE_ARGV${VERSION_INDEX}}")
|
||||
set_new_version("${NEW_VERSION}")
|
||||
else()
|
||||
message(FATAL_ERROR "Commande inconnue: ${COMMAND}. Utilisez GET ou SET.")
|
||||
endif()
|
||||
Reference in New Issue
Block a user