Compare commits

..

1 Commits

Author SHA1 Message Date
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
76 changed files with 331 additions and 3863 deletions

View File

@@ -0,0 +1,22 @@
name: Backend Tests
on:
push:
paths:
- 'backend/**'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Run Backend Tests
run: pnpm -F @memegoat/backend test

View File

@@ -1,111 +0,0 @@
# Pipeline CI/CD pour Gitea Actions (Forgejo)
# Compatible avec GitHub Actions pour la portabilité
name: CI/CD Pipeline
on:
push:
branches:
- '**'
tags:
- 'v*'
pull_request:
jobs:
validate:
name: Valider ${{ matrix.component }}
runs-on: ubuntu-latest
strategy:
matrix:
component: [backend, frontend, documentation]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Installer pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Configurer Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Obtenir le chemin du store pnpm
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> "${GITEA_OUTPUT:-$GITHUB_OUTPUT}"
- name: Configurer le cache pnpm
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Installer les dépendances
run: pnpm install --frozen-lockfile --prefer-offline
- name: Lint ${{ matrix.component }}
run: pnpm -F @memegoat/${{ matrix.component }} lint
- name: Tester ${{ matrix.component }}
if: matrix.component == 'backend' || matrix.component == 'frontend'
run: |
if pnpm -F @memegoat/${{ matrix.component }} run | grep -q "test"; then
pnpm -F @memegoat/${{ matrix.component }} test
else
echo "Pas de script de test trouvé pour ${{ matrix.component }}, passage."
fi
- name: Build ${{ matrix.component }}
run: pnpm -F @memegoat/${{ matrix.component }} build
env:
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
deploy:
name: Déploiement en Production
needs: validate
# Déclenchement uniquement sur push sur main ou tag de version
# Gitea supporte le contexte 'github' pour la compatibilité
if: gitea.event_name == 'push' && (gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/v'))
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Vérifier l'environnement Docker
run: |
docker version
docker compose version
- name: Déployer avec Docker Compose
run: |
docker compose -f docker-compose.prod.yml up -d --build --remove-orphans
env:
BACKEND_PORT: ${{ secrets.BACKEND_PORT }}
FRONTEND_PORT: ${{ secrets.FRONTEND_PORT }}
POSTGRES_HOST: ${{ secrets.POSTGRES_HOST }}
POSTGRES_PORT: ${{ secrets.POSTGRES_PORT }}
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
REDIS_HOST: ${{ secrets.REDIS_HOST }}
REDIS_PORT: ${{ secrets.REDIS_PORT }}
S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }}
S3_PORT: ${{ secrets.S3_PORT }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
S3_BUCKET_NAME: ${{ secrets.S3_BUCKET_NAME }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
PGP_ENCRYPTION_KEY: ${{ secrets.PGP_ENCRYPTION_KEY }}
SESSION_PASSWORD: ${{ secrets.SESSION_PASSWORD }}
MAIL_HOST: ${{ secrets.MAIL_HOST }}
MAIL_PASS: ${{ secrets.MAIL_PASS }}
MAIL_USER: ${{ secrets.MAIL_USER }}
MAIL_FROM: ${{ secrets.MAIL_FROM }}
DOMAIN_NAME: ${{ secrets.DOMAIN_NAME }}
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}

View File

@@ -0,0 +1,87 @@
name: Deploy to Production
on:
push:
branches:
- prod
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 20
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITEA_ENV
- name: Setup pnpm cache
uses: actions/cache@v3
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
- name: Lint - Backend
run: pnpm run lint:back
- name: Build - Backend
run: pnpm run build:back
env:
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
- name: Lint - Frontend
run: pnpm run lint:front
- name: Build - Frontend
run: pnpm run build:front
- name: Lint - Documentation
run: pnpm run lint:docs
- name: Build - Documentation
run: pnpm run build:docs
- name: Deploy with Docker Compose
run: |
docker compose -f docker-compose.prod.yml up -d --build
env:
BACKEND_PORT: ${{ secrets.BACKEND_PORT }}
FRONTEND_PORT: ${{ secrets.FRONTEND_PORT }}
POSTGRES_HOST: ${{ secrets.POSTGRES_HOST }}
POSTGRES_PORT: ${{ secrets.POSTGRES_PORT }}
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
REDIS_HOST: ${{ secrets.REDIS_HOST }}
REDIS_PORT: ${{ secrets.REDIS_PORT }}
S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }}
S3_PORT: ${{ secrets.S3_PORT }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
S3_BUCKET_NAME: ${{ secrets.S3_BUCKET_NAME }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
PGP_ENCRYPTION_KEY: ${{ secrets.PGP_ENCRYPTION_KEY }}
SESSION_PASSWORD: ${{ secrets.SESSION_PASSWORD }}
MAIL_HOST: ${{ secrets.MAIL_HOST }}
MAIL_PASS: ${{ secrets.MAIL_PASS }}
MAIL_USER: ${{ secrets.MAIL_USER }}
MAIL_FROM: ${{ secrets.MAIL_FROM }}
DOMAIN_NAME: ${{ secrets.DOMAIN_NAME }}
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}

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

@@ -0,0 +1,31 @@
name: Lint
on:
push:
paths:
- 'frontend/**'
- 'backend/**'
- 'documentation/**'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Lint Frontend
if: success() || failure()
run: pnpm -F @memegoat/frontend lint
- name: Lint Backend
if: success() || failure()
run: pnpm -F @memegoat/backend lint
- name: Lint Documentation
if: success() || failure()
run: pnpm -F @bypass/documentation lint

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)
- [x] 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

@@ -24,8 +24,7 @@
"rules": { "rules": {
"recommended": true, "recommended": true,
"suspicious": { "suspicious": {
"noUnknownAtRules": "off", "noUnknownAtRules": "off"
"noExplicitAny": "off"
}, },
"style": { "style": {
"useImportType": "off" "useImportType": "off"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@memegoat/backend", "name": "@memegoat/backend",
"version": "1.2.1", "version": "0.0.1",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -13,7 +13,7 @@
"scripts": { "scripts": {
"build": "nest build", "build": "nest build",
"lint": "biome check", "lint": "biome check",
"lint:write": "biome check --write --unsafe", "lint:write": "biome check --write",
"format": "biome format --write", "format": "biome format --write",
"start": "nest start", "start": "nest start",
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
@@ -107,7 +107,7 @@
"coverageDirectory": "../coverage", "coverageDirectory": "../coverage",
"testEnvironment": "node", "testEnvironment": "node",
"transformIgnorePatterns": [ "transformIgnorePatterns": [
"node_modules/(?!(.pnpm/)?(jose|@noble|uuid))" "node_modules/(?!(.pnpm/)?(jose|@noble|uuid)/)"
], ],
"transform": { "transform": {
"^.+\\.(t|j)sx?$": "ts-jest" "^.+\\.(t|j)sx?$": "ts-jest"

View File

@@ -1,62 +0,0 @@
jest.mock("uuid", () => ({
v4: jest.fn(() => "mocked-uuid"),
}));
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
ml_kem768: {
keygen: jest.fn(),
encapsulate: jest.fn(),
decapsulate: jest.fn(),
},
}));
jest.mock("jose", () => ({
SignJWT: jest.fn().mockReturnValue({
setProtectedHeader: jest.fn().mockReturnThis(),
setIssuedAt: jest.fn().mockReturnThis(),
setExpirationTime: jest.fn().mockReturnThis(),
sign: jest.fn().mockResolvedValue("mocked-jwt"),
}),
jwtVerify: jest.fn(),
}));
import { Test, TestingModule } from "@nestjs/testing";
import { AuthGuard } from "../auth/guards/auth.guard";
import { RolesGuard } from "../auth/guards/roles.guard";
import { AdminController } from "./admin.controller";
import { AdminService } from "./admin.service";
describe("AdminController", () => {
let controller: AdminController;
let service: AdminService;
const mockAdminService = {
getStats: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AdminController],
providers: [{ provide: AdminService, useValue: mockAdminService }],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<AdminController>(AdminController);
service = module.get<AdminService>(AdminService);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("getStats", () => {
it("should call service.getStats", async () => {
await controller.getStats();
expect(service.getStats).toHaveBeenCalled();
});
});
});

View File

@@ -1,58 +0,0 @@
import { Test, TestingModule } from "@nestjs/testing";
import { CategoriesRepository } from "../categories/repositories/categories.repository";
import { ContentsRepository } from "../contents/repositories/contents.repository";
import { UsersRepository } from "../users/repositories/users.repository";
import { AdminService } from "./admin.service";
describe("AdminService", () => {
let service: AdminService;
let _usersRepository: UsersRepository;
let _contentsRepository: ContentsRepository;
let _categoriesRepository: CategoriesRepository;
const mockUsersRepository = {
countAll: jest.fn(),
};
const mockContentsRepository = {
count: jest.fn(),
};
const mockCategoriesRepository = {
countAll: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AdminService,
{ provide: UsersRepository, useValue: mockUsersRepository },
{ provide: ContentsRepository, useValue: mockContentsRepository },
{ provide: CategoriesRepository, useValue: mockCategoriesRepository },
],
}).compile();
service = module.get<AdminService>(AdminService);
_usersRepository = module.get<UsersRepository>(UsersRepository);
_contentsRepository = module.get<ContentsRepository>(ContentsRepository);
_categoriesRepository =
module.get<CategoriesRepository>(CategoriesRepository);
});
it("should return stats", async () => {
mockUsersRepository.countAll.mockResolvedValue(10);
mockContentsRepository.count.mockResolvedValue(20);
mockCategoriesRepository.countAll.mockResolvedValue(5);
const result = await service.getStats();
expect(result).toEqual({
users: 10,
contents: 20,
categories: 5,
});
expect(mockUsersRepository.countAll).toHaveBeenCalled();
expect(mockContentsRepository.count).toHaveBeenCalledWith({});
expect(mockCategoriesRepository.countAll).toHaveBeenCalled();
});
});

View File

@@ -1,95 +0,0 @@
jest.mock("uuid", () => ({
v4: jest.fn(() => "mocked-uuid"),
}));
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
ml_kem768: {
keygen: jest.fn(),
encapsulate: jest.fn(),
decapsulate: jest.fn(),
},
}));
jest.mock("jose", () => ({
SignJWT: jest.fn().mockReturnValue({
setProtectedHeader: jest.fn().mockReturnThis(),
setIssuedAt: jest.fn().mockReturnThis(),
setExpirationTime: jest.fn().mockReturnThis(),
sign: jest.fn().mockResolvedValue("mocked-jwt"),
}),
jwtVerify: jest.fn(),
}));
import { Test, TestingModule } from "@nestjs/testing";
import { AuthGuard } from "../auth/guards/auth.guard";
import { AuthenticatedRequest } from "../common/interfaces/request.interface";
import { ApiKeysController } from "./api-keys.controller";
import { ApiKeysService } from "./api-keys.service";
describe("ApiKeysController", () => {
let controller: ApiKeysController;
let service: ApiKeysService;
const mockApiKeysService = {
create: jest.fn(),
findAll: jest.fn(),
revoke: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ApiKeysController],
providers: [{ provide: ApiKeysService, useValue: mockApiKeysService }],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<ApiKeysController>(ApiKeysController);
service = module.get<ApiKeysService>(ApiKeysService);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("create", () => {
it("should call service.create", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
const dto = { name: "Key Name", expiresAt: "2026-01-20T12:00:00Z" };
await controller.create(req, dto);
expect(service.create).toHaveBeenCalledWith(
"user-uuid",
"Key Name",
new Date(dto.expiresAt),
);
});
it("should call service.create without expiresAt", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
const dto = { name: "Key Name" };
await controller.create(req, dto);
expect(service.create).toHaveBeenCalledWith(
"user-uuid",
"Key Name",
undefined,
);
});
});
describe("findAll", () => {
it("should call service.findAll", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.findAll(req);
expect(service.findAll).toHaveBeenCalledWith("user-uuid");
});
});
describe("revoke", () => {
it("should call service.revoke", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.revoke(req, "key-id");
expect(service.revoke).toHaveBeenCalledWith("user-uuid", "key-id");
});
});
});

View File

@@ -1,83 +0,0 @@
import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "../../database/database.service";
import { ApiKeysRepository } from "./api-keys.repository";
describe("ApiKeysRepository", () => {
let repository: ApiKeysRepository;
let _databaseService: DatabaseService;
const mockDb = {
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
returning: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
execute: jest.fn(),
};
const wrapWithThen = (obj: unknown) => {
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
Object.defineProperty(obj, "then", {
value: function (onFulfilled: (arg0: unknown) => void) {
const result = (this as Record<string, unknown>).execute();
return Promise.resolve(result).then(onFulfilled);
},
configurable: true,
});
return obj;
};
wrapWithThen(mockDb);
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ApiKeysRepository,
{ provide: DatabaseService, useValue: { db: mockDb } },
],
}).compile();
repository = module.get<ApiKeysRepository>(ApiKeysRepository);
_databaseService = module.get<DatabaseService>(DatabaseService);
});
it("should create an api key", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
await repository.create({
userId: "u1",
name: "n",
prefix: "p",
keyHash: "h",
});
expect(mockDb.insert).toHaveBeenCalled();
});
it("should find all keys for user", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
const result = await repository.findAll("u1");
expect(result).toHaveLength(1);
});
it("should revoke a key", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([
{ id: "1", isActive: false },
]);
const result = await repository.revoke("u1", "k1");
expect(result[0].isActive).toBe(false);
});
it("should find active by hash", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
const result = await repository.findActiveByKeyHash("h");
expect(result.id).toBe("1");
});
it("should update last used", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
await repository.updateLastUsed("1");
expect(mockDb.update).toHaveBeenCalled();
});
});

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

