Compare commits
23 Commits
bb640cd8f9
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9699127739
|
||
|
|
938d8bde7b
|
||
|
|
65c7096f46
|
||
|
|
57c00ad4d1
|
||
|
|
39618f7708
|
||
|
|
e84e4a5a9d
|
||
|
|
e74973a9d0
|
||
|
|
9233c1bf89
|
||
|
|
88c7f45a2c
|
||
|
|
9af72156f5
|
||
|
|
597a4d615e
|
||
|
|
2df45af305
|
||
|
|
863a4bf528
|
||
|
|
9a1cdb05a4
|
||
|
|
28caf92f9a
|
||
|
|
8b2728dc5a
|
||
|
|
3bbbbc307f
|
||
|
|
f080919563
|
||
|
|
edc1ab2438
|
||
|
|
01b66d6f2f
|
||
|
|
9a70dd02bb
|
||
|
|
e285a4e634
|
||
|
|
f247a01ac7
|
@@ -1,36 +0,0 @@
|
|||||||
name: Backend Tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- 'backend/**'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- 'backend/**'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: 9
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 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 --frozen-lockfile --prefer-offline
|
|
||||||
- name: Run Backend Tests
|
|
||||||
run: pnpm -F @memegoat/backend test
|
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
name: Deploy to Production
|
# Pipeline CI/CD pour Gitea Actions (Forgejo)
|
||||||
|
# Compatible avec GitHub Actions pour la portabilité
|
||||||
|
name: CI/CD Pipeline
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- prod
|
- '**'
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validate:
|
validate:
|
||||||
name: Validate Build & Lint
|
name: Valider ${{ matrix.component }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -16,23 +21,23 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Installer pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 9
|
version: 9
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Configurer Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
- name: Get pnpm store directory
|
- name: Obtenir le chemin du store pnpm
|
||||||
id: pnpm-cache
|
id: pnpm-cache
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
echo "STORE_PATH=$(pnpm store path --silent)" >> "${GITEA_OUTPUT:-$GITHUB_OUTPUT}"
|
echo "STORE_PATH=$(pnpm store path --silent)" >> "${GITEA_OUTPUT:-$GITHUB_OUTPUT}"
|
||||||
|
|
||||||
- name: Setup pnpm cache
|
- name: Configurer le cache pnpm
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||||
@@ -40,26 +45,43 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-pnpm-store-
|
${{ runner.os }}-pnpm-store-
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Installer les dépendances
|
||||||
run: pnpm install --frozen-lockfile --prefer-offline
|
run: pnpm install --frozen-lockfile --prefer-offline
|
||||||
|
|
||||||
- name: Lint ${{ matrix.component }}
|
- name: Lint ${{ matrix.component }}
|
||||||
run: pnpm -F @memegoat/${{ matrix.component }} lint
|
run: pnpm -F @memegoat/${{ matrix.component }} lint
|
||||||
|
|
||||||
|
- name: Tester ${{ matrix.component }}
|
||||||
|
if: matrix.component == 'backend' || matrix.component == 'frontend'
|
||||||
|
run: |
|
||||||
|
if pnpm -F @memegoat/${{ matrix.component }} run | grep -q "test"; then
|
||||||
|
pnpm -F @memegoat/${{ matrix.component }} test
|
||||||
|
else
|
||||||
|
echo "Pas de script de test trouvé pour ${{ matrix.component }}, passage."
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Build ${{ matrix.component }}
|
- name: Build ${{ matrix.component }}
|
||||||
run: pnpm -F @memegoat/${{ matrix.component }} build
|
run: pnpm -F @memegoat/${{ matrix.component }} build
|
||||||
env:
|
env:
|
||||||
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
|
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy to Production
|
name: Déploiement en Production
|
||||||
needs: validate
|
needs: validate
|
||||||
|
# Déclenchement uniquement sur push sur main ou tag de version
|
||||||
|
# Gitea supporte le contexte 'github' pour la compatibilité
|
||||||
|
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Deploy with Docker Compose
|
- name: Vérifier l'environnement Docker
|
||||||
|
run: |
|
||||||
|
docker version
|
||||||
|
docker compose version
|
||||||
|
|
||||||
|
- name: Déployer avec Docker Compose
|
||||||
run: |
|
run: |
|
||||||
docker compose -f docker-compose.prod.yml up -d --build
|
docker compose -f docker-compose.prod.yml up -d --build
|
||||||
env:
|
env:
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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
|
|
||||||
with:
|
|
||||||
version: 9
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
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 --frozen-lockfile --prefer-offline
|
|
||||||
- name: Lint ${{ matrix.component }}
|
|
||||||
run: pnpm -F @memegoat/${{ matrix.component }} lint
|
|
||||||
50
ROADMAP.md
Normal file
50
ROADMAP.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# 🐐 Memegoat - Roadmap & Critères de Production
|
||||||
|
|
||||||
|
Ce document définit les objectifs, les critères techniques et les fonctionnalités à atteindre pour que le projet Memegoat soit considéré comme prêt pour la production et conforme aux normes européennes (RGPD) et françaises.
|
||||||
|
|
||||||
|
## 1. 🏗️ Architecture & Infrastructure
|
||||||
|
- [x] Backend NestJS (TypeScript)
|
||||||
|
- [x] Base de données PostgreSQL avec Drizzle ORM
|
||||||
|
- [x] Stockage d'objets compatible S3 (MinIO)
|
||||||
|
- [x] Service d'Emailing (Nodemailer / SMTPS)
|
||||||
|
- [x] Documentation Technique & Référence API (`docs.memegoat.fr`)
|
||||||
|
- [x] Health Checks (`/health`)
|
||||||
|
- [x] Gestion des variables d'environnement (Validation avec Zod)
|
||||||
|
- [ ] CI/CD (Build, Lint, Test, Deploy)
|
||||||
|
|
||||||
|
## 2. 🔐 Sécurité & Authentification
|
||||||
|
- [x] Hachage des mots de passe (Argon2id)
|
||||||
|
- [x] Gestion des sessions robuste (JWT avec Refresh Token et Rotation)
|
||||||
|
- [x] RBAC (Role Based Access Control) fonctionnel
|
||||||
|
- [x] Système de Clés API (Hachées en base)
|
||||||
|
- [x] Double Authentification (2FA / TOTP)
|
||||||
|
- [x] Limitation de débit (Rate Limiting / Throttler)
|
||||||
|
- [x] Validation stricte des entrées (DTOs + ValidationPipe)
|
||||||
|
- [x] Protection contre les vulnérabilités OWASP (Helmet, CORS)
|
||||||
|
|
||||||
|
## 3. ⚖️ Conformité RGPD (EU & France)
|
||||||
|
- [x] Chiffrement natif des données personnelles (PII) via PGP (pgcrypto)
|
||||||
|
- [x] Hachage aveugle (Blind Indexing) pour l'email (recherche/unicité)
|
||||||
|
- [x] Journalisation d'audit complète (Audit Logs) pour les actions sensibles
|
||||||
|
- [x] Gestion du consentement (Versionnage CGU/Politique de Confidentialité)
|
||||||
|
- [x] Droit à l'effacement : Flux de suppression (Soft Delete -> Purge définitive)
|
||||||
|
- [x] Droit à la portabilité : Export des données utilisateur (JSON)
|
||||||
|
- [x] Purge automatique des données obsolètes (Signalements, Sessions expirées)
|
||||||
|
- [x] Anonymisation des adresses IP (Hachage) dans les logs
|
||||||
|
|
||||||
|
## 4. 🖼️ Fonctionnalités Coeur (Media & Galerie)
|
||||||
|
- [x] Exploration (Trends, Recent, Favoris)
|
||||||
|
- [x] Recherche par Tags, Catégories, Auteur, Texte
|
||||||
|
- [x] Gestion des Favoris
|
||||||
|
- [x] Upload sécurisé via S3 (URLs présignées)
|
||||||
|
- [x] Scan Antivirus (ClamAV) et traitement des médias (WebP, WebM, AVIF, AV1)
|
||||||
|
- [x] Limitation de la taille et des formats de fichiers entrants (Configurable)
|
||||||
|
- [x] Système de Signalement (Reports) et workflow de modération
|
||||||
|
- [ ] SEO : Metatags dynamiques et slugs sémantiques
|
||||||
|
|
||||||
|
## 5. ✅ Qualité & Robustesse
|
||||||
|
- [ ] Couverture de tests unitaires (Jest) > 80%
|
||||||
|
- [ ] Tests d'intégration et E2E
|
||||||
|
- [x] Gestion centralisée des erreurs (Filters NestJS)
|
||||||
|
- [ ] Monitoring et centralisation des logs (ex: Sentry, ELK/Loki)
|
||||||
|
- [ ] Performance : Cache (Redis) pour les tendances et recherches fréquentes
|
||||||
@@ -12,6 +12,7 @@ import { AuthModule } from "./auth/auth.module";
|
|||||||
import { CategoriesModule } from "./categories/categories.module";
|
import { CategoriesModule } from "./categories/categories.module";
|
||||||
import { CommonModule } from "./common/common.module";
|
import { CommonModule } from "./common/common.module";
|
||||||
import { CrawlerDetectionMiddleware } from "./common/middlewares/crawler-detection.middleware";
|
import { CrawlerDetectionMiddleware } from "./common/middlewares/crawler-detection.middleware";
|
||||||
|
import { HTTPLoggerMiddleware } from "./common/middlewares/http-logger.middleware";
|
||||||
import { validateEnv } from "./config/env.schema";
|
import { validateEnv } from "./config/env.schema";
|
||||||
import { ContentsModule } from "./contents/contents.module";
|
import { ContentsModule } from "./contents/contents.module";
|
||||||
import { CryptoModule } from "./crypto/crypto.module";
|
import { CryptoModule } from "./crypto/crypto.module";
|
||||||
@@ -76,6 +77,8 @@ import { UsersModule } from "./users/users.module";
|
|||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
configure(consumer: MiddlewareConsumer) {
|
configure(consumer: MiddlewareConsumer) {
|
||||||
consumer.apply(CrawlerDetectionMiddleware).forRoutes("*");
|
consumer
|
||||||
|
.apply(HTTPLoggerMiddleware, CrawlerDetectionMiddleware)
|
||||||
|
.forRoutes("*");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ export class AuthService {
|
|||||||
const user = await this.usersService.findByEmailHash(emailHash);
|
const user = await this.usersService.findByEmailHash(emailHash);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
this.logger.warn(`Login failed: user not found for email hash`);
|
||||||
throw new UnauthorizedException("Invalid credentials");
|
throw new UnauthorizedException("Invalid credentials");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,10 +120,12 @@ export class AuthService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!isPasswordValid) {
|
if (!isPasswordValid) {
|
||||||
|
this.logger.warn(`Login failed: invalid password for user ${user.uuid}`);
|
||||||
throw new UnauthorizedException("Invalid credentials");
|
throw new UnauthorizedException("Invalid credentials");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.isTwoFactorEnabled) {
|
if (user.isTwoFactorEnabled) {
|
||||||
|
this.logger.log(`2FA required for user ${user.uuid}`);
|
||||||
return {
|
return {
|
||||||
message: "2FA required",
|
message: "2FA required",
|
||||||
requires2FA: true,
|
requires2FA: true,
|
||||||
@@ -141,6 +144,7 @@ export class AuthService {
|
|||||||
ip,
|
ip,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.logger.log(`User ${user.uuid} logged in successfully`);
|
||||||
return {
|
return {
|
||||||
message: "User logged in successfully",
|
message: "User logged in successfully",
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
@@ -165,6 +169,9 @@ export class AuthService {
|
|||||||
|
|
||||||
const isValid = authenticator.verify({ token, secret });
|
const isValid = authenticator.verify({ token, secret });
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
|
this.logger.warn(
|
||||||
|
`2FA verification failed for user ${userId}: invalid token`,
|
||||||
|
);
|
||||||
throw new UnauthorizedException("Invalid 2FA token");
|
throw new UnauthorizedException("Invalid 2FA token");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +186,7 @@ export class AuthService {
|
|||||||
ip,
|
ip,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.logger.log(`User ${userId} logged in successfully via 2FA`);
|
||||||
return {
|
return {
|
||||||
message: "User logged in successfully (2FA)",
|
message: "User logged in successfully (2FA)",
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ import {
|
|||||||
import * as Sentry from "@sentry/nestjs";
|
import * as Sentry from "@sentry/nestjs";
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
|
|
||||||
|
interface RequestWithUser extends Request {
|
||||||
|
user?: {
|
||||||
|
sub?: string;
|
||||||
|
username?: string;
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Catch()
|
@Catch()
|
||||||
export class AllExceptionsFilter implements ExceptionFilter {
|
export class AllExceptionsFilter implements ExceptionFilter {
|
||||||
private readonly logger = new Logger("ExceptionFilter");
|
private readonly logger = new Logger("ExceptionFilter");
|
||||||
@@ -16,7 +24,7 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
|||||||
catch(exception: unknown, host: ArgumentsHost) {
|
catch(exception: unknown, host: ArgumentsHost) {
|
||||||
const ctx = host.switchToHttp();
|
const ctx = host.switchToHttp();
|
||||||
const response = ctx.getResponse<Response>();
|
const response = ctx.getResponse<Response>();
|
||||||
const request = ctx.getRequest<Request>();
|
const request = ctx.getRequest<RequestWithUser>();
|
||||||
|
|
||||||
const status =
|
const status =
|
||||||
exception instanceof HttpException
|
exception instanceof HttpException
|
||||||
@@ -28,6 +36,9 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
|||||||
? exception.getResponse()
|
? exception.getResponse()
|
||||||
: "Internal server error";
|
: "Internal server error";
|
||||||
|
|
||||||
|
const userId = request.user?.sub || request.user?.id;
|
||||||
|
const userPart = userId ? `[User: ${userId}] ` : "";
|
||||||
|
|
||||||
const errorResponse = {
|
const errorResponse = {
|
||||||
statusCode: status,
|
statusCode: status,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@@ -42,12 +53,12 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
|||||||
if (status === HttpStatus.INTERNAL_SERVER_ERROR) {
|
if (status === HttpStatus.INTERNAL_SERVER_ERROR) {
|
||||||
Sentry.captureException(exception);
|
Sentry.captureException(exception);
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`${request.method} ${request.url} - Error: ${exception instanceof Error ? exception.message : "Unknown error"}`,
|
`${userPart}${request.method} ${request.url} - Error: ${exception instanceof Error ? exception.message : "Unknown error"}`,
|
||||||
exception instanceof Error ? exception.stack : "",
|
exception instanceof Error ? exception.stack : "",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`${request.method} ${request.url} - Status: ${status} - Message: ${JSON.stringify(message)}`,
|
`${userPart}${request.method} ${request.url} - Status: ${status} - Message: ${JSON.stringify(message)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Readable } from "node:stream";
|
import { Readable } from "node:stream";
|
||||||
import { NotFoundException } from "@nestjs/common";
|
import { NotFoundException } from "@nestjs/common";
|
||||||
import { Test, TestingModule } from "@nestjs/testing";
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import type { Response } from "express";
|
||||||
import { S3Service } from "../s3/s3.service";
|
import { S3Service } from "../s3/s3.service";
|
||||||
import { MediaController } from "./media.controller";
|
import { MediaController } from "./media.controller";
|
||||||
|
|
||||||
describe("MediaController", () => {
|
describe("MediaController", () => {
|
||||||
let controller: MediaController;
|
let controller: MediaController;
|
||||||
let s3Service: S3Service;
|
|
||||||
|
|
||||||
const mockS3Service = {
|
const mockS3Service = {
|
||||||
getFileInfo: jest.fn(),
|
getFileInfo: jest.fn(),
|
||||||
@@ -20,7 +20,6 @@ describe("MediaController", () => {
|
|||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
controller = module.get<MediaController>(MediaController);
|
controller = module.get<MediaController>(MediaController);
|
||||||
s3Service = module.get<S3Service>(S3Service);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be defined", () => {
|
it("should be defined", () => {
|
||||||
@@ -31,7 +30,7 @@ describe("MediaController", () => {
|
|||||||
it("should stream the file and set headers with path containing slashes", async () => {
|
it("should stream the file and set headers with path containing slashes", async () => {
|
||||||
const res = {
|
const res = {
|
||||||
setHeader: jest.fn(),
|
setHeader: jest.fn(),
|
||||||
} as any;
|
} as unknown as Response;
|
||||||
const stream = new Readable();
|
const stream = new Readable();
|
||||||
stream.pipe = jest.fn();
|
stream.pipe = jest.fn();
|
||||||
const key = "contents/user-id/test.webp";
|
const key = "contents/user-id/test.webp";
|
||||||
@@ -52,7 +51,7 @@ describe("MediaController", () => {
|
|||||||
|
|
||||||
it("should throw NotFoundException if file is not found", async () => {
|
it("should throw NotFoundException if file is not found", async () => {
|
||||||
mockS3Service.getFileInfo.mockRejectedValue(new Error("Not found"));
|
mockS3Service.getFileInfo.mockRejectedValue(new Error("Not found"));
|
||||||
const res = {} as any;
|
const res = {} as unknown as Response;
|
||||||
|
|
||||||
await expect(controller.getFile("invalid", res)).rejects.toThrow(
|
await expect(controller.getFile("invalid", res)).rejects.toThrow(
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Controller, Get, NotFoundException, Param, Res } from "@nestjs/common";
|
import { Controller, Get, NotFoundException, Param, Res } from "@nestjs/common";
|
||||||
import type { Response } from "express";
|
import type { Response } from "express";
|
||||||
|
import type { BucketItemStat } from "minio";
|
||||||
import { S3Service } from "../s3/s3.service";
|
import { S3Service } from "../s3/s3.service";
|
||||||
|
|
||||||
@Controller("media")
|
@Controller("media")
|
||||||
@@ -9,7 +10,7 @@ export class MediaController {
|
|||||||
@Get("*key")
|
@Get("*key")
|
||||||
async getFile(@Param("key") key: string, @Res() res: Response) {
|
async getFile(@Param("key") key: string, @Res() res: Response) {
|
||||||
try {
|
try {
|
||||||
const stats = (await this.s3Service.getFileInfo(key)) as any;
|
const stats = (await this.s3Service.getFileInfo(key)) as BucketItemStat;
|
||||||
const stream = await this.s3Service.getFile(key);
|
const stream = await this.s3Service.getFile(key);
|
||||||
|
|
||||||
const contentType =
|
const contentType =
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ describe("S3Service", () => {
|
|||||||
|
|
||||||
it("should use DOMAIN_NAME and PORT for localhost", () => {
|
it("should use DOMAIN_NAME and PORT for localhost", () => {
|
||||||
(configService.get as jest.Mock).mockImplementation(
|
(configService.get as jest.Mock).mockImplementation(
|
||||||
(key: string, def: any) => {
|
(key: string, def: unknown) => {
|
||||||
if (key === "API_URL") return null;
|
if (key === "API_URL") return null;
|
||||||
if (key === "DOMAIN_NAME") return "localhost";
|
if (key === "DOMAIN_NAME") return "localhost";
|
||||||
if (key === "PORT") return 3000;
|
if (key === "PORT") return 3000;
|
||||||
@@ -210,7 +210,7 @@ describe("S3Service", () => {
|
|||||||
|
|
||||||
it("should use api.DOMAIN_NAME for production", () => {
|
it("should use api.DOMAIN_NAME for production", () => {
|
||||||
(configService.get as jest.Mock).mockImplementation(
|
(configService.get as jest.Mock).mockImplementation(
|
||||||
(key: string, def: any) => {
|
(key: string, def: unknown) => {
|
||||||
if (key === "API_URL") return null;
|
if (key === "API_URL") return null;
|
||||||
if (key === "DOMAIN_NAME") return "memegoat.fr";
|
if (key === "DOMAIN_NAME") return "memegoat.fr";
|
||||||
return def;
|
return def;
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export class S3Service implements OnModuleInit, IStorageService {
|
|||||||
...metaData,
|
...metaData,
|
||||||
"Content-Type": mimeType,
|
"Content-Type": mimeType,
|
||||||
});
|
});
|
||||||
|
this.logger.log(`File uploaded successfully: ${fileName} to ${bucketName}`);
|
||||||
return fileName;
|
return fileName;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Error uploading file to ${bucketName}: ${error.message}`);
|
this.logger.error(`Error uploading file to ${bucketName}: ${error.message}`);
|
||||||
@@ -113,6 +114,7 @@ export class S3Service implements OnModuleInit, IStorageService {
|
|||||||
async deleteFile(fileName: string, bucketName: string = this.bucketName) {
|
async deleteFile(fileName: string, bucketName: string = this.bucketName) {
|
||||||
try {
|
try {
|
||||||
await this.minioClient.removeObject(bucketName, fileName);
|
await this.minioClient.removeObject(bucketName, fileName);
|
||||||
|
this.logger.log(`File deleted successfully: ${fileName} from ${bucketName}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Error deleting file from ${bucketName}: ${error.message}`,
|
`Error deleting file from ${bucketName}: ${error.message}`,
|
||||||
|
|||||||
@@ -82,6 +82,11 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
|||||||
Récupère les informations détaillées de l'utilisateur connecté. Requiert l'authentification.
|
Récupère les informations détaillées de l'utilisateur connecté. Requiert l'authentification.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion title="GET /users/public/:username">
|
||||||
|
Récupère le profil public d'un utilisateur par son nom d'utilisateur.
|
||||||
|
**Réponse :** `id`, `username`, `displayName`, `avatarUrl`, `createdAt`.
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="GET /users/me/export">
|
<Accordion title="GET /users/me/export">
|
||||||
Extrait l'intégralité des données de l'utilisateur au format JSON (Conformité RGPD).
|
Extrait l'intégralité des données de l'utilisateur au format JSON (Conformité RGPD).
|
||||||
Contient le profil, les contenus et les favoris.
|
Contient le profil, les contenus et les favoris.
|
||||||
@@ -89,7 +94,22 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
|||||||
|
|
||||||
<Accordion title="PATCH /users/me">
|
<Accordion title="PATCH /users/me">
|
||||||
Met à jour les informations du profil.
|
Met à jour les informations du profil.
|
||||||
|
**Corps :**
|
||||||
- `displayName` (string)
|
- `displayName` (string)
|
||||||
|
- `bio` (string)
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion title="POST /users/me/avatar">
|
||||||
|
Met à jour l'avatar de l'utilisateur.
|
||||||
|
**Type :** `multipart/form-data`
|
||||||
|
**Champ :** `file` (Image)
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion title="PATCH /users/me/consent">
|
||||||
|
Met à jour les consentements légaux de l'utilisateur.
|
||||||
|
**Corps :**
|
||||||
|
- `termsVersion` (string)
|
||||||
|
- `privacyVersion` (string)
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="DELETE /users/me">
|
<Accordion title="DELETE /users/me">
|
||||||
@@ -105,9 +125,9 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
|||||||
- `POST /users/me/2fa/disable` : Désactive avec jeton.
|
- `POST /users/me/2fa/disable` : Désactive avec jeton.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="Administration (GET /users/admin)">
|
<Accordion title="Administration (Admin uniquement)">
|
||||||
Liste tous les utilisateurs. Réservé aux administrateurs.
|
- `GET /users/admin` : Liste tous les utilisateurs (avec pagination `limit`, `offset`).
|
||||||
**Params :** `limit`, `offset`.
|
- `DELETE /users/:uuid` : Supprime définitivement un utilisateur par son UUID.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</Accordions>
|
</Accordions>
|
||||||
|
|
||||||
@@ -118,12 +138,15 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
|||||||
Recherche et filtre les contenus. Ces endpoints sont mis en cache (Redis + Navigateur).
|
Recherche et filtre les contenus. Ces endpoints sont mis en cache (Redis + Navigateur).
|
||||||
|
|
||||||
**Query Params :**
|
**Query Params :**
|
||||||
|
- `limit` (number) : Défaut 10.
|
||||||
|
- `offset` (number) : Défaut 0.
|
||||||
- `sort` : `trend` | `recent` (uniquement sur `/explore`)
|
- `sort` : `trend` | `recent` (uniquement sur `/explore`)
|
||||||
- `tag` (string)
|
- `tag` (string) : Filtrer par tag.
|
||||||
- `category` (slug ou id)
|
- `category` (slug ou id) : Filtrer par catégorie.
|
||||||
- `author` (username)
|
- `author` (username) : Filtrer par auteur.
|
||||||
- `query` (titre)
|
- `query` (titre) : Recherche textuelle.
|
||||||
- `favoritesOnly` (bool)
|
- `favoritesOnly` (bool) : Ne montrer que les favoris de l'utilisateur connecté.
|
||||||
|
- `userId` (uuid) : Filtrer les contenus d'un utilisateur spécifique.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="GET /contents/:idOrSlug">
|
<Accordion title="GET /contents/:idOrSlug">
|
||||||
@@ -133,8 +156,13 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
|||||||
Si l'User-Agent correspond à un robot d'indexation (Googlebot, Twitterbot, etc.), l'API retourne un rendu HTML minimal contenant les méta-tags **OpenGraph** et **Twitter Cards** pour un partage optimal. Pour les autres clients, les données sont retournées en JSON.
|
Si l'User-Agent correspond à un robot d'indexation (Googlebot, Twitterbot, etc.), l'API retourne un rendu HTML minimal contenant les méta-tags **OpenGraph** et **Twitter Cards** pour un partage optimal. Pour les autres clients, les données sont retournées en JSON.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion title="POST /contents">
|
||||||
|
Crée une entrée de contenu (sans upload de fichier direct). Utile pour référencer des URLs externes.
|
||||||
|
**Corps :** `title`, `description`, `url`, `type`, `categoryId`, `tags`.
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="POST /contents/upload">
|
<Accordion title="POST /contents/upload">
|
||||||
Upload un fichier avec traitement automatique.
|
Upload un fichier avec traitement automatique par le serveur.
|
||||||
**Type :** `multipart/form-data`
|
**Type :** `multipart/form-data`
|
||||||
|
|
||||||
**Champs :**
|
**Champs :**
|
||||||
@@ -145,6 +173,11 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
|||||||
- `tags`? : string[]
|
- `tags`? : string[]
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion title="POST /contents/upload-url">
|
||||||
|
Génère une URL présignée pour un upload direct vers S3.
|
||||||
|
**Query Param :** `fileName` (string).
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="POST /contents/:id/view | /use">
|
<Accordion title="POST /contents/:id/view | /use">
|
||||||
Incrémente les statistiques de vue ou d'utilisation.
|
Incrémente les statistiques de vue ou d'utilisation.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
@@ -152,6 +185,10 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
|||||||
<Accordion title="DELETE /contents/:id">
|
<Accordion title="DELETE /contents/:id">
|
||||||
Supprime un contenu (Soft Delete). Doit être l'auteur.
|
Supprime un contenu (Soft Delete). Doit être l'auteur.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion title="DELETE /contents/:id/admin">
|
||||||
|
Supprime définitivement un contenu. **Réservé aux administrateurs.**
|
||||||
|
</Accordion>
|
||||||
</Accordions>
|
</Accordions>
|
||||||
|
|
||||||
### 📂 Catégories, ⭐ Favoris, 🚩 Signalements
|
### 📂 Catégories, ⭐ Favoris, 🚩 Signalements
|
||||||
@@ -159,19 +196,23 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
|||||||
<Accordions>
|
<Accordions>
|
||||||
<Accordion title="Catégories (/categories)">
|
<Accordion title="Catégories (/categories)">
|
||||||
- `GET /categories` : Liste toutes les catégories.
|
- `GET /categories` : Liste toutes les catégories.
|
||||||
|
- `GET /categories/:id` : Détails d'une catégorie.
|
||||||
- `POST /categories` : Création (Admin uniquement).
|
- `POST /categories` : Création (Admin uniquement).
|
||||||
|
- `PATCH /categories/:id` : Mise à jour (Admin uniquement).
|
||||||
|
- `DELETE /categories/:id` : Suppression (Admin uniquement).
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="Favoris (/favorites)">
|
<Accordion title="Favoris (/favorites)">
|
||||||
- `GET /favorites` : Liste les favoris de l'utilisateur.
|
Requiert l'authentification.
|
||||||
|
- `GET /favorites` : Liste les favoris de l'utilisateur (avec pagination `limit`, `offset`).
|
||||||
- `POST /favorites/:contentId` : Ajoute un favori.
|
- `POST /favorites/:contentId` : Ajoute un favori.
|
||||||
- `DELETE /favorites/:contentId` : Retire un favori.
|
- `DELETE /favorites/:contentId` : Retire un favori.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="Signalements (/reports)">
|
<Accordion title="Signalements (/reports)">
|
||||||
- `POST /reports` : Signale un contenu ou un tag.
|
- `POST /reports` : Signale un contenu ou un tag.
|
||||||
- `GET /reports` : Liste (Modérateurs).
|
- `GET /reports` : Liste des signalements (Pagination `limit`, `offset`). **Admin/Modérateurs**.
|
||||||
- `PATCH /reports/:id/status` : Gère le workflow.
|
- `PATCH /reports/:id/status` : Change le statut (`pending`, `resolved`, `dismissed`). **Admin/Modérateurs**.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</Accordions>
|
</Accordions>
|
||||||
|
|
||||||
@@ -185,7 +226,23 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
|||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="Tags (/tags)">
|
<Accordion title="Tags (/tags)">
|
||||||
- `GET /tags` : Recherche de tags populaires ou récents.
|
- `GET /tags` : Recherche de tags.
|
||||||
**Params :** `query`, `sort`, `limit`.
|
- **Params :** `query` (recherche), `sort` (`popular` | `recent`), `limit`, `offset`.
|
||||||
|
</Accordion>
|
||||||
|
</Accordions>
|
||||||
|
|
||||||
|
### 🛠️ Système & Médias
|
||||||
|
|
||||||
|
<Accordions>
|
||||||
|
<Accordion title="Santé (/health)">
|
||||||
|
- `GET /health` : Vérifie l'état de l'API et de la connexion à la base de données.
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion title="Médias (/media)">
|
||||||
|
- `GET /media/*key` : Accès direct aux fichiers stockés sur S3. Supporte la mise en cache agressive.
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion title="Administration (/admin)">
|
||||||
|
- `GET /admin/stats` : Récupère les statistiques globales de la plateforme. **Admin uniquement**.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</Accordions>
|
</Accordions>
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ Le système utilise plusieurs méthodes d'authentification sécurisées pour ré
|
|||||||
<Card title="Double Authentification" description="Support TOTP natif avec secret chiffré PGP pour une sécurité maximale." />
|
<Card title="Double Authentification" description="Support TOTP natif avec secret chiffré PGP pour une sécurité maximale." />
|
||||||
</Cards>
|
</Cards>
|
||||||
|
|
||||||
### Webhooks / Services Externes
|
### Stockage & Médias (S3)
|
||||||
|
|
||||||
Liste des intégrations tierces.
|
Memegoat utilise une architecture de stockage d'objets compatible S3 (MinIO). Les interactions se font de deux manières :
|
||||||
|
|
||||||
|
1. **Proxification Backend** : Pour l'accès public via `/media/*`.
|
||||||
|
2. **URLs Présignées** : Pour l'upload sécurisé direct depuis le client (via `/contents/upload-url`).
|
||||||
|
|
||||||
|
### Notifications (Mail)
|
||||||
|
|
||||||
|
Le système intègre un service d'envoi d'emails (SMTP) pour les notifications critiques et la gestion des comptes.
|
||||||
|
|||||||
@@ -35,10 +35,13 @@ erDiagram
|
|||||||
string username
|
string username
|
||||||
string email
|
string email
|
||||||
string display_name
|
string display_name
|
||||||
|
string avatar_url
|
||||||
|
string bio
|
||||||
string status
|
string status
|
||||||
}
|
}
|
||||||
CONTENT {
|
CONTENT {
|
||||||
string title
|
string title
|
||||||
|
string slug
|
||||||
string type
|
string type
|
||||||
string storage_key
|
string storage_key
|
||||||
}
|
}
|
||||||
@@ -82,6 +85,8 @@ erDiagram
|
|||||||
bytea email
|
bytea email
|
||||||
varchar email_hash
|
varchar email_hash
|
||||||
varchar display_name
|
varchar display_name
|
||||||
|
varchar avatar_url
|
||||||
|
varchar bio
|
||||||
varchar password_hash
|
varchar password_hash
|
||||||
user_status status
|
user_status status
|
||||||
bytea two_factor_secret
|
bytea two_factor_secret
|
||||||
@@ -100,6 +105,7 @@ erDiagram
|
|||||||
uuid category_id FK
|
uuid category_id FK
|
||||||
content_type type
|
content_type type
|
||||||
varchar title
|
varchar title
|
||||||
|
varchar slug
|
||||||
varchar storage_key
|
varchar storage_key
|
||||||
varchar mime_type
|
varchar mime_type
|
||||||
integer file_size
|
integer file_size
|
||||||
@@ -233,6 +239,8 @@ erDiagram
|
|||||||
varchar email_hash "UNIQUE, INDEXED"
|
varchar email_hash "UNIQUE, INDEXED"
|
||||||
varchar username "UNIQUE, NOT NULL"
|
varchar username "UNIQUE, NOT NULL"
|
||||||
varchar password_hash "NOT NULL"
|
varchar password_hash "NOT NULL"
|
||||||
|
varchar avatar_url "NULLABLE"
|
||||||
|
varchar bio "NULLABLE"
|
||||||
bytea two_factor_secret "ENCRYPTED"
|
bytea two_factor_secret "ENCRYPTED"
|
||||||
boolean is_two_factor_enabled "DEFAULT false"
|
boolean is_two_factor_enabled "DEFAULT false"
|
||||||
timestamp gdpr_accepted_at "NULLABLE"
|
timestamp gdpr_accepted_at "NULLABLE"
|
||||||
@@ -241,6 +249,7 @@ erDiagram
|
|||||||
contents {
|
contents {
|
||||||
uuid id "DEFAULT gen_random_uuid()"
|
uuid id "DEFAULT gen_random_uuid()"
|
||||||
uuid user_id "REFERENCES users(uuid)"
|
uuid user_id "REFERENCES users(uuid)"
|
||||||
|
varchar slug "UNIQUE, NOT NULL"
|
||||||
varchar storage_key "UNIQUE, NOT NULL"
|
varchar storage_key "UNIQUE, NOT NULL"
|
||||||
integer file_size "NOT NULL"
|
integer file_size "NOT NULL"
|
||||||
timestamp deleted_at "SOFT DELETE"
|
timestamp deleted_at "SOFT DELETE"
|
||||||
|
|||||||
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
|
||||||
@@ -3,6 +3,11 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"version:get": "cmake -P version.cmake GET",
|
||||||
|
"version:set": "cmake -P version.cmake SET",
|
||||||
|
"v:patch": "cmake -P version.cmake PATCH",
|
||||||
|
"v:minor": "cmake -P version.cmake MINOR",
|
||||||
|
"v:major": "cmake -P version.cmake MAJOR",
|
||||||
"build": "pnpm run build:back && pnpm run build:front && pnpm run build:docs",
|
"build": "pnpm run build:back && pnpm run build:front && pnpm run build:docs",
|
||||||
"build:front": "pnpm run -F @memegoat/frontend build",
|
"build:front": "pnpm run -F @memegoat/frontend build",
|
||||||
"build:back": "pnpm run -F @memegoat/backend build",
|
"build:back": "pnpm run -F @memegoat/backend build",
|
||||||
|
|||||||
109
version.cmake
Normal file
109
version.cmake
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# version.cmake - Script pour gérer la version SemVer de manière centralisée
|
||||||
|
|
||||||
|
# Usage: cmake -P version.cmake [GET|SET|PATCH|MINOR|MAJOR] [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 incrémenter la version SemVer
|
||||||
|
function(increment_version CURRENT_VERSION TYPE OUT_VAR)
|
||||||
|
string(REPLACE "." ";" VERSION_LIST ${CURRENT_VERSION})
|
||||||
|
list(GET VERSION_LIST 0 MAJOR)
|
||||||
|
list(GET VERSION_LIST 1 MINOR)
|
||||||
|
list(GET VERSION_LIST 2 PATCH)
|
||||||
|
|
||||||
|
if("${TYPE}" STREQUAL "MAJOR")
|
||||||
|
math(EXPR MAJOR "${MAJOR} + 1")
|
||||||
|
set(MINOR 0)
|
||||||
|
set(PATCH 0)
|
||||||
|
elseif("${TYPE}" STREQUAL "MINOR")
|
||||||
|
math(EXPR MINOR "${MINOR} + 1")
|
||||||
|
set(PATCH 0)
|
||||||
|
elseif("${TYPE}" STREQUAL "PATCH")
|
||||||
|
math(EXPR PATCH "${PATCH} + 1")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set(${OUT_VAR} "${MAJOR}.${MINOR}.${PATCH}" 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|PATCH|MINOR|MAJOR] [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}")
|
||||||
|
elseif("${COMMAND}" MATCHES "^(PATCH|MINOR|MAJOR)$")
|
||||||
|
get_current_version(CURRENT_VERSION)
|
||||||
|
increment_version("${CURRENT_VERSION}" "${COMMAND}" NEW_VERSION)
|
||||||
|
set_new_version("${NEW_VERSION}")
|
||||||
|
else()
|
||||||
|
message(FATAL_ERROR "Commande inconnue: ${COMMAND}. Utilisez GET, SET, PATCH, MINOR ou MAJOR.")
|
||||||
|
endif()
|
||||||
Reference in New Issue
Block a user