Compare commits

..

3 Commits

Author SHA1 Message Date
ec0d4b296b Merge pull request 'Fix media routing & CI Perfs' (#12) from dev into prod
All checks were successful
Backend Tests / test (push) Successful in 1m11s
Deploy to Production / Validate Build & Lint (backend) (push) Successful in 1m13s
Deploy to Production / Validate Build & Lint (documentation) (push) Successful in 1m26s
Lint / lint (backend) (push) Successful in 1m7s
Lint / lint (documentation) (push) Successful in 1m8s
Lint / lint (frontend) (push) Successful in 1m7s
Deploy to Production / Validate Build & Lint (frontend) (push) Successful in 1m23s
Deploy to Production / Deploy to Production (push) Successful in 1m51s
Reviewed-on: #12
2026-01-15 00:01:34 +01:00
7a928df73c Merge pull request 'dev' (#11) from dev into prod
Some checks failed
Backend Tests / test (push) Has been cancelled
Lint / lint (push) Has been cancelled
Deploy to Production / deploy (push) Successful in 6m37s
Reviewed-on: #11
2026-01-14 23:23:06 +01:00
a1c48bb792 Merge pull request 'refactor(modules): mark DatabaseModule and CryptoModule as global and remove redundant imports' (#10) from dev into prod
Some checks failed
Backend Tests / test (push) Has been cancelled
Lint / lint (push) Has been cancelled
Deploy to Production / deploy (push) Successful in 6m10s
Reviewed-on: #10
2026-01-14 22:52:03 +01:00
23 changed files with 169 additions and 478 deletions

View File

@@ -0,0 +1,36 @@
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
- name: Run Backend Tests
run: pnpm -F @memegoat/backend test

View File

@@ -1,18 +1,13 @@
# Pipeline CI/CD pour Gitea Actions (Forgejo) name: Deploy to Production
# 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: Valider ${{ matrix.component }} name: Validate Build & Lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
@@ -21,23 +16,23 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Installer pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with: with:
version: 9 version: 9
- name: Configurer Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
- name: Obtenir le chemin du store pnpm - name: Get pnpm store directory
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: Configurer le cache pnpm - name: Setup pnpm cache
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
@@ -45,43 +40,26 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-pnpm-store- ${{ runner.os }}-pnpm-store-
- name: Installer les dépendances - name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline run: pnpm install --frozen-lockfile
- 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: Déploiement en Production name: Deploy to 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: Vérifier l'environnement Docker - name: Deploy with Docker Compose
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:

43
.gitea/workflows/lint.yml Normal file
View File

@@ -0,0 +1,43 @@
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
- name: Lint ${{ matrix.component }}
run: pnpm -F @memegoat/${{ matrix.component }} lint

View File

@@ -1,50 +0,0 @@
# 🐐 Memegoat - Roadmap & Critères de Production
Ce document définit les objectifs, les critères techniques et les fonctionnalités à atteindre pour que le projet Memegoat soit considéré comme prêt pour la production et conforme aux normes européennes (RGPD) et françaises.
## 1. 🏗️ Architecture & Infrastructure
- [x] Backend NestJS (TypeScript)
- [x] Base de données PostgreSQL avec Drizzle ORM
- [x] Stockage d'objets compatible S3 (MinIO)
- [x] Service d'Emailing (Nodemailer / SMTPS)
- [x] Documentation Technique & Référence API (`docs.memegoat.fr`)
- [x] Health Checks (`/health`)
- [x] Gestion des variables d'environnement (Validation avec Zod)
- [ ] CI/CD (Build, Lint, Test, Deploy)
## 2. 🔐 Sécurité & Authentification
- [x] Hachage des mots de passe (Argon2id)
- [x] Gestion des sessions robuste (JWT avec Refresh Token et Rotation)
- [x] RBAC (Role Based Access Control) fonctionnel
- [x] Système de Clés API (Hachées en base)
- [x] Double Authentification (2FA / TOTP)
- [x] Limitation de débit (Rate Limiting / Throttler)
- [x] Validation stricte des entrées (DTOs + ValidationPipe)
- [x] Protection contre les vulnérabilités OWASP (Helmet, CORS)
## 3. ⚖️ Conformité RGPD (EU & France)
- [x] Chiffrement natif des données personnelles (PII) via PGP (pgcrypto)
- [x] Hachage aveugle (Blind Indexing) pour l'email (recherche/unicité)
- [x] Journalisation d'audit complète (Audit Logs) pour les actions sensibles
- [x] Gestion du consentement (Versionnage CGU/Politique de Confidentialité)
- [x] Droit à l'effacement : Flux de suppression (Soft Delete -> Purge définitive)
- [x] Droit à la portabilité : Export des données utilisateur (JSON)
- [x] Purge automatique des données obsolètes (Signalements, Sessions expirées)
- [x] Anonymisation des adresses IP (Hachage) dans les logs
## 4. 🖼️ Fonctionnalités Coeur (Media & Galerie)
- [x] Exploration (Trends, Recent, Favoris)
- [x] Recherche par Tags, Catégories, Auteur, Texte
- [x] Gestion des Favoris
- [x] Upload sécurisé via S3 (URLs présignées)
- [x] Scan Antivirus (ClamAV) et traitement des médias (WebP, WebM, AVIF, AV1)
- [x] Limitation de la taille et des formats de fichiers entrants (Configurable)
- [x] Système de Signalement (Reports) et workflow de modération
- [ ] SEO : Metatags dynamiques et slugs sémantiques
## 5. ✅ Qualité & Robustesse
- [ ] Couverture de tests unitaires (Jest) > 80%
- [ ] Tests d'intégration et E2E
- [x] Gestion centralisée des erreurs (Filters NestJS)
- [ ] Monitoring et centralisation des logs (ex: Sentry, ELK/Loki)
- [ ] Performance : Cache (Redis) pour les tendances et recherches fréquentes

View File

@@ -1,5 +1,4 @@
# syntax=docker/dockerfile:1 FROM node:22-slim AS base
FROM node:22-alpine AS base
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@latest --activate RUN corepack enable && corepack prepare pnpm@latest --activate
@@ -10,17 +9,10 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY backend/package.json ./backend/ COPY backend/package.json ./backend/
COPY frontend/package.json ./frontend/ COPY frontend/package.json ./frontend/
COPY documentation/package.json ./documentation/ COPY documentation/package.json ./documentation/
RUN pnpm install --no-frozen-lockfile
# Utilisation du cache pour pnpm et installation figée
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
COPY . . COPY . .
# On réinstalle après COPY pour s'assurer que tous les scripts de cycle de vie et les liens sont corrects
# Deuxième passe avec cache pour les scripts/liens RUN pnpm install --no-frozen-lockfile
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
RUN pnpm run --filter @memegoat/backend build RUN pnpm run --filter @memegoat/backend build
RUN pnpm deploy --filter=@memegoat/backend --prod --legacy /app RUN pnpm deploy --filter=@memegoat/backend --prod --legacy /app
RUN cp -r backend/dist /app/dist RUN cp -r backend/dist /app/dist

View File

@@ -12,7 +12,6 @@ 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";
@@ -77,8 +76,6 @@ import { UsersModule } from "./users/users.module";
}) })
export class AppModule implements NestModule { export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) { configure(consumer: MiddlewareConsumer) {
consumer consumer.apply(CrawlerDetectionMiddleware).forRoutes("*");
.apply(HTTPLoggerMiddleware, CrawlerDetectionMiddleware)
.forRoutes("*");
} }
} }