@@ -1,190 +0,0 @@
jest.mock("uuid", () => ({
v4: jest.fn(() => "mocked-uuid"),
}));
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
ml_kem768: {
keygen: jest.fn(),
encapsulate: jest.fn(),
decapsulate: jest.fn(),
},
}));
jest.mock("jose", () => ({
SignJWT: jest.fn().mockReturnValue({
setProtectedHeader: jest.fn().mockReturnThis(),
setIssuedAt: jest.fn().mockReturnThis(),
setExpirationTime: jest.fn().mockReturnThis(),
sign: jest.fn().mockResolvedValue("mocked-jwt"),
}),
jwtVerify: jest.fn(),
}));
import { ConfigService } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { BootstrapService } from "./bootstrap.service";
jest.mock("iron-session", () => ({
getIronSession: jest.fn().mockResolvedValue({
save: jest.fn(),
destroy: jest.fn(),
}),
}));
describe("AuthController", () => {
let controller: AuthController;
let authService: AuthService;
let _configService: ConfigService;
const mockAuthService = {
register: jest.fn(),
login: jest.fn(),
verifyTwoFactorLogin: jest.fn(),
refresh: jest.fn(),
};
const mockBootstrapService = {
consumeToken: jest.fn(),
};
const mockConfigService = {
get: jest
.fn()
.mockReturnValue("complex_password_at_least_32_characters_long"),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
providers: [
{ provide: AuthService, useValue: mockAuthService },
{ provide: BootstrapService, useValue: mockBootstrapService },
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
controller = module.get<AuthController>(AuthController);
authService = module.get<AuthService>(AuthService);
_configService = module.get<ConfigService>(ConfigService);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("register", () => {
it("should call authService.register", async () => {
const dto = {
email: "test@example.com",
password: "password",
username: "test",
};
await controller.register(dto as any);
expect(authService.register).toHaveBeenCalledWith(dto);
});
});
describe("login", () => {
it("should call authService.login and setup session if success", async () => {
const dto = { email: "test@example.com", password: "password" };
const req = { ip: "127.0.0.1" } as any;
const res = { json: jest.fn() } as any;
const loginResult = {
access_token: "at",
refresh_token: "rt",
userId: "1",
message: "ok",
};
mockAuthService.login.mockResolvedValue(loginResult);
await controller.login(dto as any, "ua", req, res);
expect(authService.login).toHaveBeenCalledWith(dto, "ua", "127.0.0.1");
expect(res.json).toHaveBeenCalledWith({ message: "ok", userId: "1" });
});
it("should return result if no access_token", async () => {
const dto = { email: "test@example.com", password: "password" };
const req = { ip: "127.0.0.1" } as any;
const res = { json: jest.fn() } as any;
const loginResult = { message: "2fa_required", userId: "1" };
mockAuthService.login.mockResolvedValue(loginResult);
await controller.login(dto as any, "ua", req, res);
expect(res.json).toHaveBeenCalledWith(loginResult);
});
});
describe("verifyTwoFactor", () => {
it("should call authService.verifyTwoFactorLogin and setup session", async () => {
const dto = { userId: "1", token: "123456" };
const req = { ip: "127.0.0.1" } as any;
const res = { json: jest.fn() } as any;
const verifyResult = {
access_token: "at",
refresh_token: "rt",
message: "ok",
};
mockAuthService.verifyTwoFactorLogin.mockResolvedValue(verifyResult);
await controller.verifyTwoFactor(dto, "ua", req, res);
expect(authService.verifyTwoFactorLogin).toHaveBeenCalledWith(
"1",
"123456",
"ua",
"127.0.0.1",
);
expect(res.json).toHaveBeenCalledWith({ message: "ok" });
});
});
describe("refresh", () => {
it("should refresh token if session has refresh token", async () => {
const { getIronSession } = require("iron-session");
const session = { refreshToken: "rt", save: jest.fn() };
getIronSession.mockResolvedValue(session);
const req = {} as any;
const res = { json: jest.fn() } as any;
mockAuthService.refresh.mockResolvedValue({
access_token: "at2",
refresh_token: "rt2",
});
await controller.refresh(req, res);
expect(authService.refresh).toHaveBeenCalledWith("rt");
expect(res.json).toHaveBeenCalledWith({ message: "Token refreshed" });
});
it("should return 401 if no refresh token", async () => {
const { getIronSession } = require("iron-session");
const session = { save: jest.fn() };
getIronSession.mockResolvedValue(session);
const req = {} as any;
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() } as any;
await controller.refresh(req, res);
expect(res.status).toHaveBeenCalledWith(401);
});
});
describe("logout", () => {
it("should destroy session", async () => {
const { getIronSession } = require("iron-session");
const session = { destroy: jest.fn() };
getIronSession.mockResolvedValue(session);
const req = {} as any;
const res = { json: jest.fn() } as any;
await controller.logout(req, res);
expect(session.destroy).toHaveBeenCalled();
expect(res.json).toHaveBeenCalledWith({ message: "User logged out" });
});
});
});

View File

@@ -1,19 +1,9 @@
import { import { Body, Controller, Headers, Post, Req, Res } from "@nestjs/common";
Body,
Controller,
Get,
Headers,
Post,
Query,
Req,
Res,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { Throttle } from "@nestjs/throttler"; import { Throttle } from "@nestjs/throttler";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { getIronSession } from "iron-session"; import { getIronSession } from "iron-session";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { BootstrapService } from "./bootstrap.service";
import { LoginDto } from "./dto/login.dto"; import { LoginDto } from "./dto/login.dto";
import { RegisterDto } from "./dto/register.dto"; import { RegisterDto } from "./dto/register.dto";
import { Verify2faDto } from "./dto/verify-2fa.dto"; import { Verify2faDto } from "./dto/verify-2fa.dto";
@@ -23,7 +13,6 @@ import { getSessionOptions, SessionData } from "./session.config";
export class AuthController { export class AuthController {
constructor( constructor(
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly bootstrapService: BootstrapService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
) {} ) {}
@@ -131,12 +120,4 @@ export class AuthController {
session.destroy(); session.destroy();
return res.json({ message: "User logged out" }); return res.json({ message: "User logged out" });
} }
@Get("bootstrap-admin")
async bootstrapAdmin(
@Query("token") token: string,
@Query("username") username: string,
) {
return this.bootstrapService.consumeToken(token, username);
}
} }

View File

@@ -3,7 +3,6 @@ import { SessionsModule } from "../sessions/sessions.module";
import { UsersModule } from "../users/users.module"; import { UsersModule } from "../users/users.module";
import { AuthController } from "./auth.controller"; import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { BootstrapService } from "./bootstrap.service";
import { AuthGuard } from "./guards/auth.guard"; import { AuthGuard } from "./guards/auth.guard";
import { OptionalAuthGuard } from "./guards/optional-auth.guard"; import { OptionalAuthGuard } from "./guards/optional-auth.guard";
import { RolesGuard } from "./guards/roles.guard"; import { RolesGuard } from "./guards/roles.guard";
@@ -16,7 +15,6 @@ import { RbacRepository } from "./repositories/rbac.repository";
providers: [ providers: [
AuthService, AuthService,
RbacService, RbacService,
BootstrapService,
RbacRepository, RbacRepository,
AuthGuard, AuthGuard,
OptionalAuthGuard, OptionalAuthGuard,

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

@@ -1,114 +0,0 @@
import { UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
import { UsersService } from "../users/users.service";
import { BootstrapService } from "./bootstrap.service";
import { RbacService } from "./rbac.service";
describe("BootstrapService", () => {
let service: BootstrapService;
let rbacService: RbacService;
let _usersService: UsersService;
const mockRbacService = {
countAdmins: jest.fn(),
assignRoleToUser: jest.fn(),
};
const mockUsersService = {
findPublicProfile: jest.fn(),
};
const mockConfigService = {
get: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
BootstrapService,
{ provide: RbacService, useValue: mockRbacService },
{ provide: UsersService, useValue: mockUsersService },
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
service = module.get<BootstrapService>(BootstrapService);
rbacService = module.get<RbacService>(RbacService);
_usersService = module.get<UsersService>(UsersService);
});
it("should be defined", () => {
expect(service).toBeDefined();
});
describe("onApplicationBootstrap", () => {
it("should generate a token if no admin exists", async () => {
mockRbacService.countAdmins.mockResolvedValue(0);
const generateTokenSpy = jest.spyOn(
service as any,
"generateBootstrapToken",
);
await service.onApplicationBootstrap();
expect(rbacService.countAdmins).toHaveBeenCalled();
expect(generateTokenSpy).toHaveBeenCalled();
});
it("should not generate a token if admin exists", async () => {
mockRbacService.countAdmins.mockResolvedValue(1);
const generateTokenSpy = jest.spyOn(
service as any,
"generateBootstrapToken",
);
await service.onApplicationBootstrap();
expect(rbacService.countAdmins).toHaveBeenCalled();
expect(generateTokenSpy).not.toHaveBeenCalled();
});
});
describe("consumeToken", () => {
it("should throw UnauthorizedException if token is invalid", async () => {
mockRbacService.countAdmins.mockResolvedValue(0);
await service.onApplicationBootstrap();
await expect(service.consumeToken("wrong-token", "user1")).rejects.toThrow(
UnauthorizedException,
);
});
it("should throw UnauthorizedException if user not found", async () => {
mockRbacService.countAdmins.mockResolvedValue(0);
await service.onApplicationBootstrap();
const token = (service as any).bootstrapToken;
mockUsersService.findPublicProfile.mockResolvedValue(null);
await expect(service.consumeToken(token, "user1")).rejects.toThrow(
UnauthorizedException,
);
});
it("should assign admin role and invalidate token on success", async () => {
mockRbacService.countAdmins.mockResolvedValue(0);
await service.onApplicationBootstrap();
const token = (service as any).bootstrapToken;
const mockUser = { uuid: "user-uuid", username: "user1" };
mockUsersService.findPublicProfile.mockResolvedValue(mockUser);
const result = await service.consumeToken(token, "user1");
expect(rbacService.assignRoleToUser).toHaveBeenCalledWith(
"user-uuid",
"admin",
);
expect((service as any).bootstrapToken).toBeNull();
expect(result.message).toContain("user1 is now an administrator");
});
});
});

View File

@@ -1,67 +0,0 @@
import * as crypto from "node:crypto";
import {
Injectable,
Logger,
OnApplicationBootstrap,
UnauthorizedException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { UsersService } from "../users/users.service";
import { RbacService } from "./rbac.service";
@Injectable()
export class BootstrapService implements OnApplicationBootstrap {
private readonly logger = new Logger(BootstrapService.name);
private bootstrapToken: string | null = null;
constructor(
private readonly rbacService: RbacService,
private readonly usersService: UsersService,
private readonly configService: ConfigService,
) {}
async onApplicationBootstrap() {
const adminCount = await this.rbacService.countAdmins();
if (adminCount === 0) {
this.generateBootstrapToken();
}
}
private generateBootstrapToken() {
this.bootstrapToken = crypto.randomBytes(32).toString("hex");
const domain = this.configService.get("DOMAIN_NAME") || "localhost";
const protocol = domain.includes("localhost") ? "http" : "https";
const url = `${protocol}://${domain}/auth/bootstrap-admin`;
this.logger.warn("SECURITY ALERT: No administrator found in database.");
this.logger.warn(
"To create the first administrator, use the following endpoint:",
);
this.logger.warn(
`Endpoint: GET ${url}?token=${this.bootstrapToken}&username=votre_nom_utilisateur`,
);
this.logger.warn(
'Exemple: curl -X GET "http://localhost/auth/bootstrap-admin?token=...&username=..."',
);
this.logger.warn("This token is one-time use only.");
}
async consumeToken(token: string, username: string) {
if (!this.bootstrapToken || token !== this.bootstrapToken) {
throw new UnauthorizedException("Invalid or expired bootstrap token");
}
const user = await this.usersService.findPublicProfile(username);
if (!user) {
throw new UnauthorizedException(`User ${username} not found`);
}
await this.rbacService.assignRoleToUser(user.uuid, "admin");
this.bootstrapToken = null; // One-time use
this.logger.log(
`User ${username} has been promoted to administrator via bootstrap token.`,
);
return { message: `User ${username} is now an administrator` };
}
}

View File

@@ -1,89 +0,0 @@
import { ExecutionContext, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
import { getIronSession } from "iron-session";
import { JwtService } from "../../crypto/services/jwt.service";
import { AuthGuard } from "./auth.guard";
jest.mock("jose", () => ({}));
jest.mock("iron-session", () => ({
getIronSession: jest.fn(),
}));
describe("AuthGuard", () => {
let guard: AuthGuard;
let _jwtService: JwtService;
let _configService: ConfigService;
const mockJwtService = {
verifyJwt: jest.fn(),
};
const mockConfigService = {
get: jest.fn().mockReturnValue("session-password"),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthGuard,
{ provide: JwtService, useValue: mockJwtService },
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
guard = module.get<AuthGuard>(AuthGuard);
_jwtService = module.get<JwtService>(JwtService);
_configService = module.get<ConfigService>(ConfigService);
});
it("should return true for valid token", async () => {
const request = { user: null };
const context = {
switchToHttp: () => ({
getRequest: () => request,
getResponse: () => ({}),
}),
} as unknown as ExecutionContext;
(getIronSession as jest.Mock).mockResolvedValue({
accessToken: "valid-token",
});
mockJwtService.verifyJwt.mockResolvedValue({ sub: "user1" });
const result = await guard.canActivate(context);
expect(result).toBe(true);
expect(request.user).toEqual({ sub: "user1" });
});
it("should throw UnauthorizedException if no token", async () => {
const context = {
switchToHttp: () => ({
getRequest: () => ({}),
getResponse: () => ({}),
}),
} as ExecutionContext;
(getIronSession as jest.Mock).mockResolvedValue({});
await expect(guard.canActivate(context)).rejects.toThrow(
UnauthorizedException,
);
});
it("should throw UnauthorizedException if token invalid", async () => {
const context = {
switchToHttp: () => ({
getRequest: () => ({}),
getResponse: () => ({}),
}),
} as ExecutionContext;
(getIronSession as jest.Mock).mockResolvedValue({ accessToken: "invalid" });
mockJwtService.verifyJwt.mockRejectedValue(new Error("invalid"));
await expect(guard.canActivate(context)).rejects.toThrow(
UnauthorizedException,
);
});
});

View File

@@ -1,84 +0,0 @@
import { ExecutionContext } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
import { getIronSession } from "iron-session";
import { JwtService } from "../../crypto/services/jwt.service";
import { OptionalAuthGuard } from "./optional-auth.guard";
jest.mock("jose", () => ({}));
jest.mock("iron-session", () => ({
getIronSession: jest.fn(),
}));
describe("OptionalAuthGuard", () => {
let guard: OptionalAuthGuard;
let _jwtService: JwtService;
const mockJwtService = {
verifyJwt: jest.fn(),
};
const mockConfigService = {
get: jest.fn().mockReturnValue("session-password"),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
OptionalAuthGuard,
{ provide: JwtService, useValue: mockJwtService },
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
guard = module.get<OptionalAuthGuard>(OptionalAuthGuard);
_jwtService = module.get<JwtService>(JwtService);
});
it("should return true and set user for valid token", async () => {
const request = { user: null };
const context = {
switchToHttp: () => ({
getRequest: () => request,
getResponse: () => ({}),
}),
} as unknown as ExecutionContext;
(getIronSession as jest.Mock).mockResolvedValue({ accessToken: "valid" });
mockJwtService.verifyJwt.mockResolvedValue({ sub: "u1" });
const result = await guard.canActivate(context);
expect(result).toBe(true);
expect(request.user).toEqual({ sub: "u1" });
});
it("should return true if no token", async () => {
const context = {
switchToHttp: () => ({
getRequest: () => ({}),
getResponse: () => ({}),
}),
} as ExecutionContext;
(getIronSession as jest.Mock).mockResolvedValue({});
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
it("should return true even if token invalid", async () => {
const context = {
switchToHttp: () => ({
getRequest: () => ({ user: null }),
getResponse: () => ({}),
}),
} as ExecutionContext;
(getIronSession as jest.Mock).mockResolvedValue({ accessToken: "invalid" });
mockJwtService.verifyJwt.mockRejectedValue(new Error("invalid"));
const result = await guard.canActivate(context);
expect(result).toBe(true);
expect(context.switchToHttp().getRequest().user).toBeNull();
});
});

View File

@@ -1,90 +0,0 @@
import { ExecutionContext } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Test, TestingModule } from "@nestjs/testing";
import { RbacService } from "../rbac.service";
import { RolesGuard } from "./roles.guard";
describe("RolesGuard", () => {
let guard: RolesGuard;
let _reflector: Reflector;
let _rbacService: RbacService;
const mockReflector = {
getAllAndOverride: jest.fn(),
};
const mockRbacService = {
getUserRoles: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
RolesGuard,
{ provide: Reflector, useValue: mockReflector },
{ provide: RbacService, useValue: mockRbacService },
],
}).compile();
guard = module.get<RolesGuard>(RolesGuard);
_reflector = module.get<Reflector>(Reflector);
_rbacService = module.get<RbacService>(RbacService);
});
it("should return true if no roles required", async () => {
mockReflector.getAllAndOverride.mockReturnValue(null);
const context = {
getHandler: () => ({}),
getClass: () => ({}),
} as ExecutionContext;
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
it("should return false if no user in request", async () => {
mockReflector.getAllAndOverride.mockReturnValue(["admin"]);
const context = {
getHandler: () => ({}),
getClass: () => ({}),
switchToHttp: () => ({
getRequest: () => ({ user: null }),
}),
} as ExecutionContext;
const result = await guard.canActivate(context);
expect(result).toBe(false);
});
it("should return true if user has required role", async () => {
mockReflector.getAllAndOverride.mockReturnValue(["admin"]);
const context = {
getHandler: () => ({}),
getClass: () => ({}),
switchToHttp: () => ({
getRequest: () => ({ user: { sub: "u1" } }),
}),
} as ExecutionContext;
mockRbacService.getUserRoles.mockResolvedValue(["admin", "user"]);
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
it("should return false if user doesn't have required role", async () => {
mockReflector.getAllAndOverride.mockReturnValue(["admin"]);
const context = {
getHandler: () => ({}),
getClass: () => ({}),
switchToHttp: () => ({
getRequest: () => ({ user: { sub: "u1" } }),
}),
} as ExecutionContext;
mockRbacService.getUserRoles.mockResolvedValue(["user"]);
const result = await guard.canActivate(context);
expect(result).toBe(false);
});
});

View File

@@ -9,8 +9,6 @@ describe("RbacService", () => {
const mockRbacRepository = { const mockRbacRepository = {
findRolesByUserId: jest.fn(), findRolesByUserId: jest.fn(),
findPermissionsByUserId: jest.fn(), findPermissionsByUserId: jest.fn(),
countRoles: jest.fn(),
createRole: jest.fn(),
}; };
beforeEach(async () => { beforeEach(async () => {
@@ -60,35 +58,4 @@ describe("RbacService", () => {
expect(repository.findPermissionsByUserId).toHaveBeenCalledWith(userId); expect(repository.findPermissionsByUserId).toHaveBeenCalledWith(userId);
}); });
}); });
describe("seedRoles", () => {
it("should be called on application bootstrap", async () => {
const seedRolesSpy = jest.spyOn(service, "seedRoles");
await service.onApplicationBootstrap();
expect(seedRolesSpy).toHaveBeenCalled();
});
it("should seed roles if none exist", async () => {
mockRbacRepository.countRoles.mockResolvedValue(0);
await service.seedRoles();
expect(repository.countRoles).toHaveBeenCalled();
expect(repository.createRole).toHaveBeenCalledTimes(3);
expect(repository.createRole).toHaveBeenCalledWith(
"Administrator",
"admin",
"Full system access",
);
});
it("should not seed roles if some already exist", async () => {
mockRbacRepository.countRoles.mockResolvedValue(3);
await service.seedRoles();
expect(repository.countRoles).toHaveBeenCalled();
expect(repository.createRole).not.toHaveBeenCalled();
});
});
}); });

View File

@@ -1,53 +1,10 @@
import { Injectable, Logger, OnApplicationBootstrap } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { RbacRepository } from "./repositories/rbac.repository"; import { RbacRepository } from "./repositories/rbac.repository";
@Injectable() @Injectable()
export class RbacService implements OnApplicationBootstrap { export class RbacService {
private readonly logger = new Logger(RbacService.name);
constructor(private readonly rbacRepository: RbacRepository) {} constructor(private readonly rbacRepository: RbacRepository) {}
async onApplicationBootstrap() {
this.logger.log("RbacService initialized, checking roles...");
await this.seedRoles();
}
async seedRoles() {
try {
const count = await this.rbacRepository.countRoles();
if (count === 0) {
this.logger.log("No roles found, seeding default roles...");
const defaultRoles = [
{
name: "Administrator",
slug: "admin",
description: "Full system access",
},
{
name: "Moderator",
slug: "moderator",
description: "Access to moderation tools",
},
{ name: "User", slug: "user", description: "Standard user access" },
];
for (const role of defaultRoles) {
await this.rbacRepository.createRole(
role.name,
role.slug,
role.description,
);
this.logger.log(`Created role: ${role.slug}`);
}
this.logger.log("Default roles seeded successfully.");
} else {
this.logger.log(`${count} roles already exist, skipping seeding.`);
}
} catch (error) {
this.logger.error("Error during roles seeding:", error);
}
}
async getUserRoles(userId: string) { async getUserRoles(userId: string) {
return this.rbacRepository.findRolesByUserId(userId); return this.rbacRepository.findRolesByUserId(userId);
} }
@@ -55,12 +12,4 @@ export class RbacService implements OnApplicationBootstrap {
async getUserPermissions(userId: string) { async getUserPermissions(userId: string) {
return this.rbacRepository.findPermissionsByUserId(userId); return this.rbacRepository.findPermissionsByUserId(userId);
} }
async countAdmins() {
return this.rbacRepository.countAdmins();
}
async assignRoleToUser(userId: string, roleSlug: string) {
return this.rbacRepository.assignRole(userId, roleSlug);
}
} }

View File

@@ -39,52 +39,4 @@ export class RbacRepository {
return Array.from(new Set(result.map((p) => p.slug))); return Array.from(new Set(result.map((p) => p.slug)));
} }
async countRoles(): Promise<number> {
const result = await this.databaseService.db
.select({ count: roles.id })
.from(roles);
return result.length;
}
async countAdmins(): Promise<number> {
const result = await this.databaseService.db
.select({ count: usersToRoles.userId })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(roles.slug, "admin"));
return result.length;
}
async createRole(name: string, slug: string, description?: string) {
return this.databaseService.db
.insert(roles)
.values({
name,
slug,
description,
})
.returning();
}
async assignRole(userId: string, roleSlug: string) {
const role = await this.databaseService.db
.select()
.from(roles)
.where(eq(roles.slug, roleSlug))
.limit(1);
if (!role[0]) {
throw new Error(`Role with slug ${roleSlug} not found`);
}
return this.databaseService.db
.insert(usersToRoles)
.values({
userId,
roleId: role[0].id,
})
.onConflictDoNothing()
.returning();
}
} }

View File

@@ -1,105 +0,0 @@
jest.mock("uuid", () => ({
v4: jest.fn(() => "mocked-uuid"),
}));
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
ml_kem768: {
keygen: jest.fn(),
encapsulate: jest.fn(),
decapsulate: jest.fn(),
},
}));
jest.mock("jose", () => ({
SignJWT: jest.fn().mockReturnValue({
setProtectedHeader: jest.fn().mockReturnThis(),
setIssuedAt: jest.fn().mockReturnThis(),
setExpirationTime: jest.fn().mockReturnThis(),
sign: jest.fn().mockResolvedValue("mocked-jwt"),
}),
jwtVerify: jest.fn(),
}));
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Test, TestingModule } from "@nestjs/testing";
import { AuthGuard } from "../auth/guards/auth.guard";
import { RolesGuard } from "../auth/guards/roles.guard";
import { CategoriesController } from "./categories.controller";
import { CategoriesService } from "./categories.service";
describe("CategoriesController", () => {
let controller: CategoriesController;
let service: CategoriesService;
const mockCategoriesService = {
findAll: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
update: jest.fn(),
remove: jest.fn(),
};
const mockCacheManager = {
get: jest.fn(),
set: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [CategoriesController],
providers: [
{ provide: CategoriesService, useValue: mockCategoriesService },
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<CategoriesController>(CategoriesController);
service = module.get<CategoriesService>(CategoriesService);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("findAll", () => {
it("should call service.findAll", async () => {
await controller.findAll();
expect(service.findAll).toHaveBeenCalled();
});
});
describe("findOne", () => {
it("should call service.findOne", async () => {
await controller.findOne("1");
expect(service.findOne).toHaveBeenCalledWith("1");
});
});
describe("create", () => {
it("should call service.create", async () => {
const dto = { name: "Cat", slug: "cat" };
await controller.create(dto);
expect(service.create).toHaveBeenCalledWith(dto);
});
});
describe("update", () => {
it("should call service.update", async () => {
const dto = { name: "New Name" };
await controller.update("1", dto);
expect(service.update).toHaveBeenCalledWith("1", dto);
});
});
describe("remove", () => {
it("should call service.remove", async () => {
await controller.remove("1");
expect(service.remove).toHaveBeenCalledWith("1");
});
});
});

View File

@@ -1,82 +0,0 @@
import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "../../database/database.service";
import { CategoriesRepository } from "./categories.repository";
describe("CategoriesRepository", () => {
let repository: CategoriesRepository;
const mockDb = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
returning: jest.fn().mockReturnThis(),
execute: jest.fn(),
};
const wrapWithThen = (obj: unknown) => {
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
Object.defineProperty(obj, "then", {
value: function (onFulfilled: (arg0: unknown) => void) {
const result = (this as Record<string, unknown>).execute();
return Promise.resolve(result).then(onFulfilled);
},
configurable: true,
});
return obj;
};
wrapWithThen(mockDb);
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
CategoriesRepository,
{ provide: DatabaseService, useValue: { db: mockDb } },
],
}).compile();
repository = module.get<CategoriesRepository>(CategoriesRepository);
});
it("should find all", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
const result = await repository.findAll();
expect(result).toHaveLength(1);
});
it("should count all", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ count: 5 }]);
const result = await repository.countAll();
expect(result).toBe(5);
});
it("should find one", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
const result = await repository.findOne("1");
expect(result.id).toBe("1");
});
it("should create", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
await repository.create({ name: "C", slug: "s" });
expect(mockDb.insert).toHaveBeenCalled();
});
it("should update", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
await repository.update("1", { name: "N", updatedAt: new Date() });
expect(mockDb.update).toHaveBeenCalled();
});
it("should remove", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
await repository.remove("1");
expect(mockDb.delete).toHaveBeenCalled();
});
});

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

@@ -33,6 +33,4 @@ export interface IStorageService {
sourceBucketName?: string, sourceBucketName?: string,
destinationBucketName?: string, destinationBucketName?: string,
): Promise<string>; ): Promise<string>;
getPublicUrl(storageKey: string): string;
} }

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

@@ -33,7 +33,6 @@ export const envSchema = z.object({
MAIL_FROM: z.string().email(), MAIL_FROM: z.string().email(),
DOMAIN_NAME: z.string(), DOMAIN_NAME: z.string(),
API_URL: z.string().url().optional(),
// Sentry // Sentry
SENTRY_DSN: z.string().optional(), SENTRY_DSN: z.string().optional(),

View File

@@ -1,230 +0,0 @@
jest.mock("uuid", () => ({
v4: jest.fn(() => "mocked-uuid"),
}));
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
ml_kem768: {
keygen: jest.fn(),
encapsulate: jest.fn(),
decapsulate: jest.fn(),
},
}));
jest.mock("jose", () => ({
SignJWT: jest.fn().mockReturnValue({
setProtectedHeader: jest.fn().mockReturnThis(),
setIssuedAt: jest.fn().mockReturnThis(),
setExpirationTime: jest.fn().mockReturnThis(),
sign: jest.fn().mockResolvedValue("mocked-jwt"),
}),
jwtVerify: jest.fn(),
}));
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Test, TestingModule } from "@nestjs/testing";
import { AuthGuard } from "../auth/guards/auth.guard";
import { OptionalAuthGuard } from "../auth/guards/optional-auth.guard";
import { RolesGuard } from "../auth/guards/roles.guard";
import { AuthenticatedRequest } from "../common/interfaces/request.interface";
import { ContentsController } from "./contents.controller";
import { ContentsService } from "./contents.service";
describe("ContentsController", () => {
let controller: ContentsController;
let service: ContentsService;
const mockContentsService = {
create: jest.fn(),
getUploadUrl: jest.fn(),
uploadAndProcess: jest.fn(),
findAll: jest.fn(),
findOne: jest.fn(),
incrementViews: jest.fn(),
incrementUsage: jest.fn(),
remove: jest.fn(),
removeAdmin: jest.fn(),
generateBotHtml: jest.fn(),
};
const mockCacheManager = {
get: jest.fn(),
set: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ContentsController],
providers: [
{ provide: ContentsService, useValue: mockContentsService },
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.overrideGuard(OptionalAuthGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<ContentsController>(ContentsController);
service = module.get<ContentsService>(ContentsService);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("create", () => {
it("should call service.create", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
const dto = { title: "Title", type: "image" as any };
await controller.create(req, dto as any);
expect(service.create).toHaveBeenCalledWith("user-uuid", dto);
});
});
describe("getUploadUrl", () => {
it("should call service.getUploadUrl", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.getUploadUrl(req, "test.jpg");
expect(service.getUploadUrl).toHaveBeenCalledWith("user-uuid", "test.jpg");
});
});
describe("upload", () => {
it("should call service.uploadAndProcess", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
const file = {} as Express.Multer.File;
const dto = { title: "Title" };
await controller.upload(req, file, dto as any);
expect(service.uploadAndProcess).toHaveBeenCalledWith(
"user-uuid",
file,
dto,
);
});
});
describe("explore", () => {
it("should call service.findAll", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.explore(
req,
10,
0,
"trend",
"tag",
"cat",
"auth",
"query",
false,
undefined,
);
expect(service.findAll).toHaveBeenCalledWith({
limit: 10,
offset: 0,
sortBy: "trend",
tag: "tag",
category: "cat",
author: "auth",
query: "query",
favoritesOnly: false,
userId: "user-uuid",
});
});
});
describe("trends", () => {
it("should call service.findAll with trend sort", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.trends(req, 10, 0);
expect(service.findAll).toHaveBeenCalledWith({
limit: 10,
offset: 0,
sortBy: "trend",
userId: "user-uuid",
});
});
});
describe("recent", () => {
it("should call service.findAll with recent sort", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.recent(req, 10, 0);
expect(service.findAll).toHaveBeenCalledWith({
limit: 10,
offset: 0,
sortBy: "recent",
userId: "user-uuid",
});
});
});
describe("findOne", () => {
it("should return json for normal user", async () => {
const req = { user: { sub: "user-uuid" }, headers: {} } as any;
const res = { json: jest.fn(), send: jest.fn() } as any;
const content = { id: "1" };
mockContentsService.findOne.mockResolvedValue(content);
await controller.findOne("1", req, res);
expect(res.json).toHaveBeenCalledWith(content);
});
it("should return html for bot", async () => {
const req = {
user: { sub: "user-uuid" },
headers: { "user-agent": "Googlebot" },
} as any;
const res = { json: jest.fn(), send: jest.fn() } as any;
const content = { id: "1" };
mockContentsService.findOne.mockResolvedValue(content);
mockContentsService.generateBotHtml.mockReturnValue("<html></html>");
await controller.findOne("1", req, res);
expect(res.send).toHaveBeenCalledWith("<html></html>");
});
it("should throw NotFoundException if not found", async () => {
const req = { user: { sub: "user-uuid" }, headers: {} } as any;
const res = { json: jest.fn(), send: jest.fn() } as any;
mockContentsService.findOne.mockResolvedValue(null);
await expect(controller.findOne("1", req, res)).rejects.toThrow(
"Contenu non trouvé",
);
});
});
describe("incrementViews", () => {
it("should call service.incrementViews", async () => {
await controller.incrementViews("1");
expect(service.incrementViews).toHaveBeenCalledWith("1");
});
});
describe("incrementUsage", () => {
it("should call service.incrementUsage", async () => {
await controller.incrementUsage("1");
expect(service.incrementUsage).toHaveBeenCalledWith("1");
});
});
describe("remove", () => {
it("should call service.remove", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.remove("1", req);
expect(service.remove).toHaveBeenCalledWith("1", "user-uuid");
});
});
describe("removeAdmin", () => {
it("should call service.removeAdmin", async () => {
await controller.removeAdmin("1");
expect(service.removeAdmin).toHaveBeenCalledWith("1");
});
});
});