View File

@@ -110,7 +110,6 @@ 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");
} }
@@ -120,12 +119,10 @@ export class AuthService {
); );
if (!isPasswordValid) { if (!isPasswordValid) {
this.logger.warn(`Login failed: invalid password for user ${user.uuid}`);
throw new UnauthorizedException("Invalid credentials"); throw new UnauthorizedException("Invalid credentials");
} }
if (user.isTwoFactorEnabled) { if (user.isTwoFactorEnabled) {
this.logger.log(`2FA required for user ${user.uuid}`);
return { return {
message: "2FA required", message: "2FA required",
requires2FA: true, requires2FA: true,
@@ -144,7 +141,6 @@ export class AuthService {
ip, ip,
); );
this.logger.log(`User ${user.uuid} logged in successfully`);
return { return {
message: "User logged in successfully", message: "User logged in successfully",
access_token: accessToken, access_token: accessToken,
@@ -169,9 +165,6 @@ 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");
} }
@@ -186,7 +179,6 @@ export class AuthService {
ip, ip,
); );
this.logger.log(`User ${userId} logged in successfully via 2FA`);
return { return {
message: "User logged in successfully (2FA)", message: "User logged in successfully (2FA)",
access_token: accessToken, access_token: accessToken,

View File

@@ -9,14 +9,6 @@ import {
import * as Sentry from "@sentry/nestjs"; import * as Sentry from "@sentry/nestjs";
import { Request, Response } from "express"; import { Request, Response } from "express";
interface RequestWithUser extends Request {
user?: {
sub?: string;
username?: string;
id?: string;
};
}
@Catch() @Catch()
export class AllExceptionsFilter implements ExceptionFilter { export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger("ExceptionFilter"); private readonly logger = new Logger("ExceptionFilter");
@@ -24,7 +16,7 @@ export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) { catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp(); const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>(); const response = ctx.getResponse<Response>();
const request = ctx.getRequest<RequestWithUser>(); const request = ctx.getRequest<Request>();
const status = const status =
exception instanceof HttpException exception instanceof HttpException
@@ -36,9 +28,6 @@ export class AllExceptionsFilter implements ExceptionFilter {
? exception.getResponse() ? exception.getResponse()
: "Internal server error"; : "Internal server error";
const userId = request.user?.sub || request.user?.id;
const userPart = userId ? `[User: ${userId}] ` : "";
const errorResponse = { const errorResponse = {
statusCode: status, statusCode: status,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -53,12 +42,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(
`${userPart}${request.method} ${request.url} - Error: ${exception instanceof Error ? exception.message : "Unknown error"}`, `${request.method} ${request.url} - Error: ${exception instanceof Error ? exception.message : "Unknown error"}`,
exception instanceof Error ? exception.stack : "", exception instanceof Error ? exception.stack : "",
); );
} else { } else {
this.logger.warn( this.logger.warn(
`${userPart}${request.method} ${request.url} - Status: ${status} - Message: ${JSON.stringify(message)}`, `${request.method} ${request.url} - Status: ${status} - Message: ${JSON.stringify(message)}`,
); );
} }

View File

@@ -1,37 +0,0 @@
import { createHash } from "node:crypto";
import { Injectable, Logger, NestMiddleware } from "@nestjs/common";
import { NextFunction, Request, Response } from "express";
@Injectable()
export class HTTPLoggerMiddleware implements NestMiddleware {
private readonly logger = new Logger("HTTP");
use(request: Request, response: Response, next: NextFunction): void {
const { method, originalUrl, ip } = request;
const userAgent = request.get("user-agent") || "";
const startTime = Date.now();
response.on("finish", () => {
const { statusCode } = response;
const contentLength = response.get("content-length");
const duration = Date.now() - startTime;
const hashedIp = createHash("sha256")
.update(ip as string)
.digest("hex");
const message = `${method} ${originalUrl} ${statusCode} ${contentLength || 0} - ${userAgent} ${hashedIp} +${duration}ms`;
if (statusCode >= 500) {
return this.logger.error(message);
}
if (statusCode >= 400) {
return this.logger.warn(message);
}
return this.logger.log(message);
});
next();
}
}

View File

@@ -100,7 +100,6 @@ export class ContentsService {
// 3. Upload vers S3 // 3. Upload vers S3
const key = `contents/${userId}/${Date.now()}-${uuidv4()}.${processed.extension}`; const key = `contents/${userId}/${Date.now()}-${uuidv4()}.${processed.extension}`;
await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType); await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType);
this.logger.log(`File uploaded successfully to S3: ${key}`);
// 4. Création en base de données // 4. Création en base de données
return await this.create(userId, { return await this.create(userId, {

View File

@@ -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,6 +20,7 @@ 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", () => {
@@ -27,13 +28,12 @@ describe("MediaController", () => {
}); });
describe("getFile", () => { describe("getFile", () => {
it("should stream the file and set headers with path containing slashes", async () => { it("should stream the file and set headers", async () => {
const res = { const res = {
setHeader: jest.fn(), setHeader: jest.fn(),
} as unknown as Response; } as any;
const stream = new Readable(); const stream = new Readable();
stream.pipe = jest.fn(); stream.pipe = jest.fn();
const key = "contents/user-id/test.webp";
mockS3Service.getFileInfo.mockResolvedValue({ mockS3Service.getFileInfo.mockResolvedValue({
size: 100, size: 100,
@@ -41,9 +41,8 @@ describe("MediaController", () => {
}); });
mockS3Service.getFile.mockResolvedValue(stream); mockS3Service.getFile.mockResolvedValue(stream);
await controller.getFile(key, res); await controller.getFile("test.webp", res);
expect(mockS3Service.getFileInfo).toHaveBeenCalledWith(key);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/webp"); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/webp");
expect(res.setHeader).toHaveBeenCalledWith("Content-Length", 100); expect(res.setHeader).toHaveBeenCalledWith("Content-Length", 100);
expect(stream.pipe).toHaveBeenCalledWith(res); expect(stream.pipe).toHaveBeenCalledWith(res);
@@ -51,7 +50,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 unknown as Response; const res = {} as any;
await expect(controller.getFile("invalid", res)).rejects.toThrow( await expect(controller.getFile("invalid", res)).rejects.toThrow(
NotFoundException, NotFoundException,

View File

@@ -1,6 +1,5 @@
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")
@@ -10,15 +9,13 @@ 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 BucketItemStat; const stats = await this.s3Service.getFileInfo(key);
const stream = await this.s3Service.getFile(key); const stream = await this.s3Service.getFile(key);
const contentType = res.setHeader(
stats.metaData?.["content-type"] || "Content-Type",
stats.metadata?.["content-type"] || stats.metaData["content-type"] || "application/octet-stream",
"application/octet-stream"; );
res.setHeader("Content-Type", contentType);
res.setHeader("Content-Length", stats.size); res.setHeader("Content-Length", stats.size);
res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); res.setHeader("Cache-Control", "public, max-age=31536000, immutable");

View File

@@ -7,7 +7,7 @@ jest.mock("minio");
describe("S3Service", () => { describe("S3Service", () => {
let service: S3Service; let service: S3Service;
let configService: ConfigService; let _configService: ConfigService;
// biome-ignore lint/suspicious/noExplicitAny: Fine for testing purposes // biome-ignore lint/suspicious/noExplicitAny: Fine for testing purposes
let minioClient: any; let minioClient: any;
@@ -42,7 +42,7 @@ describe("S3Service", () => {
}).compile(); }).compile();
service = module.get<S3Service>(S3Service); service = module.get<S3Service>(S3Service);
configService = module.get<ConfigService>(ConfigService); _configService = module.get<ConfigService>(ConfigService);
}); });
it("should be defined", () => { it("should be defined", () => {
@@ -185,39 +185,35 @@ describe("S3Service", () => {
}); });
}); });
describe("getPublicUrl", () => { describe("moveFile", () => {
it("should use API_URL if provided", () => { it("should move file within default bucket", async () => {
(configService.get as jest.Mock).mockImplementation((key: string) => { const source = "source.txt";
if (key === "API_URL") return "https://api.test.com"; const dest = "dest.txt";
return null; await service.moveFile(source, dest);
});
const url = service.getPublicUrl("test.webp"); expect(minioClient.copyObject).toHaveBeenCalledWith(
expect(url).toBe("https://api.test.com/media/test.webp"); "memegoat",
dest,
"/memegoat/source.txt",
expect.any(Minio.CopyConditions),
);
expect(minioClient.removeObject).toHaveBeenCalledWith("memegoat", source);
}); });
it("should use DOMAIN_NAME and PORT for localhost", () => { it("should move file between different buckets", async () => {
(configService.get as jest.Mock).mockImplementation( const source = "source.txt";
(key: string, def: unknown) => { const dest = "dest.txt";
if (key === "API_URL") return null; const sBucket = "source-bucket";
if (key === "DOMAIN_NAME") return "localhost"; const dBucket = "dest-bucket";
if (key === "PORT") return 3000; await service.moveFile(source, dest, sBucket, dBucket);
return def;
},
);
const url = service.getPublicUrl("test.webp");
expect(url).toBe("http://localhost:3000/media/test.webp");
});
it("should use api.DOMAIN_NAME for production", () => { expect(minioClient.copyObject).toHaveBeenCalledWith(
(configService.get as jest.Mock).mockImplementation( dBucket,
(key: string, def: unknown) => { dest,
if (key === "API_URL") return null; `/${sBucket}/${source}`,
if (key === "DOMAIN_NAME") return "memegoat.fr"; expect.any(Minio.CopyConditions),
return def;
},
); );
const url = service.getPublicUrl("test.webp"); expect(minioClient.removeObject).toHaveBeenCalledWith(sBucket, source);
expect(url).toBe("https://api.memegoat.fr/media/test.webp");
}); });
}); });
}); });

View File

@@ -54,7 +54,6 @@ export class S3Service implements OnModuleInit, IStorageService {
...metaData, ...metaData,
"Content-Type": mimeType, "Content-Type": mimeType,
}); });
this.logger.log(`File uploaded successfully: ${fileName} to ${bucketName}`);
return fileName; return fileName;
} catch (error) { } catch (error) {
this.logger.error(`Error uploading file to ${bucketName}: ${error.message}`); this.logger.error(`Error uploading file to ${bucketName}: ${error.message}`);
@@ -114,7 +113,6 @@ export class S3Service implements OnModuleInit, IStorageService {
async deleteFile(fileName: string, bucketName: string = this.bucketName) { async deleteFile(fileName: string, bucketName: string = this.bucketName) {
try { try {
await this.minioClient.removeObject(bucketName, fileName); await this.minioClient.removeObject(bucketName, fileName);
this.logger.log(`File deleted successfully: ${fileName} from ${bucketName}`);
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
`Error deleting file from ${bucketName}: ${error.message}`, `Error deleting file from ${bucketName}: ${error.message}`,
@@ -160,19 +158,17 @@ export class S3Service implements OnModuleInit, IStorageService {
getPublicUrl(storageKey: string): string { getPublicUrl(storageKey: string): string {
const apiUrl = this.configService.get<string>("API_URL"); const apiUrl = this.configService.get<string>("API_URL");
if (apiUrl) {
return `${apiUrl.replace(/\/$/, "")}/media/${storageKey}`;
}
const domain = this.configService.get<string>("DOMAIN_NAME", "localhost"); const domain = this.configService.get<string>("DOMAIN_NAME", "localhost");
const port = this.configService.get<number>("PORT", 3000); const port = this.configService.get<number>("PORT", 3000);
let baseUrl: string; if (domain === "localhost" || domain === "127.0.0.1") {
return `http://${domain}:${port}/media/${storageKey}`;
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}`; return `https://api.${domain}/media/${storageKey}`;
} }
} }

View File

@@ -143,7 +143,6 @@ export class UsersService {
// 3. Upload vers S3 // 3. Upload vers S3
const key = `avatars/${uuid}/${Date.now()}-${uuidv4()}.${processed.extension}`; const key = `avatars/${uuid}/${Date.now()}-${uuidv4()}.${processed.extension}`;
await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType); await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType);
this.logger.log(`Avatar uploaded successfully to S3: ${key}`);
// 4. Mise à jour de la base de données // 4. Mise à jour de la base de données
const user = await this.update(uuid, { avatarUrl: key }); const user = await this.update(uuid, { avatarUrl: key });

View File

@@ -1,4 +1,4 @@
# syntax=docker/dockerfile:1 # syntax=docker.io/docker/dockerfile:1
FROM node:22-alpine AS base FROM node:22-alpine AS base
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
@@ -11,20 +11,11 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY backend/package.json ./backend/ COPY backend/package.json ./backend/
COPY frontend/package.json ./frontend/ COPY frontend/package.json ./frontend/
COPY documentation/package.json ./documentation/ COPY documentation/package.json ./documentation/
RUN pnpm install --no-frozen-lockfile
# Montage du cache pnpm
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
COPY . . COPY . .
# On réinstalle après COPY pour s'assurer que tous les scripts de cycle de vie et les liens sont corrects
# Deuxième passe avec cache pour les scripts/liens RUN pnpm install --no-frozen-lockfile
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ RUN pnpm run --filter @memegoat/documentation build
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 FROM node:22-alpine AS runner
WORKDIR /app WORKDIR /app

View File

@@ -82,11 +82,6 @@ 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.
@@ -94,22 +89,7 @@ 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">
@@ -125,9 +105,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 (Admin uniquement)"> <Accordion title="Administration (GET /users/admin)">
- `GET /users/admin` : Liste tous les utilisateurs (avec pagination `limit`, `offset`). Liste tous les utilisateurs. Réservé aux administrateurs.
- `DELETE /users/:uuid` : Supprime définitivement un utilisateur par son UUID. **Params :** `limit`, `offset`.
</Accordion> </Accordion>
</Accordions> </Accordions>
@@ -138,15 +118,12 @@ 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) : Filtrer par tag. - `tag` (string)
- `category` (slug ou id) : Filtrer par catégorie. - `category` (slug ou id)
- `author` (username) : Filtrer par auteur. - `author` (username)
- `query` (titre) : Recherche textuelle. - `query` (titre)
- `favoritesOnly` (bool) : Ne montrer que les favoris de l'utilisateur connecté. - `favoritesOnly` (bool)
- `userId` (uuid) : Filtrer les contenus d'un utilisateur spécifique.
</Accordion> </Accordion>
<Accordion title="GET /contents/:idOrSlug"> <Accordion title="GET /contents/:idOrSlug">
@@ -156,13 +133,8 @@ 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 par le serveur. Upload un fichier avec traitement automatique.
**Type :** `multipart/form-data` **Type :** `multipart/form-data`
**Champs :** **Champs :**
@@ -173,11 +145,6 @@ 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>
@@ -185,10 +152,6 @@ 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
@@ -196,23 +159,19 @@ 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)">
Requiert l'authentification. - `GET /favorites` : Liste les favoris de l'utilisateur.
- `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 des signalements (Pagination `limit`, `offset`). **Admin/Modérateurs**. - `GET /reports` : Liste (Modérateurs).
- `PATCH /reports/:id/status` : Change le statut (`pending`, `resolved`, `dismissed`). **Admin/Modérateurs**. - `PATCH /reports/:id/status` : Gère le workflow.
</Accordion> </Accordion>
</Accordions> </Accordions>
@@ -226,23 +185,7 @@ 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. - `GET /tags` : Recherche de tags populaires ou récents.
- **Params :** `query` (recherche), `sort` (`popular` | `recent`), `limit`, `offset`. **Params :** `query`, `sort`, `limit`.
</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>

View File

@@ -20,13 +20,6 @@ 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>
### Stockage & Médias (S3) ### Webhooks / Services Externes
Memegoat utilise une architecture de stockage d'objets compatible S3 (MinIO). Les interactions se font de deux manières : Liste des intégrations tierces.
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.

View File

@@ -35,13 +35,10 @@ 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
} }
@@ -85,8 +82,6 @@ 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
@@ -105,7 +100,6 @@ 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
@@ -239,8 +233,6 @@ 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"
@@ -249,7 +241,6 @@ 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"

View File

@@ -1,4 +1,4 @@
# syntax=docker/dockerfile:1 # syntax=docker.io/docker/dockerfile:1
FROM node:22-alpine AS base FROM node:22-alpine AS base
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
@@ -11,20 +11,11 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY backend/package.json ./backend/ COPY backend/package.json ./backend/
COPY frontend/package.json ./frontend/ COPY frontend/package.json ./frontend/
COPY documentation/package.json ./documentation/ COPY documentation/package.json ./documentation/
RUN pnpm install --no-frozen-lockfile
# Montage du cache pnpm
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
COPY . . COPY . .
# On réinstalle après COPY pour s'assurer que tous les scripts de cycle de vie et les liens sont corrects
# Deuxième passe avec cache pour les scripts/liens RUN pnpm install --no-frozen-lockfile
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ RUN pnpm run --filter @memegoat/frontend build
pnpm install --frozen-lockfile
# Build avec cache Next.js
RUN --mount=type=cache,id=next-cache,target=/usr/src/app/frontend/.next/cache \
pnpm run --filter @memegoat/frontend build
FROM node:22-alpine AS runner FROM node:22-alpine AS runner
WORKDIR /app WORKDIR /app

View File

@@ -1,30 +0,0 @@
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

View File

@@ -3,11 +3,6 @@
"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",

View File

@@ -1,109 +0,0 @@
# 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()