View File

@@ -23,7 +23,6 @@ describe("ContentsService", () => {
incrementViews: jest.fn(), incrementViews: jest.fn(),
incrementUsage: jest.fn(), incrementUsage: jest.fn(),
softDelete: jest.fn(), softDelete: jest.fn(),
softDeleteAdmin: jest.fn(),
findOne: jest.fn(), findOne: jest.fn(),
findBySlug: jest.fn(), findBySlug: jest.fn(),
}; };
@@ -31,7 +30,6 @@ describe("ContentsService", () => {
const mockS3Service = { const mockS3Service = {
getUploadUrl: jest.fn(), getUploadUrl: jest.fn(),
uploadFile: jest.fn(), uploadFile: jest.fn(),
getPublicUrl: jest.fn(),
}; };
const mockMediaService = { const mockMediaService = {
@@ -148,81 +146,4 @@ describe("ContentsService", () => {
expect(result[0].views).toBe(1); expect(result[0].views).toBe(1);
}); });
}); });
describe("incrementUsage", () => {
it("should increment usage", async () => {
mockContentsRepository.incrementUsage.mockResolvedValue([
{ id: "1", usageCount: 1 },
]);
await service.incrementUsage("1");
expect(mockContentsRepository.incrementUsage).toHaveBeenCalledWith("1");
});
});
describe("remove", () => {
it("should soft delete content", async () => {
mockContentsRepository.softDelete.mockResolvedValue({ id: "1" });
await service.remove("1", "u1");
expect(mockContentsRepository.softDelete).toHaveBeenCalledWith("1", "u1");
});
});
describe("removeAdmin", () => {
it("should soft delete content without checking owner", async () => {
mockContentsRepository.softDeleteAdmin.mockResolvedValue({ id: "1" });
await service.removeAdmin("1");
expect(mockContentsRepository.softDeleteAdmin).toHaveBeenCalledWith("1");
});
});
describe("findOne", () => {
it("should return content by id", async () => {
mockContentsRepository.findOne.mockResolvedValue({
id: "1",
storageKey: "k",
author: { avatarUrl: "a" },
});
mockS3Service.getPublicUrl.mockReturnValue("url");
const result = await service.findOne("1");
expect(result.id).toBe("1");
expect(result.url).toBe("url");
});
it("should return content by slug", async () => {
mockContentsRepository.findOne.mockResolvedValue({
id: "1",
slug: "s",
storageKey: "k",
});
const result = await service.findOne("s");
expect(result.slug).toBe("s");
});
});
describe("generateBotHtml", () => {
it("should generate html with og tags", () => {
const content = { title: "Title", storageKey: "k" };
mockS3Service.getPublicUrl.mockReturnValue("url");
const html = service.generateBotHtml(content as any);
expect(html).toContain("<title>Title</title>");
expect(html).toContain('content="Title"');
expect(html).toContain('content="url"');
});
});
describe("ensureUniqueSlug", () => {
it("should return original slug if unique", async () => {
mockContentsRepository.findBySlug.mockResolvedValue(null);
const slug = (service as any).ensureUniqueSlug("My Title");
await expect(slug).resolves.toBe("my-title");
});
it("should append counter if not unique", async () => {
mockContentsRepository.findBySlug
.mockResolvedValueOnce({ id: "1" })
.mockResolvedValueOnce(null);
const slug = await (service as any).ensureUniqueSlug("My Title");
expect(slug).toBe("my-title-1");
});
});
}); });

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, {
@@ -129,11 +128,11 @@ export class ContentsService {
const processedData = data.map((content) => ({ const processedData = data.map((content) => ({
...content, ...content,
url: this.s3Service.getPublicUrl(content.storageKey), url: this.getFileUrl(content.storageKey),
author: { author: {
...content.author, ...content.author,
avatarUrl: content.author?.avatarUrl avatarUrl: content.author?.avatarUrl
? this.s3Service.getPublicUrl(content.author.avatarUrl) ? this.getFileUrl(content.author.avatarUrl)
: null, : null,
}, },
})); }));
@@ -190,18 +189,18 @@ export class ContentsService {
return { return {
...content, ...content,
url: this.s3Service.getPublicUrl(content.storageKey), url: this.getFileUrl(content.storageKey),
author: { author: {
...content.author, ...content.author,
avatarUrl: content.author?.avatarUrl avatarUrl: content.author?.avatarUrl
? this.s3Service.getPublicUrl(content.author.avatarUrl) ? this.getFileUrl(content.author.avatarUrl)
: null, : null,
}, },
}; };
} }
generateBotHtml(content: { title: string; storageKey: string }): string { generateBotHtml(content: { title: string; storageKey: string }): string {
const imageUrl = this.s3Service.getPublicUrl(content.storageKey); const imageUrl = this.getFileUrl(content.storageKey);
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html> <html>
<head> <head>
@@ -222,6 +221,19 @@ export class ContentsService {
</html>`; </html>`;
} }
getFileUrl(storageKey: string): string {
const endpoint = this.configService.get("S3_ENDPOINT");
const port = this.configService.get("S3_PORT");
const protocol =
this.configService.get("S3_USE_SSL") === true ? "https" : "http";
const bucket = this.configService.get("S3_BUCKET_NAME");
if (endpoint === "localhost" || endpoint === "127.0.0.1") {
return `${protocol}://${endpoint}:${port}/${bucket}/${storageKey}`;
}
return `${protocol}://${endpoint}/${bucket}/${storageKey}`;
}
private generateSlug(text: string): string { private generateSlug(text: string): string {
return text return text
.toLowerCase() .toLowerCase()

View File

@@ -1,67 +0,0 @@
import { ConfigService } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "./database.service";
jest.mock("pg", () => {
const mPool = {
connect: jest.fn(),
query: jest.fn(),
end: jest.fn(),
on: jest.fn(),
};
return { Pool: jest.fn(() => mPool) };
});
jest.mock("drizzle-orm/node-postgres", () => ({
drizzle: jest.fn().mockReturnValue({}),
}));
describe("DatabaseService", () => {
let service: DatabaseService;
let _configService: ConfigService;
const mockConfigService = {
get: jest.fn((key) => {
const config = {
POSTGRES_PASSWORD: "p",
POSTGRES_USER: "u",
POSTGRES_HOST: "h",
POSTGRES_PORT: "5432",
POSTGRES_DB: "db",
NODE_ENV: "development",
};
return config[key];
}),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
DatabaseService,
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
service = module.get<DatabaseService>(DatabaseService);
_configService = module.get<ConfigService>(ConfigService);
});
it("should be defined", () => {
expect(service).toBeDefined();
});
describe("onModuleInit", () => {
it("should skip migrations in development", async () => {
await service.onModuleInit();
expect(mockConfigService.get).toHaveBeenCalledWith("NODE_ENV");
});
});
describe("onModuleDestroy", () => {
it("should close pool", async () => {
const pool = (service as any).pool;
await service.onModuleDestroy();
expect(pool.end).toHaveBeenCalled();
});
});
});

View File

@@ -1,82 +0,0 @@
jest.mock("uuid", () => ({
v4: jest.fn(() => "mocked-uuid"),
}));
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
ml_kem768: {
keygen: jest.fn(),
encapsulate: jest.fn(),
decapsulate: jest.fn(),
},
}));
jest.mock("jose", () => ({
SignJWT: jest.fn().mockReturnValue({
setProtectedHeader: jest.fn().mockReturnThis(),
setIssuedAt: jest.fn().mockReturnThis(),
setExpirationTime: jest.fn().mockReturnThis(),
sign: jest.fn().mockResolvedValue("mocked-jwt"),
}),
jwtVerify: jest.fn(),
}));
import { Test, TestingModule } from "@nestjs/testing";
import { AuthGuard } from "../auth/guards/auth.guard";
import { AuthenticatedRequest } from "../common/interfaces/request.interface";
import { FavoritesController } from "./favorites.controller";
import { FavoritesService } from "./favorites.service";
describe("FavoritesController", () => {
let controller: FavoritesController;
let service: FavoritesService;
const mockFavoritesService = {
addFavorite: jest.fn(),
removeFavorite: jest.fn(),
getUserFavorites: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [FavoritesController],
providers: [{ provide: FavoritesService, useValue: mockFavoritesService }],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<FavoritesController>(FavoritesController);
service = module.get<FavoritesService>(FavoritesService);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("add", () => {
it("should call service.addFavorite", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.add(req, "content-1");
expect(service.addFavorite).toHaveBeenCalledWith("user-uuid", "content-1");
});
});
describe("remove", () => {
it("should call service.removeFavorite", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.remove(req, "content-1");
expect(service.removeFavorite).toHaveBeenCalledWith(
"user-uuid",
"content-1",
);
});
});
describe("list", () => {
it("should call service.getUserFavorites", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.list(req, 10, 0);
expect(service.getUserFavorites).toHaveBeenCalledWith("user-uuid", 10, 0);
});
});
});

View File

@@ -1,72 +0,0 @@
import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "../../database/database.service";
import { FavoritesRepository } from "./favorites.repository";
describe("FavoritesRepository", () => {
let repository: FavoritesRepository;
const mockDb = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
innerJoin: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
offset: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
returning: jest.fn().mockReturnThis(),
execute: jest.fn(),
};
const wrapWithThen = (obj: unknown) => {
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
Object.defineProperty(obj, "then", {
value: function (onFulfilled: (arg0: unknown) => void) {
const result = (this as Record<string, unknown>).execute();
return Promise.resolve(result).then(onFulfilled);
},
configurable: true,
});
return obj;
};
wrapWithThen(mockDb);
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
FavoritesRepository,
{ provide: DatabaseService, useValue: { db: mockDb } },
],
}).compile();
repository = module.get<FavoritesRepository>(FavoritesRepository);
});
it("should find content by id", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
const result = await repository.findContentById("1");
expect(result.id).toBe("1");
});
it("should add favorite", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([
{ userId: "u", contentId: "c" },
]);
await repository.add("u", "c");
expect(mockDb.insert).toHaveBeenCalled();
});
it("should remove favorite", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
await repository.remove("u", "c");
expect(mockDb.delete).toHaveBeenCalled();
});
it("should find by user id", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ content: { id: "c1" } }]);
const result = await repository.findByUserId("u1", 10, 0);
expect(result).toHaveLength(1);
expect(result[0].id).toBe("c1");
});
});

View File

@@ -1,4 +1,3 @@
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "./database/database.service"; import { DatabaseService } from "./database/database.service";
import { HealthController } from "./health.controller"; import { HealthController } from "./health.controller";
@@ -10,10 +9,6 @@ describe("HealthController", () => {
execute: jest.fn().mockResolvedValue([]), execute: jest.fn().mockResolvedValue([]),
}; };
const mockCacheManager = {
set: jest.fn().mockResolvedValue(undefined),
};
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
controllers: [HealthController], controllers: [HealthController],
@@ -24,42 +19,24 @@ describe("HealthController", () => {
db: mockDb, db: mockDb,
}, },
}, },
{
provide: CACHE_MANAGER,
useValue: mockCacheManager,
},
], ],
}).compile(); }).compile();
controller = module.get<HealthController>(HealthController); controller = module.get<HealthController>(HealthController);
}); });
it("should return ok if database and redis are connected", async () => { it("should return ok if database is connected", async () => {
mockDb.execute.mockResolvedValue([]); mockDb.execute.mockResolvedValue([]);
mockCacheManager.set.mockResolvedValue(undefined);
const result = await controller.check(); const result = await controller.check();
expect(result.status).toBe("ok"); expect(result.status).toBe("ok");
expect(result.database).toBe("connected"); expect(result.database).toBe("connected");
expect(result.redis).toBe("connected");
}); });
it("should return error if database is disconnected", async () => { it("should return error if database is disconnected", async () => {
mockDb.execute.mockRejectedValue(new Error("DB Error")); mockDb.execute.mockRejectedValue(new Error("DB Error"));
mockCacheManager.set.mockResolvedValue(undefined);
const result = await controller.check(); const result = await controller.check();
expect(result.status).toBe("error"); expect(result.status).toBe("error");
expect(result.database).toBe("disconnected"); expect(result.database).toBe("disconnected");
expect(result.databaseError).toBe("DB Error"); expect(result.message).toBe("DB Error");
expect(result.redis).toBe("connected");
});
it("should return error if redis is disconnected", async () => {
mockDb.execute.mockResolvedValue([]);
mockCacheManager.set.mockRejectedValue(new Error("Redis Error"));
const result = await controller.check();
expect(result.status).toBe("error");
expect(result.database).toBe("connected");
expect(result.redis).toBe("disconnected");
expect(result.redisError).toBe("Redis Error");
}); });
}); });

View File

@@ -1,44 +1,28 @@
import { CACHE_MANAGER } from "@nestjs/cache-manager"; import { Controller, Get } from "@nestjs/common";
import { Controller, Get, Inject } from "@nestjs/common";
import type { Cache } from "cache-manager";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { DatabaseService } from "./database/database.service"; import { DatabaseService } from "./database/database.service";
@Controller("health") @Controller("health")
export class HealthController { export class HealthController {
constructor( constructor(private readonly databaseService: DatabaseService) {}
private readonly databaseService: DatabaseService,
@Inject(CACHE_MANAGER) private cacheManager: Cache,
) {}
@Get() @Get()
async check() { async check() {
const health: any = {
status: "ok",
timestamp: new Date().toISOString(),
};
try { try {
// Check database connection // Check database connection
await this.databaseService.db.execute(sql`SELECT 1`); await this.databaseService.db.execute(sql`SELECT 1`);
health.database = "connected"; return {
status: "ok",
database: "connected",
timestamp: new Date().toISOString(),
};
} catch (error) { } catch (error) {
health.status = "error"; return {
health.database = "disconnected"; status: "error",
health.databaseError = error.message; database: "disconnected",
message: error.message,
timestamp: new Date().toISOString(),
};
} }
try {
// Check Redis connection via cache-manager
// We try to set a temporary key to verify the connection
await this.cacheManager.set("health-check", "ok", 1000);
health.redis = "connected";
} catch (error) {
health.status = "error";
health.redis = "disconnected";
health.redisError = error.message;
}
return health;
} }
} }

View File

@@ -1,66 +0,0 @@
import { Readable } from "node:stream";
import { NotFoundException } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import type { Response } from "express";
import { S3Service } from "../s3/s3.service";
import { MediaController } from "./media.controller";
describe("MediaController", () => {
let controller: MediaController;
const mockS3Service = {
getFileInfo: jest.fn(),
getFile: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [MediaController],
providers: [{ provide: S3Service, useValue: mockS3Service }],
}).compile();
controller = module.get<MediaController>(MediaController);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("getFile", () => {
it("should stream the file and set headers with path containing slashes", async () => {
const res = {
setHeader: jest.fn(),
} as unknown as Response;
const stream = new Readable();
stream.pipe = jest.fn();
const key = "contents/user-id/test.webp";
mockS3Service.getFileInfo.mockResolvedValue({
size: 100,
metaData: { "content-type": "image/webp" },
});
mockS3Service.getFile.mockResolvedValue(stream);
await controller.getFile(key, res);
expect(mockS3Service.getFileInfo).toHaveBeenCalledWith(key);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/webp");
expect(res.setHeader).toHaveBeenCalledWith("Content-Length", 100);
expect(stream.pipe).toHaveBeenCalledWith(res);
});
it("should throw NotFoundException if path is missing", async () => {
const res = {} as unknown as Response;
await expect(controller.getFile("", res)).rejects.toThrow(NotFoundException);
});
it("should throw NotFoundException if file is not found", async () => {
mockS3Service.getFileInfo.mockRejectedValue(new Error("Not found"));
const res = {} as unknown as Response;
await expect(controller.getFile("invalid", res)).rejects.toThrow(
NotFoundException,
);
});
});
});

View File

@@ -1,48 +0,0 @@
import {
Controller,
Get,
Logger,
NotFoundException,
Query,
Res,
} from "@nestjs/common";
import type { Response } from "express";
import type { BucketItemStat } from "minio";
import { S3Service } from "../s3/s3.service";
@Controller("media")
export class MediaController {
private readonly logger = new Logger(MediaController.name);
constructor(private readonly s3Service: S3Service) {}
@Get()
async getFile(@Query("path") path: string, @Res() res: Response) {
if (!path) {
this.logger.warn("Tentative d'accès à un média sans paramètre 'path'");
throw new NotFoundException("Paramètre 'path' manquant");
}
try {
this.logger.log(`Récupération du fichier : ${path}`);
const stats = (await this.s3Service.getFileInfo(path)) as BucketItemStat;
const stream = await this.s3Service.getFile(path);
const contentType: string =
stats.metaData?.["content-type"] ||
stats.metaData?.["Content-Type"] ||
"application/octet-stream";
res.setHeader("Content-Type", contentType);
res.setHeader("Content-Length", stats.size);
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
stream.pipe(res);
} catch (error) {
this.logger.error(
`Erreur lors de la récupération du fichier ${path} : ${error.message}`,
);
throw new NotFoundException("Fichier non trouvé");
}
}
}

View File

@@ -1,13 +1,9 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { S3Module } from "../s3/s3.module";
import { MediaController } from "./media.controller";
import { MediaService } from "./media.service"; import { MediaService } from "./media.service";
import { ImageProcessorStrategy } from "./strategies/image-processor.strategy"; import { ImageProcessorStrategy } from "./strategies/image-processor.strategy";
import { VideoProcessorStrategy } from "./strategies/video-processor.strategy"; import { VideoProcessorStrategy } from "./strategies/video-processor.strategy";
@Module({ @Module({
imports: [S3Module],
controllers: [MediaController],
providers: [MediaService, ImageProcessorStrategy, VideoProcessorStrategy], providers: [MediaService, ImageProcessorStrategy, VideoProcessorStrategy],
exports: [MediaService], exports: [MediaService],
}) })

View File

@@ -96,37 +96,4 @@ describe("MediaService", () => {
expect(result.buffer).toEqual(Buffer.from("processed-video")); expect(result.buffer).toEqual(Buffer.from("processed-video"));
}); });
}); });
describe("scanFile", () => {
it("should return false if clamav not initialized", async () => {
const result = await service.scanFile(Buffer.from(""), "test.txt");
expect(result.isInfected).toBe(false);
});
it("should handle virus detection", async () => {
// Mock private property to simulate initialized clamscan
(service as any).isClamAvInitialized = true;
(service as any).clamscan = {
scanStream: jest.fn().mockResolvedValue({
isInfected: true,
viruses: ["Eicar-Test-Signature"],
}),
};
const result = await service.scanFile(Buffer.from(""), "test.txt");
expect(result.isInfected).toBe(true);
expect(result.virusName).toBe("Eicar-Test-Signature");
});
it("should handle scan error", async () => {
(service as any).isClamAvInitialized = true;
(service as any).clamscan = {
scanStream: jest.fn().mockRejectedValue(new Error("Scan failed")),
};
await expect(
service.scanFile(Buffer.from(""), "test.txt"),
).rejects.toThrow();
});
});
}); });

View File

@@ -1,82 +0,0 @@
jest.mock("uuid", () => ({
v4: jest.fn(() => "mocked-uuid"),
}));
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
ml_kem768: {
keygen: jest.fn(),
encapsulate: jest.fn(),
decapsulate: jest.fn(),
},
}));
jest.mock("jose", () => ({
SignJWT: jest.fn().mockReturnValue({
setProtectedHeader: jest.fn().mockReturnThis(),
setIssuedAt: jest.fn().mockReturnThis(),
setExpirationTime: jest.fn().mockReturnThis(),
sign: jest.fn().mockResolvedValue("mocked-jwt"),
}),
jwtVerify: jest.fn(),
}));
import { Test, TestingModule } from "@nestjs/testing";
import { AuthGuard } from "../auth/guards/auth.guard";
import { RolesGuard } from "../auth/guards/roles.guard";
import { AuthenticatedRequest } from "../common/interfaces/request.interface";
import { ReportsController } from "./reports.controller";
import { ReportsService } from "./reports.service";
describe("ReportsController", () => {
let controller: ReportsController;
let service: ReportsService;
const mockReportsService = {
create: jest.fn(),
findAll: jest.fn(),
updateStatus: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ReportsController],
providers: [{ provide: ReportsService, useValue: mockReportsService }],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<ReportsController>(ReportsController);
service = module.get<ReportsService>(ReportsService);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("create", () => {
it("should call service.create", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
const dto = { contentId: "1", reason: "spam" };
await controller.create(req, dto as any);
expect(service.create).toHaveBeenCalledWith("user-uuid", dto);
});
});
describe("findAll", () => {
it("should call service.findAll", async () => {
await controller.findAll(10, 0);
expect(service.findAll).toHaveBeenCalledWith(10, 0);
});
});
describe("updateStatus", () => {
it("should call service.updateStatus", async () => {
const dto = { status: "resolved" as any };
await controller.updateStatus("1", dto);
expect(service.updateStatus).toHaveBeenCalledWith("1", "resolved");
});
});
});

View File

@@ -1,73 +0,0 @@
import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "../../database/database.service";
import { ReportsRepository } from "./reports.repository";
describe("ReportsRepository", () => {
let repository: ReportsRepository;
const mockDb = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
offset: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
returning: jest.fn().mockReturnThis(),
execute: jest.fn(),
};
const wrapWithThen = (obj: unknown) => {
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
Object.defineProperty(obj, "then", {
value: function (onFulfilled: (arg0: unknown) => void) {
const result = (this as Record<string, unknown>).execute();
return Promise.resolve(result).then(onFulfilled);
},
configurable: true,
});
return obj;
};
wrapWithThen(mockDb);
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ReportsRepository,
{ provide: DatabaseService, useValue: { db: mockDb } },
],
}).compile();
repository = module.get<ReportsRepository>(ReportsRepository);
});
it("should create report", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
const result = await repository.create({ reporterId: "u", reason: "spam" });
expect(result.id).toBe("1");
});
it("should find all", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
const result = await repository.findAll(10, 0);
expect(result).toHaveLength(1);
});
it("should update status", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([
{ id: "1", status: "resolved" },
]);
const result = await repository.updateStatus("1", "resolved");
expect(result[0].status).toBe("resolved");
});
it("should purge obsolete", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
await repository.purgeObsolete(new Date());
expect(mockDb.delete).toHaveBeenCalled();
});
});

View File

@@ -7,7 +7,8 @@ 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
let minioClient: any; let minioClient: any;
beforeEach(async () => { beforeEach(async () => {
@@ -41,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", () => {
@@ -184,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?path=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?path=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?path=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}`,
@@ -157,22 +155,4 @@ export class S3Service implements OnModuleInit, IStorageService {
throw error; throw error;
} }
} }
getPublicUrl(storageKey: string): string {
const apiUrl = this.configService.get<string>("API_URL");
const domain = this.configService.get<string>("DOMAIN_NAME", "localhost");
const port = this.configService.get<number>("PORT", 3000);
let baseUrl: string;
if (apiUrl) {
baseUrl = apiUrl.replace(/\/$/, "");
} else if (domain === "localhost" || domain === "127.0.0.1") {
baseUrl = `http://${domain}:${port}`;
} else {
baseUrl = `https://api.${domain}`;
}
return `${baseUrl}/media?path=${storageKey}`;
}
} }

View File

@@ -1,69 +0,0 @@
jest.mock("uuid", () => ({
v4: jest.fn(() => "mocked-uuid"),
}));
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
ml_kem768: {
keygen: jest.fn(),
encapsulate: jest.fn(),
decapsulate: jest.fn(),
},
}));
jest.mock("jose", () => ({
SignJWT: jest.fn().mockReturnValue({
setProtectedHeader: jest.fn().mockReturnThis(),
setIssuedAt: jest.fn().mockReturnThis(),
setExpirationTime: jest.fn().mockReturnThis(),
sign: jest.fn().mockResolvedValue("mocked-jwt"),
}),
jwtVerify: jest.fn(),
}));
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Test, TestingModule } from "@nestjs/testing";
import { TagsController } from "./tags.controller";
import { TagsService } from "./tags.service";
describe("TagsController", () => {
let controller: TagsController;
let service: TagsService;
const mockTagsService = {
findAll: jest.fn(),
};
const mockCacheManager = {
get: jest.fn(),
set: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [TagsController],
providers: [
{ provide: TagsService, useValue: mockTagsService },
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
],
}).compile();
controller = module.get<TagsController>(TagsController);
service = module.get<TagsService>(TagsService);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("findAll", () => {
it("should call service.findAll", async () => {
await controller.findAll(10, 0, "test", "popular");
expect(service.findAll).toHaveBeenCalledWith({
limit: 10,
offset: 0,
query: "test",
sortBy: "popular",
});
});
});
});

View File

@@ -1,150 +0,0 @@
import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "../../database/database.service";
import { UsersRepository } from "./users.repository";
describe("UsersRepository", () => {
let repository: UsersRepository;
let _databaseService: DatabaseService;
const mockDb = {
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
returning: jest.fn().mockResolvedValue([{ uuid: "u1" }]),
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
offset: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
transaction: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersRepository,
{
provide: DatabaseService,
useValue: { db: mockDb },
},
],
}).compile();
repository = module.get<UsersRepository>(UsersRepository);
_databaseService = module.get<DatabaseService>(DatabaseService);
jest.clearAllMocks();
});
it("should be defined", () => {
expect(repository).toBeDefined();
});
describe("create", () => {
it("should insert a user", async () => {
const data = {
username: "u",
email: "e",
passwordHash: "p",
emailHash: "eh",
};
await repository.create(data);
expect(mockDb.insert).toHaveBeenCalled();
expect(mockDb.values).toHaveBeenCalledWith(data);
});
});
describe("findByEmailHash", () => {
it("should select user by email hash", async () => {
mockDb.limit.mockResolvedValueOnce([{ uuid: "u1" }]);
const result = await repository.findByEmailHash("hash");
expect(result.uuid).toBe("u1");
expect(mockDb.select).toHaveBeenCalled();
expect(mockDb.where).toHaveBeenCalled();
});
});
describe("findOneWithPrivateData", () => {
it("should select user with private data", async () => {
mockDb.limit.mockResolvedValueOnce([{ uuid: "u1" }]);
const result = await repository.findOneWithPrivateData("u1");
expect(result.uuid).toBe("u1");
});
});
describe("countAll", () => {
it("should return count", async () => {
mockDb.from.mockResolvedValueOnce([{ count: 5 }]);
const result = await repository.countAll();
expect(result).toBe(5);
});
});
describe("findAll", () => {
it("should select users with limit and offset", async () => {
mockDb.offset.mockResolvedValueOnce([{ uuid: "u1" }]);
const result = await repository.findAll(10, 0);
expect(result[0].uuid).toBe("u1");
expect(mockDb.limit).toHaveBeenCalledWith(10);
expect(mockDb.offset).toHaveBeenCalledWith(0);
});
});
describe("findByUsername", () => {
it("should find by username", async () => {
mockDb.limit.mockResolvedValueOnce([{ uuid: "u1" }]);
const result = await repository.findByUsername("u");
expect(result.uuid).toBe("u1");
});
});
describe("update", () => {
it("should update user", async () => {
mockDb.returning.mockResolvedValueOnce([{ uuid: "u1" }]);
await repository.update("u1", { displayName: "New" });
expect(mockDb.update).toHaveBeenCalled();
expect(mockDb.set).toHaveBeenCalled();
});
});
describe("getTwoFactorSecret", () => {
it("should return secret", async () => {
mockDb.limit.mockResolvedValueOnce([{ secret: "s" }]);
const result = await repository.getTwoFactorSecret("u1");
expect(result).toBe("s");
});
});
describe("getUserContents", () => {
it("should return contents", async () => {
mockDb.where.mockResolvedValueOnce([{ id: "c1" }]);
const result = await repository.getUserContents("u1");
expect(result[0].id).toBe("c1");
});
});
describe("softDeleteUserAndContents", () => {
it("should run transaction", async () => {
const mockTx = {
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
returning: jest.fn().mockResolvedValue([{ uuid: "u1" }]),
};
mockDb.transaction.mockImplementation(async (cb) => cb(mockTx));
const result = await repository.softDeleteUserAndContents("u1");
expect(result[0].uuid).toBe("u1");
expect(mockTx.update).toHaveBeenCalledTimes(2);
});
});
describe("purgeDeleted", () => {
it("should delete old deleted users", async () => {
mockDb.returning.mockResolvedValueOnce([{ uuid: "u1" }]);
const _result = await repository.purgeDeleted(new Date());
expect(mockDb.delete).toHaveBeenCalled();
});
});
});

View File

@@ -1,192 +0,0 @@
jest.mock("uuid", () => ({
v4: jest.fn(() => "mocked-uuid"),
}));
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
ml_kem768: {
keygen: jest.fn(),
encapsulate: jest.fn(),
decapsulate: jest.fn(),
},
}));
jest.mock("jose", () => ({
SignJWT: jest.fn().mockReturnValue({
setProtectedHeader: jest.fn().mockReturnThis(),
setIssuedAt: jest.fn().mockReturnThis(),
setExpirationTime: jest.fn().mockReturnThis(),
sign: jest.fn().mockResolvedValue("mocked-jwt"),
}),
jwtVerify: jest.fn(),
}));
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Test, TestingModule } from "@nestjs/testing";
import { AuthService } from "../auth/auth.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { RolesGuard } from "../auth/guards/roles.guard";
import { AuthenticatedRequest } from "../common/interfaces/request.interface";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";
describe("UsersController", () => {
let controller: UsersController;
let usersService: UsersService;
let authService: AuthService;
const mockUsersService = {
findAll: jest.fn(),
findPublicProfile: jest.fn(),
findOneWithPrivateData: jest.fn(),
exportUserData: jest.fn(),
update: jest.fn(),
updateAvatar: jest.fn(),
updateConsent: jest.fn(),
remove: jest.fn(),
};
const mockAuthService = {
generateTwoFactorSecret: jest.fn(),
enableTwoFactor: jest.fn(),
disableTwoFactor: jest.fn(),
};
const mockCacheManager = {
get: jest.fn(),
set: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [UsersController],
providers: [
{ provide: UsersService, useValue: mockUsersService },
{ provide: AuthService, useValue: mockAuthService },
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<UsersController>(UsersController);
usersService = module.get<UsersService>(UsersService);
authService = module.get<AuthService>(AuthService);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("findAll", () => {
it("should call usersService.findAll", async () => {
await controller.findAll(10, 0);
expect(usersService.findAll).toHaveBeenCalledWith(10, 0);
});
});
describe("findPublicProfile", () => {
it("should call usersService.findPublicProfile", async () => {
await controller.findPublicProfile("testuser");
expect(usersService.findPublicProfile).toHaveBeenCalledWith("testuser");
});
});
describe("findMe", () => {
it("should call usersService.findOneWithPrivateData", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.findMe(req);
expect(usersService.findOneWithPrivateData).toHaveBeenCalledWith(
"user-uuid",
);
});
});
describe("exportMe", () => {
it("should call usersService.exportUserData", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.exportMe(req);
expect(usersService.exportUserData).toHaveBeenCalledWith("user-uuid");
});
});
describe("updateMe", () => {
it("should call usersService.update", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
const dto = { displayName: "New Name" };
await controller.updateMe(req, dto);
expect(usersService.update).toHaveBeenCalledWith("user-uuid", dto);
});
});
describe("updateAvatar", () => {
it("should call usersService.updateAvatar", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
const file = {} as Express.Multer.File;
await controller.updateAvatar(req, file);
expect(usersService.updateAvatar).toHaveBeenCalledWith("user-uuid", file);
});
});
describe("updateConsent", () => {
it("should call usersService.updateConsent", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
const dto = { termsVersion: "1.0", privacyVersion: "1.0" };
await controller.updateConsent(req, dto);
expect(usersService.updateConsent).toHaveBeenCalledWith(
"user-uuid",
"1.0",
"1.0",
);
});
});
describe("removeMe", () => {
it("should call usersService.remove", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.removeMe(req);
expect(usersService.remove).toHaveBeenCalledWith("user-uuid");
});
});
describe("removeAdmin", () => {
it("should call usersService.remove", async () => {
await controller.removeAdmin("target-uuid");
expect(usersService.remove).toHaveBeenCalledWith("target-uuid");
});
});
describe("setup2fa", () => {
it("should call authService.generateTwoFactorSecret", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.setup2fa(req);
expect(authService.generateTwoFactorSecret).toHaveBeenCalledWith(
"user-uuid",
);
});
});
describe("enable2fa", () => {
it("should call authService.enableTwoFactor", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.enable2fa(req, "token123");
expect(authService.enableTwoFactor).toHaveBeenCalledWith(
"user-uuid",
"token123",
);
});
});
describe("disable2fa", () => {
it("should call authService.disableTwoFactor", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.disable2fa(req, "token123");
expect(authService.disableTwoFactor).toHaveBeenCalledWith(
"user-uuid",
"token123",
);
});
});
});

View File

@@ -58,7 +58,6 @@ describe("UsersService", () => {
const mockS3Service = { const mockS3Service = {
uploadFile: jest.fn(), uploadFile: jest.fn(),
getPublicUrl: jest.fn(),
}; };
const mockConfigService = { const mockConfigService = {
@@ -128,112 +127,4 @@ describe("UsersService", () => {
expect(result[0].displayName).toBe("New"); expect(result[0].displayName).toBe("New");
}); });
}); });
describe("clearUserCache", () => {
it("should delete cache", async () => {
await service.clearUserCache("u1");
expect(mockCacheManager.del).toHaveBeenCalledWith("users/profile/u1");
});
});
describe("findByEmailHash", () => {
it("should call repository.findByEmailHash", async () => {
mockUsersRepository.findByEmailHash.mockResolvedValue({ uuid: "u1" });
const result = await service.findByEmailHash("hash");
expect(result.uuid).toBe("u1");
expect(mockUsersRepository.findByEmailHash).toHaveBeenCalledWith("hash");
});
});
describe("findOneWithPrivateData", () => {
it("should return user with roles", async () => {
mockUsersRepository.findOneWithPrivateData.mockResolvedValue({ uuid: "u1" });
mockRbacService.getUserRoles.mockResolvedValue(["admin"]);
const result = await service.findOneWithPrivateData("u1");
expect(result.roles).toEqual(["admin"]);
});
});
describe("findAll", () => {
it("should return all users", async () => {
mockUsersRepository.findAll.mockResolvedValue([{ uuid: "u1" }]);
mockUsersRepository.countAll.mockResolvedValue(1);
const result = await service.findAll(10, 0);
expect(result.totalCount).toBe(1);
expect(result.data[0].uuid).toBe("u1");
});
});
describe("findPublicProfile", () => {
it("should return public profile", async () => {
mockUsersRepository.findByUsername.mockResolvedValue({
uuid: "u1",
username: "u1",
});
const result = await service.findPublicProfile("u1");
expect(result.username).toBe("u1");
});
});
describe("updateConsent", () => {
it("should update consent", async () => {
await service.updateConsent("u1", "v1", "v2");
expect(mockUsersRepository.update).toHaveBeenCalledWith("u1", {
termsVersion: "v1",
privacyVersion: "v2",
gdprAcceptedAt: expect.any(Date),
});
});
});
describe("setTwoFactorSecret", () => {
it("should set 2fa secret", async () => {
await service.setTwoFactorSecret("u1", "secret");
expect(mockUsersRepository.update).toHaveBeenCalledWith("u1", {
twoFactorSecret: "secret",
});
});
});
describe("toggleTwoFactor", () => {
it("should toggle 2fa", async () => {
await service.toggleTwoFactor("u1", true);
expect(mockUsersRepository.update).toHaveBeenCalledWith("u1", {
isTwoFactorEnabled: true,
});
});
});
describe("getTwoFactorSecret", () => {
it("should return 2fa secret", async () => {
mockUsersRepository.getTwoFactorSecret.mockResolvedValue("secret");
const result = await service.getTwoFactorSecret("u1");
expect(result).toBe("secret");
});
});
describe("exportUserData", () => {
it("should return all user data", async () => {
mockUsersRepository.findOneWithPrivateData.mockResolvedValue({ uuid: "u1" });
mockUsersRepository.getUserContents.mockResolvedValue([]);
mockUsersRepository.getUserFavorites.mockResolvedValue([]);
const result = await service.exportUserData("u1");
expect(result.profile).toBeDefined();
expect(result.contents).toBeDefined();
expect(result.favorites).toBeDefined();
});
});
describe("remove", () => {
it("should soft delete user", async () => {
await service.remove("u1");
expect(mockUsersRepository.softDeleteUserAndContents).toHaveBeenCalledWith(
"u1",
);
});
});
}); });

View File

@@ -6,6 +6,7 @@ import {
Injectable, Injectable,
Logger, Logger,
} from "@nestjs/common"; } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import type { Cache } from "cache-manager"; import type { Cache } from "cache-manager";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { RbacService } from "../auth/rbac.service"; import { RbacService } from "../auth/rbac.service";
@@ -27,6 +28,7 @@ export class UsersService {
private readonly rbacService: RbacService, private readonly rbacService: RbacService,
@Inject(MediaService) private readonly mediaService: IMediaService, @Inject(MediaService) private readonly mediaService: IMediaService,
@Inject(S3Service) private readonly s3Service: IStorageService, @Inject(S3Service) private readonly s3Service: IStorageService,
private readonly configService: ConfigService,
) {} ) {}
private async clearUserCache(username?: string) { private async clearUserCache(username?: string) {
@@ -58,9 +60,7 @@ export class UsersService {
return { return {
...user, ...user,
avatarUrl: user.avatarUrl avatarUrl: user.avatarUrl ? this.getFileUrl(user.avatarUrl) : null,
? this.s3Service.getPublicUrl(user.avatarUrl)
: null,
role: roles.includes("admin") ? "admin" : "user", role: roles.includes("admin") ? "admin" : "user",
roles, roles,
}; };
@@ -74,9 +74,7 @@ export class UsersService {
const processedData = data.map((user) => ({ const processedData = data.map((user) => ({
...user, ...user,
avatarUrl: user.avatarUrl avatarUrl: user.avatarUrl ? this.getFileUrl(user.avatarUrl) : null,
? this.s3Service.getPublicUrl(user.avatarUrl)
: null,
})); }));
return { data: processedData, totalCount }; return { data: processedData, totalCount };
@@ -88,9 +86,7 @@ export class UsersService {
return { return {
...user, ...user,
avatarUrl: user.avatarUrl avatarUrl: user.avatarUrl ? this.getFileUrl(user.avatarUrl) : null,
? this.s3Service.getPublicUrl(user.avatarUrl)
: null,
}; };
} }
@@ -143,7 +139,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 });
@@ -198,4 +193,17 @@ export class UsersService {
async remove(uuid: string) { async remove(uuid: string) {
return await this.usersRepository.softDeleteUserAndContents(uuid); return await this.usersRepository.softDeleteUserAndContents(uuid);
} }
private getFileUrl(storageKey: string): string {
const endpoint = this.configService.get("S3_ENDPOINT");
const port = this.configService.get("S3_PORT");
const protocol =
this.configService.get("S3_USE_SSL") === true ? "https" : "http";
const bucket = this.configService.get("S3_BUCKET_NAME");
if (endpoint === "localhost" || endpoint === "127.0.0.1") {
return `${protocol}://${endpoint}:${port}/${bucket}/${storageKey}`;
}
return `${protocol}://${endpoint}/${bucket}/${storageKey}`;
}
} }

View File

@@ -9,8 +9,6 @@ services:
POSTGRES_DB: ${POSTGRES_DB:-app} POSTGRES_DB: ${POSTGRES_DB:-app}
networks: networks:
- nw_memegoat - nw_memegoat
ports:
- "127.0.0.1:5432:5432" # not exposed to WAN, LAN only for administration checkup
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
healthcheck: healthcheck:
@@ -37,7 +35,6 @@ services:
restart: always restart: always
networks: networks:
- nw_memegoat - nw_memegoat
- nw_caddy
#ports: #ports:
# - "9000:9000" # - "9000:9000"
# - "9001:9001" # - "9001:9001"

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

@@ -18,21 +18,15 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
Inscrit un nouvel utilisateur. Inscrit un nouvel utilisateur.
**Corps de la requête (JSON) :** **Corps de la requête (JSON) :**
- `username` (string, max: 32) : Nom d'utilisateur unique. - `username` (string) : Nom d'utilisateur unique.
- `email` (string) : Adresse email valide. - `email` (string) : Adresse email valide.
- `password` (string, min: 8) : Mot de passe. - `password` (string) : Mot de passe (min. 8 caractères).
- `displayName` (string, optional, max: 32) : Nom d'affichage.
**Réponses :**
- `201 Created` : Utilisateur créé.
- `400 Bad Request` : Validation échouée ou utilisateur déjà existant.
```json ```json
{ {
"username": "goat_user", "username": "goat_user",
"email": "user@memegoat.fr", "email": "user@memegoat.fr",
"password": "strong-password", "password": "strong-password"
"displayName": "Le Bouc"
} }
``` ```
</Accordion> </Accordion>
@@ -44,25 +38,23 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
- `email` (string) - `email` (string)
- `password` (string) - `password` (string)
**Réponses :** **Réponse (Succès) :**
- `200 OK` : Connexion réussie. ```json
```json {
{ "message": "User logged in successfully",
"message": "User logged in successfully", "userId": "uuid-v4"
"userId": "uuid-v4" }
} ```
```
- `200 OK` (2FA requise) :
```json
{
"message": "2FA required",
"requires2FA": true,
"userId": "uuid-v4"
}
```
- `401 Unauthorized` : Identifiants invalides.
*Note: L'access_token et le refresh_token sont stockés dans un cookie HttpOnly chiffré.* *Note: L'access_token et le refresh_token sont stockés dans un cookie HttpOnly chiffré.*
**Réponse (2FA requise) :**
```json
{
"message": "2FA required",
"requires2FA": true,
"userId": "uuid-v4"
}
```
</Accordion> </Accordion>
<Accordion title="POST /auth/verify-2fa"> <Accordion title="POST /auth/verify-2fa">
@@ -71,41 +63,15 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
**Corps de la requête :** **Corps de la requête :**
- `userId` (uuid) : ID de l'utilisateur. - `userId` (uuid) : ID de l'utilisateur.
- `token` (string) : Code TOTP à 6 chiffres. - `token` (string) : Code TOTP à 6 chiffres.
**Réponses :**
- `200 OK` : Vérification réussie, session établie.
- `401 Unauthorized` : Token invalide ou utilisateur non autorisé.
</Accordion> </Accordion>
<Accordion title="POST /auth/refresh"> <Accordion title="POST /auth/refresh">
Obtient un nouvel `access_token` à partir du `refresh_token` stocké dans la session. Obtient un nouvel `access_token` à partir du `refresh_token` stocké dans la session.
Met à jour automatiquement le cookie de session. Met à jour automatiquement le cookie de session.
**Réponses :**
- `200 OK` : Token rafraîchi.
- `401 Unauthorized` : Refresh token absent ou invalide.
</Accordion> </Accordion>
<Accordion title="POST /auth/logout"> <Accordion title="POST /auth/logout">
Invalide la session actuelle en détruisant le cookie de session. Invalide la session actuelle.
**Réponses :**
- `200 OK` : Déconnexion réussie.
</Accordion>
<Accordion title="GET /auth/bootstrap-admin">
Élève les privilèges d'un utilisateur au rang d'administrateur.
<Callout type="warn">
Cette route n'est active que si aucun administrateur n'existe en base de données. Le token est affiché dans les logs de la console au démarrage.
</Callout>
**Query Params :**
- `token` (string) : Token à usage unique généré par le système.
- `username` (string) : Nom de l'utilisateur à promouvoir.
**Réponses :**
- `200 OK` : Utilisateur promu.
- `401 Unauthorized` : Token invalide ou utilisateur non trouvé.
</Accordion> </Accordion>
</Accordions> </Accordions>
@@ -114,62 +80,16 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
<Accordions> <Accordions>
<Accordion title="GET /users/me"> <Accordion title="GET /users/me">
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.
**Réponses :**
- `200 OK` : Retourne l'objet utilisateur complet (incluant données privées).
- `401 Unauthorized` : Session invalide.
</Accordion>
<Accordion title="GET /users/public/:username">
Récupère le profil public d'un utilisateur par son nom d'utilisateur. Mise en cache pendant 1 minute.
**Réponses :**
- `200 OK` : Profil public (id, username, displayName, bio, avatarUrl, createdAt).
- `404 Not Found` : Utilisateur non trouvé.
</Accordion> </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 créés et les favoris. Contient le profil, les contenus et les favoris.
**Réponses :**
- `200 OK` : Archive JSON des données.
- `401 Unauthorized` : Non authentifié.
</Accordion> </Accordion>
<Accordion title="PATCH /users/me"> <Accordion title="PATCH /users/me">
Met à jour les informations du profil. Met à jour les informations du profil.
- `displayName` (string)
**Corps de la requête :**
- `displayName` (string, optional, max: 32)
- `bio` (string, optional, max: 255)
- `avatarUrl` (string, optional) : URL directe de l'avatar.
**Réponses :**
- `200 OK` : Profil mis à jour.
- `400 Bad Request` : Validation échouée.
</Accordion>
<Accordion title="POST /users/me/avatar">
Met à jour l'avatar de l'utilisateur via upload de fichier.
**Type :** `multipart/form-data`
**Champ :** `file` (Image: png, jpeg, webp)
**Réponses :**
- `201 Created` : Avatar téléchargé et mis à jour.
- `400 Bad Request` : Fichier invalide ou trop volumineux.
</Accordion>
<Accordion title="PATCH /users/me/consent">
Met à jour les consentements légaux de l'utilisateur (CGU/RGPD).
**Corps de la requête :**
- `termsVersion` (string, max: 16)
- `privacyVersion` (string, max: 16)
**Réponses :**
- `200 OK` : Consentements enregistrés.
</Accordion> </Accordion>
<Accordion title="DELETE /users/me"> <Accordion title="DELETE /users/me">
@@ -177,388 +97,95 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
<Callout type="warn"> <Callout type="warn">
Les données sont définitivement purgées après un délai légal de 30 jours. Les données sont définitivement purgées après un délai légal de 30 jours.
</Callout> </Callout>
**Réponses :**
- `200 OK` : Suppression planifiée.
</Accordion> </Accordion>
<Accordion title="POST /users/me/2fa/setup"> <Accordion title="Gestion 2FA">
Génère un secret et un QR Code pour la configuration de la 2FA. - `POST /users/me/2fa/setup` : Génère un secret et QR Code.
- `POST /users/me/2fa/enable` : Active après vérification du jeton.
**Réponses :** - `POST /users/me/2fa/disable` : Désactive avec jeton.
- `201 Created` :
```json
{
"secret": "JBSWY3DPEHPK3PXP",
"qrCodeDataUrl": "data:image/png;base64,..."
}
```
</Accordion> </Accordion>
<Accordion title="POST /users/me/2fa/enable"> <Accordion title="Administration (GET /users/admin)">
Active la 2FA après vérification du jeton TOTP. Liste tous les utilisateurs. Réservé aux administrateurs.
**Params :** `limit`, `offset`.
**Corps de la requête :**
- `token` (string) : Code TOTP généré par l'app.
**Réponses :**
- `200 OK` : 2FA activée.
- `400 Bad Request` : Token invalide ou 2FA non initiée.
</Accordion>
<Accordion title="POST /users/me/2fa/disable">
Désactive la 2FA en utilisant un jeton TOTP valide.
**Corps de la requête :**
- `token` (string) : Code TOTP.
**Réponses :**
- `200 OK` : 2FA désactivée.
</Accordion>
<Accordion title="GET /users/admin">
Liste tous les utilisateurs. **Réservé aux administrateurs.**
**Query Params :**
- `limit` (number) : Défaut 10.
- `offset` (number) : Défaut 0.
**Réponses :**
- `200 OK` : Liste paginée des utilisateurs.
- `403 Forbidden` : Droits insuffisants.
</Accordion>
<Accordion title="DELETE /users/:uuid">
Supprime définitivement un utilisateur par son UUID. **Réservé aux administrateurs.**
**Réponses :**
- `200 OK` : Utilisateur supprimé.
</Accordion> </Accordion>
</Accordions> </Accordions>
### 🖼️ Contenus (`/contents`) ### 🖼️ Contenus (`/contents`)
<Accordions> <Accordions>
<Accordion title="GET /contents/explore"> <Accordion title="GET /contents/explore | /trends | /recent">
Recherche et filtre les contenus. Cet endpoint est mis en cache pendant 1 minute. Recherche et filtre les contenus. Ces endpoints sont mis en cache (Redis + Navigateur).
**Query Params :** **Query Params :**
- `limit` (number) : Défaut 10. - `sort` : `trend` | `recent` (uniquement sur `/explore`)
- `offset` (number) : Défaut 0. - `tag` (string)
- `sort` : `trend` | `recent` - `category` (slug ou id)
- `tag` (string) : Filtrer par tag (nom). - `author` (username)
- `category` (slug ou uuid) : Filtrer par catégorie. - `query` (titre)
- `author` (username) : Filtrer par auteur. - `favoritesOnly` (bool)
- `query` (string) : Recherche textuelle dans le titre.
- `favoritesOnly` (boolean) : Ne montrer que les favoris de l'utilisateur (nécessite auth).
- `userId` (uuid) : Filtrer par ID utilisateur.
**Réponses :**
- `200 OK` : Liste paginée des contenus.
</Accordion>
<Accordion title="GET /contents/trends">
Récupère les contenus les plus populaires du moment. Cache de 5 minutes.
**Query Params :** `limit`, `offset`.
**Réponses :**
- `200 OK` : Liste des tendances.
</Accordion>
<Accordion title="GET /contents/recent">
Récupère les contenus les plus récents. Cache de 1 minute.
**Query Params :** `limit`, `offset`.
**Réponses :**
- `200 OK` : Liste des contenus récents.
</Accordion> </Accordion>
<Accordion title="GET /contents/:idOrSlug"> <Accordion title="GET /contents/:idOrSlug">
Récupère un contenu par son ID ou son Slug. Cache de 1 heure. Récupère un contenu par son ID ou son Slug.
**Détection de Bots (SEO) :** **Détection de Bots (SEO) :**
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**. 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.
**Réponses :**
- `200 OK` : Objet Contenu ou Rendu HTML (Bots).
- `404 Not Found` : Contenu inexistant.
</Accordion>
<Accordion title="POST /contents">
Crée une entrée de contenu à partir d'une ressource déjà uploadée ou externe.
**Corps de la requête :**
- `type` : `meme` | `gif`
- `title` (string, max: 255)
- `storageKey` (string, max: 512) : Clé du fichier sur S3.
- `mimeType` (string, max: 128)
- `fileSize` (number)
- `categoryId` (uuid, optional)
- `tags` (string[], optional)
**Réponses :**
- `201 Created` : Contenu référencé.
- `401 Unauthorized` : Non authentifié.
</Accordion> </Accordion>
<Accordion title="POST /contents/upload"> <Accordion title="POST /contents/upload">
Upload un fichier et crée le contenu associé en une seule étape. Upload un fichier avec traitement automatique.
**Type :** `multipart/form-data` **Type :** `multipart/form-data`
**Champs :** **Champs :**
- `file` (binary) : png, jpeg, webp, webm, gif. - `file` (binary) : png, jpeg, webp, webm, gif.
- `type` : `meme` | `gif` - `type` : `meme` | `gif`
- `title` (string) - `title` : string
- `categoryId` (uuid, optional) - `categoryId`? : uuid
- `tags` (string[], optional) - `tags`? : string[]
**Réponses :**
- `201 Created` : Upload réussi et contenu créé.
- `400 Bad Request` : Fichier non supporté ou données invalides.
</Accordion> </Accordion>
<Accordion title="POST /contents/upload-url"> <Accordion title="POST /contents/:id/view | /use">
Génère une URL présignée pour un upload direct vers S3. Incrémente les statistiques de vue ou d'utilisation.
**Query Param :**
- `fileName` (string) : Nom du fichier avec extension.
**Réponses :**
- `201 Created` : Retourne l'URL présignée et les champs requis.
</Accordion>
<Accordion title="POST /contents/:id/view">
Incrémente le compteur de vues d'un contenu.
**Réponses :**
- `201 Created` : Compteur incrémenté.
</Accordion>
<Accordion title="POST /contents/:id/use">
Incrémente le compteur d'utilisation (clic sur "Utiliser").
**Réponses :**
- `201 Created` : Compteur incrémenté.
</Accordion> </Accordion>
<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.
**Réponses :**
- `200 OK` : Contenu supprimé.
- `403 Forbidden` : Tentative de supprimer le contenu d'autrui.
</Accordion>
<Accordion title="DELETE /contents/:id/admin">
Supprime définitivement un contenu. **Réservé aux administrateurs.**
**Réponses :**
- `200 OK` : Contenu supprimé définitivement.
</Accordion> </Accordion>
</Accordions> </Accordions>
### 📂 Catégories (`/categories`) ### 📂 Catégories, ⭐ Favoris, 🚩 Signalements
<Accordions> <Accordions>
<Accordion title="GET /categories"> <Accordion title="Catégories (/categories)">
Liste toutes les catégories de mèmes disponibles. Cache de 1 heure. - `GET /categories` : Liste toutes les catégories.
- `POST /categories` : Création (Admin uniquement).
**Réponses :**
- `200 OK` : Liste d'objets catégorie.
</Accordion> </Accordion>
<Accordion title="GET /categories/:id"> <Accordion title="Favoris (/favorites)">
Récupère les détails d'une catégorie spécifique. - `GET /favorites` : Liste les favoris de l'utilisateur.
- `POST /favorites/:contentId` : Ajoute un favori.
**Réponses :** - `DELETE /favorites/:contentId` : Retire un favori.
- `200 OK` : Objet catégorie.
- `404 Not Found` : Catégorie non trouvée.
</Accordion> </Accordion>
<Accordion title="POST /categories"> <Accordion title="Signalements (/reports)">
Crée une nouvelle catégorie. **Admin uniquement.** - `POST /reports` : Signale un contenu ou un tag.
- `GET /reports` : Liste (Modérateurs).
**Corps de la requête :** - `PATCH /reports/:id/status` : Gère le workflow.
- `name` (string, max: 64)
- `description` (string, optional, max: 255)
- `iconUrl` (string, optional, max: 512)
**Réponses :**
- `201 Created` : Catégorie créée.
</Accordion>
<Accordion title="PATCH /categories/:id">
Met à jour une catégorie existante. **Admin uniquement.**
**Corps de la requête :** (Tous optionnels) `name`, `description`, `iconUrl`.
**Réponses :**
- `200 OK` : Catégorie mise à jour.
</Accordion>
<Accordion title="DELETE /categories/:id">
Supprime une catégorie. **Admin uniquement.**
**Réponses :**
- `200 OK` : Catégorie supprimée.
</Accordion> </Accordion>
</Accordions> </Accordions>
### ⭐ Favoris (`/favorites`) ### 🔑 Clés API & 🏷️ Tags
<Accordions> <Accordions>
<Accordion title="GET /favorites"> <Accordion title="Clés API (/api-keys)">
Liste les favoris de l'utilisateur connecté. - `POST /api-keys` : Génère une clé `{ name, expiresAt? }`.
- `GET /api-keys` : Liste les clés actives.
**Query Params :** - `DELETE /api-keys/:id` : Révoque une clé.
- `limit` (number) : Défaut 10.
- `offset` (number) : Défaut 0.
**Réponses :**
- `200 OK` : Liste paginée des favoris.
- `401 Unauthorized` : Non authentifié.
</Accordion> </Accordion>
<Accordion title="POST /favorites/:contentId"> <Accordion title="Tags (/tags)">
Ajoute un contenu aux favoris de l'utilisateur. - `GET /tags` : Recherche de tags populaires ou récents.
**Params :** `query`, `sort`, `limit`.
**Réponses :**
- `201 Created` : Favori ajouté.
</Accordion>
<Accordion title="DELETE /favorites/:contentId">
Retire un contenu des favoris de l'utilisateur.
**Réponses :**
- `200 OK` : Favori supprimé.
</Accordion>
</Accordions>
### 🚩 Signalements (`/reports`)
<Accordions>
<Accordion title="POST /reports">
Signale un contenu ou un tag pour modération.
**Corps de la requête :**
- `contentId` (uuid, optional) : ID du contenu à signaler.
- `tagId` (uuid, optional) : ID du tag à signaler.
- `reason` : `inappropriate` | `spam` | `copyright` | `other`
- `description` (string, optional, max: 1000)
**Réponses :**
- `201 Created` : Signalement enregistré.
</Accordion>
<Accordion title="GET /reports">
Liste les signalements. **Réservé aux administrateurs et modérateurs.**
**Query Params :**
- `limit` (number) : Défaut 10.
- `offset` (number) : Défaut 0.
**Réponses :**
- `200 OK` : Liste des signalements.
</Accordion>
<Accordion title="PATCH /reports/:id/status">
Met à jour le statut d'un signalement. **Réservé aux administrateurs et modérateurs.**
**Corps de la requête :**
- `status` : `pending` | `reviewed` | `resolved` | `dismissed`
**Réponses :**
- `200 OK` : Statut mis à jour.
</Accordion>
</Accordions>
### 🔑 Clés API (`/api-keys`)
<Accordions>
<Accordion title="POST /api-keys">
Génère une nouvelle clé API pour l'utilisateur.
**Corps de la requête :**
- `name` (string, max: 128) : Nom descriptif de la clé.
- `expiresAt` (date-string, optional) : Date d'expiration.
**Réponses :**
- `201 Created` : Clé générée. Retourne le token (à conserver précieusement, ne sera plus affiché).
</Accordion>
<Accordion title="GET /api-keys">
Liste toutes les clés API actives de l'utilisateur.
**Réponses :**
- `200 OK` : Liste des métadonnées des clés (nom, date de création, expiration).
</Accordion>
<Accordion title="DELETE /api-keys/:id">
Révoque une clé API spécifique.
**Réponses :**
- `200 OK` : Clé révoquée.
</Accordion>
</Accordions>
### 🏷️ Tags (`/tags`)
<Accordions>
<Accordion title="GET /tags">
Liste les tags populaires ou recherchés. Cache de 5 minutes.
**Query Params :**
- `limit` (number) : Défaut 10.
- `offset` (number) : Défaut 0.
- `query` (string, optional) : Recherche par nom.
- `sort` : `popular` | `recent`
**Réponses :**
- `200 OK` : Liste paginée des tags.
</Accordion>
</Accordions>
### 🛠️ Système (`/health`)
<Accordions>
<Accordion title="GET /health">
Vérifie l'état de santé de l'API et de ses dépendances (DB, Redis).
**Réponses :**
- `200 OK` : Système opérationnel.
```json
{
"status": "ok",
"timestamp": "2024-01-21T10:00:00.000Z",
"database": "connected",
"redis": "connected"
}
```
- `503 Service Unavailable` : Problème sur l'un des composants.
</Accordion>
</Accordions>
### 📁 Médias (`/media`)
<Accordions>
<Accordion title="GET /media">
Sert un fichier média stocké sur S3 avec une gestion optimisée du cache.
**Query Params :**
- `path` (string) : Chemin relatif du fichier sur le bucket.
**Réponses :**
- `200 OK` : Flux binaire du fichier. Headers `Content-Type` et `Cache-Control` inclus.
- `404 Not Found` : Fichier introuvable.
</Accordion>
</Accordions>
### 📊 Administration (`/admin`)
<Accordions>
<Accordion title="GET /admin/stats">
Récupère les statistiques globales d'utilisation 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?path=...`.
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,6 +1,6 @@
{ {
"name": "@memegoat/documentation", "name": "@memegoat/documentation",
"version": "0.1.0", "version": "0.0.1",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "next build", "build": "next build",

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

@@ -3,18 +3,6 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
reactCompiler: true, reactCompiler: true,
images: {
remotePatterns: [
{
protocol: "https",
hostname: "memegoat.fr",
},
{
protocol: "https",
hostname: "api.memegoat.fr",
},
],
},
output: "standalone", output: "standalone",
}; };

View File

@@ -1,13 +1,12 @@
{ {
"name": "@memegoat/frontend", "name": "@memegoat/frontend",
"version": "1.2.1", "version": "0.0.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "biome check", "lint": "biome check",
"lint:write": "biome check --write",
"format": "biome format --write" "format": "biome format --write"
}, },
"dependencies": { "dependencies": {

View File

@@ -98,7 +98,7 @@ export default function AdminContentsPage() {
<TableCell className="font-medium"> <TableCell className="font-medium">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded bg-muted"> <div className="flex h-10 w-10 items-center justify-center rounded bg-muted">
{content.mimeType.startsWith("image/") ? ( {content.type === "image" ? (
<ImageIcon className="h-5 w-5 text-muted-foreground" /> <ImageIcon className="h-5 w-5 text-muted-foreground" />
) : ( ) : (
<Video className="h-5 w-5 text-muted-foreground" /> <Video className="h-5 w-5 text-muted-foreground" />

View File

@@ -63,7 +63,7 @@ export default function HelpPage() {
<p className="text-muted-foreground"> <p className="text-muted-foreground">
N'hésitez pas à nous contacter sur nos réseaux sociaux ou par email. N'hésitez pas à nous contacter sur nos réseaux sociaux ou par email.
</p> </p>
<p className="font-semibold text-primary">contact@memegoat.fr</p> <p className="font-semibold text-primary">contact@memegoat.local</p>
</div> </div>
</div> </div>
); );

View File

@@ -1,7 +1,6 @@
import * as React from "react"; import * as React from "react";
import { AppSidebar } from "@/components/app-sidebar"; import { AppSidebar } from "@/components/app-sidebar";
import { MobileFilters } from "@/components/mobile-filters"; import { MobileFilters } from "@/components/mobile-filters";
import { ModeToggle } from "@/components/mode-toggle";
import { SearchSidebar } from "@/components/search-sidebar"; import { SearchSidebar } from "@/components/search-sidebar";
import { import {
SidebarInset, SidebarInset,
@@ -28,10 +27,7 @@ export default function DashboardLayout({
<div className="flex-1 flex justify-center"> <div className="flex-1 flex justify-center">
<span className="font-bold text-primary text-lg">MemeGoat</span> <span className="font-bold text-primary text-lg">MemeGoat</span>
</div> </div>
<div className="flex items-center gap-2"> <UserNavMobile />
<ModeToggle />
<UserNavMobile />
</div>
</header> </header>
<main className="flex-1 overflow-y-auto bg-zinc-50 dark:bg-zinc-950"> <main className="flex-1 overflow-y-auto bg-zinc-50 dark:bg-zinc-950">
{children} {children}

View File

@@ -1,16 +1,7 @@
"use client"; "use client";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { import { Loader2, Save, User as UserIcon } from "lucide-react";
Laptop,
Loader2,
Moon,
Palette,
Save,
Sun,
User as UserIcon,
} from "lucide-react";
import { useTheme } from "next-themes";
import * as React from "react"; import * as React from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -33,8 +24,6 @@ import {
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/providers/auth-provider"; import { useAuth } from "@/providers/auth-provider";
@@ -48,14 +37,8 @@ const settingsSchema = z.object({
type SettingsFormValues = z.infer<typeof settingsSchema>; type SettingsFormValues = z.infer<typeof settingsSchema>;
export default function SettingsPage() { export default function SettingsPage() {
const { theme, setTheme } = useTheme();
const { user, isLoading, refreshUser } = useAuth(); const { user, isLoading, refreshUser } = useAuth();
const [isSaving, setIsSaving] = React.useState(false); const [isSaving, setIsSaving] = React.useState(false);
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
const form = useForm<SettingsFormValues>({ const form = useForm<SettingsFormValues>({
resolver: zodResolver(settingsSchema), resolver: zodResolver(settingsSchema),
@@ -202,55 +185,6 @@ export default function SettingsPage() {
</Form> </Form>
</CardContent> </CardContent>
</Card> </Card>
<Card className="mt-8">
<CardHeader>
<div className="flex items-center gap-2">
<Palette className="h-5 w-5 text-primary" />
<CardTitle>Apparence</CardTitle>
</div>
<CardDescription>
Personnalisez l'apparence de l'application selon vos préférences.
</CardDescription>
</CardHeader>
<CardContent>
<RadioGroup
value={mounted ? theme : "system"}
onValueChange={(value) => setTheme(value)}
className="grid grid-cols-1 sm:grid-cols-3 gap-4"
>
<div>
<RadioGroupItem value="light" id="light" className="peer sr-only" />
<Label
htmlFor="light"
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer"
>
<Sun className="mb-3 h-6 w-6" />
<span>Clair</span>
</Label>
</div>
<div>
<RadioGroupItem value="dark" id="dark" className="peer sr-only" />
<Label
htmlFor="dark"
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer"
>
<Moon className="mb-3 h-6 w-6" />
<span>Sombre</span>
</Label>
</div>
<div>
<RadioGroupItem value="system" id="system" className="peer sr-only" />
<Label
htmlFor="system"
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer"
>
<Laptop className="mb-3 h-6 w-6" />
<span>Système</span>
</Label>
</div>
</RadioGroup>
</CardContent>
</Card>
</div> </div>
); );
} }

View File

@@ -2,7 +2,6 @@ import type { Metadata } from "next";
import { Ubuntu_Mono, Ubuntu_Sans } from "next/font/google"; import { Ubuntu_Mono, Ubuntu_Sans } from "next/font/google";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { AuthProvider } from "@/providers/auth-provider"; import { AuthProvider } from "@/providers/auth-provider";
import { ThemeProvider } from "@/providers/theme-provider";
import "./globals.css"; import "./globals.css";
const ubuntuSans = Ubuntu_Sans({ const ubuntuSans = Ubuntu_Sans({
@@ -49,9 +48,6 @@ export const metadata: Metadata = {
images: ["/memegoat-og.png"], images: ["/memegoat-og.png"],
}, },
icons: "/memegoat-color.svg", icons: "/memegoat-color.svg",
metadataBase: new URL(
process.env.NEXT_PUBLIC_APP_URL || "https://memegoat.fr",
),
}; };
export default function RootLayout({ export default function RootLayout({
@@ -64,17 +60,10 @@ export default function RootLayout({
<body <body
className={`${ubuntuSans.variable} ${ubuntuMono.variable} antialiased`} className={`${ubuntuSans.variable} ${ubuntuMono.variable} antialiased`}
> >
<ThemeProvider <AuthProvider>
attribute="class" {children}
defaultTheme="system" <Toaster />
enableSystem </AuthProvider>
disableTransitionOnChange
>
<AuthProvider>
{children}
<Toaster />
</AuthProvider>
</ThemeProvider>
</body> </body>
</html> </html>
); );

View File

@@ -19,7 +19,6 @@ import {
import Link from "next/link"; import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation"; import { usePathname, useSearchParams } from "next/navigation";
import * as React from "react"; import * as React from "react";
import { ModeToggle } from "@/components/mode-toggle";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { import {
Collapsible, Collapsible,
@@ -229,7 +228,7 @@ export function AppSidebar() {
<span className="truncate font-semibold"> <span className="truncate font-semibold">
{user.displayName || user.username} {user.displayName || user.username}
</span> </span>
<span className="truncate text-xs">{user.role}</span> <span className="truncate text-xs">{user.email}</span>
</div> </div>
<ChevronRight className="ml-auto size-4 group-data-[collapsible=icon]:hidden" /> <ChevronRight className="ml-auto size-4 group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton> </SidebarMenuButton>
@@ -287,14 +286,6 @@ export function AppSidebar() {
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
)} )}
<SidebarMenuItem>
<div className="flex items-center justify-between px-2 py-2">
<span className="text-xs font-medium text-muted-foreground group-data-[collapsible=icon]:hidden">
Thème
</span>
<ModeToggle />
</div>
</SidebarMenuItem>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Aide"> <SidebarMenuButton asChild tooltip="Aide">
<Link href="/help"> <Link href="/help">

View File

@@ -93,9 +93,9 @@ export function ContentCard({ content }: ContentCardProps) {
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent className="p-0 relative bg-zinc-200 dark:bg-zinc-900 aspect-square flex items-center justify-center"> <CardContent className="p-0 relative bg-zinc-100 dark:bg-zinc-900 aspect-square flex items-center justify-center">
<Link href={`/meme/${content.slug}`} className="w-full h-full relative"> <Link href={`/meme/${content.slug}`} className="w-full h-full relative">
{content.mimeType.startsWith("image/") ? ( {content.type === "image" ? (
<Image <Image
src={content.url} src={content.url}
alt={content.title} alt={content.title}

View File

@@ -1,34 +0,0 @@
"use client";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function ModeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-9 w-9">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Changer le thème</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>Clair</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>Sombre</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
Système
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -53,10 +53,7 @@ api.interceptors.response.use(
} catch (refreshError) { } catch (refreshError) {
// If refresh fails, we might want to redirect to login on the client // If refresh fails, we might want to redirect to login on the client
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
// On évite de rediriger vers login si on y est déjà pour éviter les boucles window.location.href = "/login";
if (!window.location.pathname.includes("/login")) {
window.location.href = "/login";
}
} }
return Promise.reject(refreshError); return Promise.reject(refreshError);
} }

View File

@@ -26,8 +26,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const router = useRouter(); const router = useRouter();
const refreshUser = React.useCallback(async () => { const refreshUser = React.useCallback(async () => {
// Éviter de lancer plusieurs refresh en même temps
if (!isLoading) setIsLoading(true);
try { try {
const userData = await UserService.getMe(); const userData = await UserService.getMe();
setUser(userData); setUser(userData);
@@ -36,26 +34,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [isLoading]); }, []);
React.useEffect(() => { React.useEffect(() => {
let isMounted = true; refreshUser();
const initAuth = async () => { }, [refreshUser]);
try {
const userData = await UserService.getMe();
if (isMounted) setUser(userData);
} catch (_error) {
if (isMounted) setUser(null);
} finally {
if (isMounted) setIsLoading(false);
}
};
initAuth();
return () => {
isMounted = false;
};
}, []);
const login = async (email: string, password: string) => { const login = async (email: string, password: string) => {
try { try {

View File

@@ -1,11 +0,0 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import type * as React from "react";
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@@ -7,7 +7,7 @@ export interface Content {
description?: string; description?: string;
url: string; url: string;
thumbnailUrl?: string; thumbnailUrl?: string;
type: "meme" | "gif"; type: "image" | "video";
mimeType: string; mimeType: string;
size: number; size: number;
width?: number; width?: number;

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

@@ -1,25 +1,16 @@
{ {
"name": "@memegoat/source", "name": "@memegoat/source",
"version": "1.2.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",
"build:docs": "pnpm run -F @memegoat/documentation build", "build:docs": "pnpm run -F @memegoat/documentation build",
"lint": "pnpm run lint:back && pnpm run lint:front && pnpm run lint:docs", "lint": "pnpm run lint:back && pnpm run lint:front && pnpm run lint:docs",
"lint:fix": "pnpm run lint:back:fix && pnpm run lint:front:fix && pnpm run lint:docs:fix",
"lint:back": "pnpm run -F @memegoat/backend lint", "lint:back": "pnpm run -F @memegoat/backend lint",
"lint:back:fix": "pnpm run -F @memegoat/backend lint:write",
"lint:front": "pnpm run -F @memegoat/frontend lint", "lint:front": "pnpm run -F @memegoat/frontend lint",
"lint:front:fix": "pnpm run -F @memegoat/frontend lint:write",
"lint:docs": "pnpm run -F @memegoat/documentation lint", "lint:docs": "pnpm run -F @memegoat/documentation lint",
"lint:docs:fix": "pnpm run -F @memegoat/documentation lint:write",
"test": "pnpm run test:back && pnpm run test:front", "test": "pnpm run test:back && pnpm run test:front",
"test:back": "pnpm run -F @memegoat/backend test", "test:back": "pnpm run -F @memegoat/backend test",
"test:front": "pnpm run -F @memegoat/frontend test", "test:front": "pnpm run -F @memegoat/frontend test",

View File

@@ -1,153 +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)
if(CURRENT_VERSION MATCHES "^([0-9]+)\\.([0-9]+)\\.([0-9]+)")
set(MAJOR ${CMAKE_MATCH_1})
set(MINOR ${CMAKE_MATCH_2})
set(PATCH ${CMAKE_MATCH_3})
else()
message(FATAL_ERROR "Format de version invalide: ${CURRENT_VERSION}. Attendu: X.Y.Z")
endif()
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 commit git pour les changements de version
function(commit_version_changes VERSION)
find_package(Git QUIET)
if(GIT_FOUND)
# On n'ajoute que les fichiers package.json modifiés
set(ADDED_ANY FALSE)
foreach(JSON_FILE ${PACKAGE_JSON_FILES})
if(EXISTS "${JSON_FILE}")
execute_process(
COMMAND ${GIT_EXECUTABLE} add "${JSON_FILE}"
WORKING_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}"
)
set(ADDED_ANY TRUE)
endif()
endforeach()
if(ADDED_ANY)
# On commit uniquement les fichiers qui ont été ajoutés (staged)
# L'utilisation de --only ou spécifier les fichiers à nouveau assure qu'on ne prend pas d'autres changements
execute_process(
COMMAND ${GIT_EXECUTABLE} commit -m "chore: bump version to ${VERSION}" -- ${PACKAGE_JSON_FILES}
WORKING_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}"
RESULT_VARIABLE COMMIT_RESULT
)
if(COMMIT_RESULT EQUAL 0)
message(STATUS "Changements commités avec succès pour la version ${VERSION}")
else()
message(WARNING "Échec du commit des changements. Il n'y a peut-être rien à commiter ou aucun changement sur les fichiers JSON.")
endif()
endif()
else()
message(WARNING "Git non trouvé, impossible de commiter les changements.")
endif()
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()
# Commiter les changements
commit_version_changes(${NEW_VERSION})
# Créer le tag git
create_git_tag(${NEW_VERSION})
endfunction()
# Logique principale
if(CMAKE_SCRIPT_MODE_FILE STREQUAL CMAKE_CURRENT_LIST_FILE)
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()
endif()